Automatizar enlaces internos en WordPress con Ahrefs, Python y GPT-5
💡 ¿Sabías que Ahrefs, en su Site Audit, incluye un informe de Internal Link Opportunities que te sugiere enlaces internos relevantes para mejorar tu SEO on page?
Es oro… con un problema: si tienes muchas URLs afectadas, aplicarlo a mano es inviable.
En mi caso, el informe mostró más de 400 URLs con sugerencias de enlaces internos. Editar los posts uno a uno me habría llevado días, y el posible upside SEO de este accionable se habría diluido por completo. El típico “buen insight que nunca se implementa”.
La solución: automatizar las sugerencias de enlaces internos
La salida fue clara: automatizar el proceso con un script que hiciera el trabajo sucio por mí.
⚡ Monté un flujo en Python que:
1️⃣ Cargaba el CSV de Ahrefs con las Internal Link Opportunities.
2️⃣ Limpaba y filtraba las sugerencias, eliminando las menos relevantes.
3️⃣ Se conectaba a la API de WordPress para localizar los posts afectados.
4️⃣ Actualizaba automáticamente el contenido, añadiendo los enlaces internos sugeridos donde correspondía.
✅ Y todo esto sin escribir una sola línea de código “a mano”, usando GPT-5 y Cursor IDE para generar y refinar el script. Debajo dejo el prompt exacto con el que construí todo el flujo.
Detalles del proceso y lecciones
🔍 Algunos apuntes del proceso:
- El prompt lo preparé previamente con ChatGPT 5 Thinking (GPT-5), definiendo bien el objetivo: automatizar el enlazado interno desde el CSV de Ahrefs.
- No fue un “one-shot”: tuve que iterar y corregir, ajustando el script hasta que respetara el contenido y el formato de los posts.
- El CSV de Ahrefs necesitó limpieza: no todas las sugerencias eran igual de buenas, así que filtré por relevancia antes de actualizar nada en producción.
- Entre limpiar el CSV, programar asistido con Cursor IDE + GPT-5, testear en un entorno controlado y ejecutar en bulk sobre el sitio… todo el proceso tomó unas 2h 30min.
⏳ Tiempo ridículo frente al coste de hacerlo manualmente, revisando post a post desde el informe de Internal Link Opportunities.
Por qué merece la pena automatizar el enlazado interno
- Aprovechas al máximo los datos de Ahrefs Site Audit.
- Aumentas el enlazado interno relevante hacia tus páginas clave (money pages, guías, categorías importantes).
- Escalas un accionable SEO que, de otra forma, se quedaría en el cajón.
Debajo encontrarás el prompt completo que utilicé para que GPT-5 generara el script en Python, listo para conectar el CSV de Ahrefs, la API de WordPress y actualizar tus posts en bloque sin sufrimiento manual.
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).






