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).
Continua leyendo
Leer más sobre: Programación, SEO