Prompt: Actualización en bulk de interenlazado sugerido por Ahrefs

Genérame **un script Python + REST API de WordPress** para automatizar la inserción de enlaces internos a partir del CSV de “Link Opportunities” de Ahrefs. Quiero **máxima robustez**, **idempotencia real** (si corro la misma fila dos veces, NO debe volver a insertar), **dry-run**, posibilidad de ejecutar **una sola fila** para test o **todo el CSV en bulk**, y un **menú interactivo** (terminal) para operar sin memorizar comandos. A continuación está el **brief técnico completo** que debes implementar **sin omitir nada**.

---

## Ampliaciones y ajustes obligatorios (actualizado)

- Resolver (WPML y coincidencia exacta):
  - Usar `slug` + filtros de idioma (`lang` y `wpml_language`) en `GET /wp/v2/posts|pages?slug=...`.
  - Aceptar solo coincidencia exacta de permalink canónico con la `source_url` (normalizada) cuando se use `/wp/v2/search`.
  - Fallback adicional: consultar por `search` en `posts/pages` y, en último extremo, extraer `post_id` del HTML público (shortlink o `postid-XXXX`) y validar por REST.

- Fetch/Apply/Rollback en modo editor (preservar bloques Gutenberg y plugins dinámicos):
  - Al leer: `GET /wp/v2/{type}/{id}?context=edit` y preferir `content.raw`.
  - Al aplicar: enviar `{ "content": <raw_editado> }` para no “renderizar” bloques dinámicos.
  - En rollback: restaurar el `raw` guardado y, si se dispone, registrar `revision_id` (consultar `/revisions`).

- Matching del párrafo (robustez):
  - Normalizar NBSP (\u00A0) → espacio.
  - Soportar coincidencia “cross‑node” (texto partido por etiquetas) en `<p>/<li>`.
  - Incluir celdas de tabla (`td/th`) como candidatos cuando no haya `<p>/<li>` válidos.
  - Fallback: si el mejor por contexto no contiene el anchor, elegir el primer `<p>/<li>`/`td/th` que lo contenga.
  - Modo relajado “tokens en orden” (ignora stopwords comunes: de, del, la, the, of, and, etc.) cuando el match exacto falle.

- Inserción segura e idempotente:
  - Mantener políticas y zonas a omitir. Añadir razones de skip detalladas:
    - `skip_in_heading` (anchor dentro de h1..h6)
    - `skip_in_existing_link` (anchor ya dentro de un `<a>` distinto)
    - Además de `already_linked`, `spacing`, `quota`, `no_anchor_found`, etc.

- Diffs y reporting:
  - Si `difflib.HtmlDiff` recursiona por líneas muy largas, hacer fallback a HTML simple con unified diff.
  - Incluir en `summary.json` las nuevas razones de skip detalladas.

- CLI y Menú (UX):
  - Nuevo comando de depuración: `debug-resolve --url` (muestra pasos de resolución) y `debug-id --id --type`.
  - Aplicar todo el plan o “solo una fila” (`--row-index N`) tanto en CLI como en el menú.
  - En el menú, al aplicar solo una fila, mostrar preview segura: `source_url`, `anchor`, `target_url` antes de confirmar.
  - Progreso con `tqdm` en `plan` y en `apply` (cuando se aplica todo el plan).

- Estadísticas:
  - Mostrar en el resumen final skips por `skip_in_heading` y `skip_in_existing_link` además de las existentes.

# Contexto del sitio

* **Editor**: Gutenberg (bloques) .
* **No** se usa Elementor ni otros page builders.
* Por tanto, el contenido a editar estará en `post_content` (bloques Gutenberg/HTML clásico).
* Debes **preservar** los comentarios de bloque de Gutenberg (`<!-- wp:... -->`) y no romper la estructura de bloques.

---

# Objetivo

A partir de un CSV exportado de Ahrefs con, como mínimo, estas columnas:

* `Source page` (URL absoluta de la página donde insertar el enlace)
* `Keyword` (texto exacto del ancla sugerida)
* `Keyword context` (fragmento alrededor del ancla para ubicar el párrafo correcto)
* `Target page` (URL de destino del enlace)

el programa deberá:

1. Resolver cada `Source page` a su **ID de post** en WordPress.
2. Descargar de WordPress el contenido del post (`post_content`).
3. Localizar de forma **fuzzy** el **párrafo** que mejor coincide con `Keyword context` y, dentro de él, **envolver la primera aparición** de `Keyword` con `<a href="Target page">…</a>`.
4. Subir los cambios vía REST usando **Application Passwords** (Basic Auth).
5. Registrar **logs**, **diffs**, y permitir **rollback** por lote o selectivo por post.
6. Poder ejecutar **en dry-run** (sin escribir en WP), **sobre una sola fila** (para test), **un subconjunto filtrado**, o **en bulk**.

---

# Estructura del proyecto

```
menu.py                 # Menú interactivo (entrada principal para humanos)
linker/
├─ cli.py                  # Interfaz argparse (equivalente no interactiva)
├─ config.py               # Carga de .env / TOML y validación
├─ csv_loader.py           # Lectura y validación del CSV de Ahrefs
├─ mapper.py               # Normalización de columnas / mapeo flexible
├─ wp_client.py            # Cliente REST WP (auth, retries, rate limit)
├─ resolver.py             # Resolución URL → (post_type, post_id)
├─ fetcher.py              # Descarga de contenido, meta y revisiones
├─ detector.py             # Detección de editor (Gutenberg/Clásico/otros)
├─ matching.py             # Selección de párrafo por contexto (fuzzy)
├─ editor.py               # Inserción segura del <a> (HTML block-aware)
├─ policies.py             # Reglas (máx enlaces/post, distancias, skip zonas)
├─ differ.py               # Generación de diffs (unified + HTML side-by-side)
├─ planner.py              # Construcción del plan de cambios (idempotente)
├─ executor.py             # Aplicación de cambios (apply), con backups
├─ rollback.py             # Reversión por job_id / por post
├─ report.py               # Reportes (CSV/JSON/HTML) por ejecución
├─ store.py                # Estado local (SQLite o JSONL: jobs, backups, hashes)
├─ utils.py                # Utilidades generales
├─ pyproject.toml          # Dependencias (Poetry) o requirements.txt
└─ tests/                  # Unit tests de matching, editor, policies
```

---

# Configuración

* **.env** (y opcional `config.toml`). Usa estos valores por defecto:

  ```
  WP_BASE_URL=https://*****
  WP_USER=***
  # IMPORTANTE: la contraseña incluye caracteres especiales; rodearla de comillas simples si usas shells que expanden $/&.
  # Ejemplo: WP_APP_PASSWORD=*****
  WP_APP_PASSWORD=*****
  WP_TIMEOUT=30
  WP_CONCURRENCY=2
  SITE_HOST=*****

  # Políticas por defecto (sobrescribibles por CLI)
  MAX_LINKS_PER_POST=5
  MIN_CHARS_BETWEEN_LINKS=100
  CASE_INSENSITIVE=true
  MATCH_ACCENT_INSENSITIVE=true
  CONTEXT_SCORE_THRESHOLD=70
  SKIP_TAGS=h1,h2,h3,h4,h5,h6,a,code,pre,figcaption
  ONLY_INTERNAL=true
  ALLOW_EXTERNAL=false
  SKIP_IF_UNKNOWN_BUILDER=false
  SKIP_IF_ALREADY_LINKED_TO_TARGET=true
  ```

* **Notas**:

  * No comitear `.env` al repositorio.
  * `WP_APP_PASSWORD` es un **Application Password** de WordPress (WP ≥5.6).
  * `SITE_HOST` se usa para decidir si un enlace es interno.

---

# Librerías sugeridas

`requests`, `tenacity` (retries/backoff), `python-dotenv`, `pandas` (CSV robusto), `beautifulsoup4` + `lxml` (HTML seguro), `rapidfuzz` (fuzzy match), `tqdm` (progreso), `unidecode` (acentos), `regex` (mejor que `re`), `packaging`, `python-slugify`, **`colorama`** (coloreado de salidas).
Opcional: `sqlite3` (incluido en stdlib) para `store.py`.

> **NOTA:** No uses `rich` aquí; el coloreado y estilos terminal se hacen con **colorama** (requisito).

---

# CSV y normalización

* **Detección de encoding** (UTF-8/UTF-16) y delimitador (coma/tab).
* Validar presencia de columnas mínimas; permitir **mapeo flexible**:

  * Ej.: `--map "Source page=Source URL, Keyword=Anchor, Keyword context=Context, Target page=Target URL"`.
* Limpieza:

  * Normalizar URLs (https, trailing slash coherente, decode).
  * Trim de campos y colapsar espacios.
  * Descartar filas con campos esenciales vacíos.
* **Filtrado CLI**:

  * `--row-index 123` (ejecuta SOLO esa fila; ideal para tests).
  * `--limit 20` (primeras N filas).
  * `--sample 20` (aleatorio reproducible con `--seed`).
  * `--filter "Source page~='/madrid/' and Target page~='/free-tour-.*'"`
    (operadores: `==`, `!=`, `~=` regex, `in` coma-list; columnas del CSV).

---

# Resolución de la URL fuente → post ID

Estrategia **escalonada** y cacheada:

1. Extraer `path` y `slug` de `Source page`.
2. `GET /wp-json/wp/v2/search?search={slug}&subtype=any&per_page=10`.
3. Si hay múltiples, elegir por similitud entre `link` y `Source page` normalizada.
4. Fallback: `GET /wp-json/wp/v2/posts?slug={slug}` y equivalentes para `pages` y CPT conocidos (lista configurable).
5. Si no se resuelve, marcar como **unmatched\_source** y seguir.

Guardar la **resolución** en `store` (cache local) para no repetir llamadas.

---

# Descarga de contenido y detección de editor

* `GET /wp-json/wp/v2/{post_type}/{id}` → usar `content.raw` si está disponible; si no, `content.rendered` (y reconvertir con cuidado).
* **Detector**:

  * **Gutenberg**: presencia de comentarios `<!-- wp:` en `post_content`.
  * **Clásico**: ausencia de comentarios de bloque y estructura HTML plana.
  * Si el editor es desconocido y `SKIP_IF_UNKNOWN_BUILDER=true`, saltar con motivo `unknown_builder`.
* **No** hay manejo de Elementor (no se usa en el sitio).

---

# Matching del párrafo por contexto (fuzzy)

* Candidatos editables: nodos de `<p>` y `<li>` que **no** estén dentro de `SKIP_TAGS` ni contengan `<a>` anidado en el fragmento a tocar.
* Para cada candidato:

  * **Texto limpio**: normalizar case y acentos según flags; colapsar espacios.
  * **Score** contra `Keyword context` con `rapidfuzz.fuzz.token_set_ratio` (u otro de `rapidfuzz`).
* Selección:

  * Elegir el candidato con **mejor score** ≥ `CONTEXT_SCORE_THRESHOLD` (default 70).
  * Si ninguno supera el umbral, fallback: **primer párrafo** que contenga `Keyword` (según sensibilidad configurada).
* Guardar `match_reason` (context/fallback) y el índice del bloque/párrafo.

---

# Inserción **segura** del enlace

* Dentro del candidato elegido, envolver **la primera** aparición **no anidada** de `Keyword` en `<a href="Target page">…</a>`.
* **Idempotencia robusta** (si se ejecuta dos veces sobre la misma línea **no** re-inserta):

  1. **Detección por DOM**: si ya existe un `<a>` cuyo `href` apunta al **mismo destino canónico** (normalizar: esquema, host, quitar `utm_*`, `#` anchor opcional, y tolerar `/` final), y el **texto visible** del enlace coincide (tolerancia a espacios/acentos según flags), **SKIP** con motivo `already_linked`.
  2. **Fingerprint local**: generar hash del **contexto local** (± N caracteres alrededor) + `target_url` guardado en `store.applied_links`. Si ese fingerprint existe (mismo post), **SKIP** con motivo `duplicate_fingerprint`.
  3. **Búsqueda flexible**: si el `<a>` existe pero con atributos extra (`rel`, `target`) o con URL equivalente (http↔https, trailing slash), también **SKIP**.
* Construcción del `<a>`:

  * `href=Target page` (normalizada).
  * Enlaces **internos** (mismo `SITE_HOST`): sin `rel`, sin `target="_blank"`.
  * Si en algún momento se permite externos (`ALLOW_EXTERNAL=true`): `rel="nofollow noopener noreferrer"` y `target="_blank"` opcional.
* **Políticas**:

  * `MAX_LINKS_PER_POST=5` por defecto, **contando SOLO** los enlaces **insertados por este job** (no cuentan los preexistentes).
  * `MIN_CHARS_BETWEEN_LINKS=100` por defecto: si dentro del mismo post ya insertaste un enlace y la nueva inserción quedaría a < 100 chars, intenta el siguiente párrafo candidato; si no lo hay, **skip** con `spacing`.
  * Nunca enlazar en `SKIP_TAGS`.
* Serialización:

  * Usar **BeautifulSoup (lxml)**; **no** hacer pretty-print.
  * **Preservar** comentarios de Gutenberg y estructura de bloques.
* Validación post-edición:

  * HTML bien formado, sin `<a>` anidados, texto visible del ancla coherente.

---

# Plan de ejecución (planner)

* Construir un **plan** por fila válida:

  * `job_id` (UUID), `row_index`, `source_url`, `(post_type, post_id)`, `target_url`, `anchor`, `strategy` (context/fallback), `pre_hash`, `post_hash`, `diff_summary`.
* **Idempotencia**:

  * `pre_hash` = hash del `post_content` original.
  * Antes de **apply**, re-leer el post; si cambió, re-planificar ese post o marcar `conflict`.
  * Si encuentra que ya está enlazado (reglas de arriba), marcar `noop` con motivo `already_linked` o `duplicate_fingerprint`.
* **Diffs**:

  * Unified diff (`.patch`) y **HTML side-by-side** resaltando el párrafo afectado.

---

# Modos de ejecución

## Dry-run (por defecto)

* **No** escribe en WP.
* Emite:

  * Conteo de filas **procesables** / **saltadas**.
  * Plan en `reports/{timestamp}/plan.json`.
  * Diffs (`.patch` y `.html`).
  * Métricas: % por contexto, % fallback, razones de skip (sin ID, sin match, already\_linked, spacing, quota, unknown\_builder), top posts por nº de acciones planificadas.

## Apply

* Aplica el plan **post a post**:

  * **Backup** del `post_content` original por post → `backups/{post_type}-{post_id}-{timestamp}.html`.
  * PUT `/wp/v2/{post_type}/{id}` con `content` actualizado (usar `content.raw` si disponible).
  * Reintentos (`tenacity`) para 429/5xx; `WP_CONCURRENCY` con semáforo (por defecto 2).
  * Guardar `revision_id` si WP devuelve revisiones.
  * Registrar en `store.applied_links` cada inserción con **fingerprint**.

## Rollback

* **Deshacer la última ejecución** completa (por `job_id`) o **selectivo por posts** (`--post-id` múltiples).
* Restaurar desde backup local **o** usando `revision_id` si está disponible.
* Validaciones e informe similar a `apply`.

---

# Interfaz de línea de comandos (argparse)

Ejecutable como módulo:

```
python -m linker.cli [comando] [opciones]
```

**Comandos:**

* `validate` → valida CSV y configuración.
* `plan` → genera plan y reports en **dry-run**.
* `apply` → aplica un plan (usa el último `reports/{timestamp}` por defecto o `--plan plan.json`).
* `rollback` → revierte por `--job-id <uuid>` o por `--post-id 123[,456...]`.
* `status` → muestra últimos jobs y métricas.

**Opciones comunes:**

* `--input oportunidades.csv`
* `--map "Source page=Source URL,Keyword=Anchor,Keyword context=Context,Target page=Target URL"`
* `--row-index N`
* `--limit N` / `--sample N` / `--seed 42`
* `--filter "<expresión>"`
* `--only-internal / --allow-external`
* `--max-links-per-post 5`
* `--min-chars-between-links 100`
* `--context-threshold 70`
* `--concurrency 2`
* `--dry-run / --apply`
* `--plan path/to/plan.json` (para `apply`)
* `--job-id <uuid>` (para `rollback`)
* `--post-id 123[,456]` (para `rollback` selectivo)
* `--verbose` / `--quiet`

---

# Menú interactivo (menu.py)

Al ejecutar `python -m menu`:

1. **Validar CSV**

   * Solicitar ruta CSV, mostrar columnas detectadas, permitir mapeo interactivo.
   * Salida coloreada con **colorama** (verde OK, amarillo warnings, rojo errores).

2. **Dry-run (plan + report)**

   * Elegir CSV y filtros (`row-index`, `limit`, `sample`, `filter`).
   * Mostrar **preview** de 5 diffs (resumen textual) y métricas clave.
   * Guardar reports en `reports/{timestamp}/`.

3. **Aplicar plan**

   * Confirmación explícita (escribe “APLICAR” para continuar).
   * Mostrar políticas activas y límites (`MAX_LINKS_PER_POST`, `MIN_CHARS_BETWEEN_LINKS`).
   * Progreso con `tqdm` y salida coloreada por estado.

4. **Rollback**

   * Listar últimos 10 `job_id` con fecha, nº de posts y acciones.
   * Opción de **rollback total** del job o **selectivo por post**.

5. **Config/Test**

   * Mostrar valores de `.env` (ocultar la contraseña).
   * Probar conexión a WP y permisos (`GET /wp/v2` y `GET /wp/v2/posts?per_page=1`).

6. **Salir**

> Todo el **output** del menú y CLI debe usar **colorama** para resaltar:
> `INFO` (cyan), `OK` (green), `WARN` (yellow), `ERROR` (red), `STATS` (magenta).

---

# Cliente WordPress (wp\_client.py)

* Autenticación **Basic** (`user:app_password`) sobre HTTPS.
* **Retries** exponenciales (429/5xx) con jitter (máx 5 intentos).
* **Rate limit** configurable (p. ej., `2 req/s`).
* Timeout configurable (`WP_TIMEOUT`).
* Helpers:

  * `get_json(path, params)` y `put_json(path, payload)`.
  * Manejo de errores con mensajes legibles (status, fragmento de body).
* Guardar `modified`, `_links` y `id` para trazabilidad.

---

# Editor (editor.py) y Políticas (policies.py)

* **Selección de aparición**: priorizar coincidencias de `Keyword` fuera de etiquetas prohibidas y lo más cercanas al párrafo con mejor score.
* **Espaciado** (`MIN_CHARS_BETWEEN_LINKS`): si la nueva inserción cae demasiado cerca de otra inserción **de este mismo job**, intentar siguiente candidato; si no hay, `skip` `spacing`.
* **Máximo por post** (`MAX_LINKS_PER_POST`): **contar SOLO** los enlaces que este job planea insertar; los preexistentes **no cuentan**.
* **Duplicidad / Idempotencia**:

  * `already_linked` si encuentra un `<a>` existente hacia el mismo destino canónico.
  * `duplicate_fingerprint` si el fingerprint de inserción ya está en `store`.
* **Normalización de coincidencia**:

  * Con `CASE_INSENSITIVE`/`MATCH_ACCENT_INSENSITIVE`, el texto del ancla en el contenido **se respeta tal cual** (no fuerces mayúsculas/minúsculas del CSV).

---

# Differ (differ.py) y Reportes (report.py)

* Generar:

  * `reports/{timestamp}/plan.json` (plan completo).
  * `reports/{timestamp}/summary.json` (métricas agregadas).
  * `reports/{timestamp}/summary.csv` (por fila: status, motivo, post\_id, acción).
  * `reports/{timestamp}/diffs/*.patch` y `diffs_html/*.html`.
* Métricas a incluir:

  * Filas totales / válidas / saltadas (motivo).
  * Posts resueltos / no resueltos.
  * Coincidencias por **contexto** vs **fallback**.
  * Enlaces **insertados**, **noop** (`already_linked`, `duplicate_fingerprint`), **spacing/quota**.
  * Errores por endpoint o validación.
  * Top 10 posts por nº de inserciones planificadas.

---

# Executor (executor.py), Backups y Rollback (rollback.py)

* **Apply**:

  * Antes de PUT, crear **backup** del contenido actual.
  * PUT idempotente: re-leer hash y validar que el plan sigue siendo aplicable; si no, re-planificar el post o marcar `conflict`.
  * Tras éxito, registrar inserciones en `store.applied_links` con fingerprint.
* **Rollback**:

  * Por `job_id` → restaurar **todos** los posts del backup de ese job.
  * Selectivo por `--post-id` → restaurar sólo los indicados.
  * Verificar después de restaurar (differences y estado `OK/ERROR`).

---

# Store (store.py)

* Implementar con **SQLite** (o JSONL si prefieres simple), con tablas:

  * `jobs(job_id, started_at, input_path, filtros, politicas, resumen_json, status)`
  * `backups(job_id, post_id, post_type, path, revision_id, created_at)`
  * `resolutions(source_url, post_id, post_type, last_seen_at)`
  * `applied_links(job_id, post_id, target_url_canon, fingerprint, anchor_text, created_at)`
* **Canonicalización de URL** para `target_url_canon`:

  * Forzar `https`, normalizar host, quitar `utm_*` y otras query de tracking, quitar `#fragment` salvo que sea significativo, tolerar `/` final.

---

# Estadísticas **al final de la ejecución** (terminal)

Imprimir un **bloque resumen** (coloreado con colorama):

* Totales: filas leídas, válidas, planificadas, aplicadas, `noop`, errores.
* Por motivo de **skip**: `unmatched_source`, `no_anchor_found`, `already_linked`, `duplicate_fingerprint`, `spacing`, `quota`, `unknown_builder`, `conflict`.
* Por estrategia: **context** vs **fallback** (% y absolutos).
* Top posts por inserciones (tabla).
* Tiempos: total y promedio por post.

Ejemplo de formato (texto coloreado):

* `[OK] Applied: 128`
* `[WARN] No-ops (already_linked/duplicate): 57`
* `[ERROR] Failed: 3 (ver reports/2025-…/summary.json)`

---

# Interfaz CLI: ejemplos de uso

* **Test rápido de una fila**

  ```
  python -m linker.cli plan --input ahrefs.csv --row-index 42 --dry-run
  ```
* **Subconjunto filtrado y aplicar**

  ```
  python -m linker.cli plan  --input ahrefs.csv --filter "Source page~='/madrid/'" --limit 50
  python -m linker.cli apply --plan reports/2025-08-12_1030/plan.json
  ```
* **Rollback del último job (completo)**

  ```
  python -m linker.cli rollback --job-id a1b2c3d4
  ```
* **Rollback selectivo**

  ```
  python -m linker.cli rollback --job-id a1b2c3d4 --post-id 123,456
  ```
* **Menú interactivo**

  ```
  python -m menu
  ```

---

# Seguridad y fiabilidad

* **Nunca** loguear credenciales ni bodies completos.
* Usar HTTPS siempre.
* Manejar 401/403 con mensajes claros (¿API deshabilitada? ¿Permisos?).
* **Reintentos** con backoff para 429/5xx; respetar `Retry-After` si existe.
* Snapshots/Backups siempre antes de cada PUT; restaurables.
* **Idempotencia fuerte** con doble barrera: detección de `<a href>` existente **y** `fingerprint` persistente.

---

# Tests mínimos (tests/)

* `matching_test.py`: distintos contextos ↔ párrafos (acentos/case/umbral).
* `editor_test.py`: inserción sin `<a>` anidados; respeto de `SKIP_TAGS`; spacing; `MAX_LINKS_PER_POST`.
* `policies_test.py`: cuotas, espaciados, duplicidad.
* `resolver_test.py`: slugs ambiguos, paths con o sin slash, CPT.
* `idempotency_test.py`: misma fila dos veces → `noop` (already\_linked/duplicate\_fingerprint).

---

# Criterios de aceptación

* **Dry-run** completo con reports + diffs **sin tocar WP**.
* **Apply** idempotente: ejecutar **dos veces** la misma fila **no** duplica el enlace.
* `MAX_LINKS_PER_POST=5` por defecto, contando **solo** inserciones del job.
* `MIN_CHARS_BETWEEN_LINKS=100` por defecto.
* Preserva bloques Gutenberg; no rompe comentarios ni crea `<a>` anidados.
* **Rollback** de la última ejecución completo o selectivo por post.
* Estadísticas **completas** al final, coloreadas con **colorama**.
* Manejo de 429/5xx con retries; logs claros; backups por post.

---

# Entregables

1. Proyecto funcional con la estructura indicada.
2. `README.md` con instrucciones (setup, `.env`, permisos WP, ejemplos).
3. **Demostración** con un CSV de ejemplo que genere plan, diffs HTML y aplique en staging (o dry-run completo).
4. Batería de tests unitarios básicos (pytest).
Daniel Pajuelo
Daniel Pajuelo es ingeniero informático y SEO Senior, actualmente trabajando en Guruwalk. En su blog personal escribe sobre Inteligencia Artificial, SEO, Programación... Ver más

Continua leyendo

Leer más sobre: Programación, SEO