diff --git a/remate_prompt.md b/remate_prompt.md index d35ca4f..8036e36 100644 --- a/remate_prompt.md +++ b/remate_prompt.md @@ -55,6 +55,26 @@ CHECKLIST — en este orden (el SEO se deriva del texto FINAL, nunca antes): Primero SIN --apply para revisar las inserciones. Elige anchors que ya existan en la prosa. +3b) PADRINO — quién enlaza HACIA este post (obligatorio en el informe) + + Los enlaces del paso 3 salen de aquí hacia fuera y sirven al lector. Este + paso es el contrario y sirve a Google: si nadie enlaza al post nuevo desde + una página que Google ya visita, tarda semanas en descubrirlo. Medido el + 2026-07-28: dos posts de EN publicados el 21-jul seguían siendo «URL + desconocida para Google» una semana después. Tenían enlaces entrantes, pero + solo desde artículos con 8 y 12 impresiones de por vida. + + python3 ~/seo-tools/seo_semantic.py padrinos $slug --site $site -n 3 + + Ordena por tráfico REAL del origen (Search Console), no por parecido, y + descarta los que ya enlazan y los consolidados con canonical_url. + + NO lo apliques: escribir ese enlace significa editar OTRO post publicado, y + eso te está prohibido. Lo que tienes que hacer es dejar en el informe el + comando exacto, con un anchor que exista de verdad en la prosa del origen + (compruébalo: descarga el origen y busca la frase). Si ningún candidato pasa + de 0,55 de similitud, dilo en vez de inventar un enlace forzado. + 4) METAS + ALT — con seo_finish.py: - `python3 ~/seo-tools/seo_finish.py prep --site $site` descarga la imagen destacada a ~/.cache/seo-finish/. @@ -81,4 +101,8 @@ anchas, <3000 caracteres): - Hallazgos que requieren decisión humana (lo del paso 1 que no tocaste). - Slug final, metas con sus longitudes, nº de enlaces internos añadidos y el veredicto literal del validador. +- PADRINO: el comando `seo_link.py` listo para copiar, con el anchor ya + verificado en la prosa del origen. Va aparte de los enlaces del paso 3 y no + se puede omitir: es lo único del checklist que decide si Google llega a + encontrar el artículo. - Cierra recordando que sigue en borrador, listo para publicar. diff --git a/seo_gsc.py b/seo_gsc.py index ef22403..0f59e0d 100644 --- a/seo_gsc.py +++ b/seo_gsc.py @@ -41,6 +41,11 @@ Subcomandos: Compara la misma ventana antes y después de una fecha. Escrito para responder de una vez si el remate de metas del 23-jun sirvió de algo. + seo_gsc.py inspecciona --site en|es + El veredicto de Google sobre una URL: indexada, rastreada cuándo, y a + qué canoniza. Distingue «posiciona mal» de «no lo ha visto nunca», que + son problemas opuestos. Sale con código 1 si alguna no está indexada. + seo_gsc.py comprueba Canario: recorre el camino REAL (credencial → firma → token → API) y dice en qué eslabón exacto se rompe. No simula nada. @@ -449,6 +454,42 @@ def cmd_antes_despues(a): # ──────────────────────────────── utilidades ──────────────────────────────── +def cmd_inspecciona(a): + """¿Conoce Google esta URL? Veredicto suyo, no deducción nuestra. + + Es la única forma de distinguir «posiciona mal» de «no lo ha visto». El + 2026-07-28 dos posts de EN publicados una semana antes salieron con «URL is + unknown to Google»: nunca rastreados. Sin esto se habría confundido con un + problema de contenido y se habrían reescrito titulares para nada. + """ + import requests + tok = token(carga_credencial()) + prop = SITIOS[a.site]["propiedad"] + base = ("https://www.theexclusionzone.com/" if a.site == "en" + else "https://zonadeexclusion.com/") + malas = 0 + for slug in a.slugs: + url = slug if slug.startswith("http") else f"{base}{slug.strip('/')}/" + r = requests.post( + "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect", + headers={"Authorization": f"Bearer {tok}"}, timeout=60, + json={"inspectionUrl": url, "siteUrl": prop}) + if r.status_code != 200: + print(f" ✗ {r.status_code} {url}\n {r.text[:200]}") + malas += 1 + continue + i = r.json()["inspectionResult"]["indexStatusResult"] + ok = i.get("verdict") == "PASS" + malas += 0 if ok else 1 + print(f" {'✓' if ok else '⚠'} {i.get('coverageState', '?')}") + print(f" rastreo {i.get('lastCrawlTime', '—')[:10]} " + f"robots {i.get('robotsTxtState', '?')}") + if i.get("googleCanonical") and i["googleCanonical"] != url: + print(f" ⚠ Google canoniza a: {i['googleCanonical']}") + print(f" {url}") + return 1 if malas else 0 + + def cmd_sitios(a): sa = carga_credencial() print(f"cuenta de servicio: {sa['client_email']}") @@ -555,6 +596,11 @@ def main(): p.add_argument("--site", choices=("en", "es", "todos"), default="todos") p.set_defaults(func=cmd_antes_despues) + p = sub.add_parser("inspecciona", help="¿conoce Google estas URLs?") + p.add_argument("slugs", nargs="+", help="slugs o URLs completas") + p.add_argument("--site", choices=("en", "es"), required=True) + p.set_defaults(func=cmd_inspecciona) + p = sub.add_parser("comprueba", help="canario del camino real") p.set_defaults(func=cmd_comprueba) diff --git a/seo_semantic.py b/seo_semantic.py index 85efc5a..d35c371 100644 --- a/seo_semantic.py +++ b/seo_semantic.py @@ -8,9 +8,20 @@ vectores contesta dos preguntas: · afines → los N artículos PUBLICADOS más parecidos, para sugerir enlaces internos al rematar un borrador. Es la palanca de indexación que queda después de IndexNow. + · padrinos → lo INVERSO: qué páginas fuertes deberían enlazar aquí. + `afines` sirve al lector, `padrinos` sirve a Google, y + casi nunca dan la misma respuesta. + · descubrimiento → publicados a los que solo se llega desde páginas que + Google apenas visita. · canibalizacion → pares demasiado parecidos DENTRO de un sitio: compiten por la misma búsqueda y se hacen sombra. +⚠️ Para contar enlaces internos, la verdad está en el `html` que devuelve el +CLI de Ghost, NO en la página renderizada: el tema mete navegación anterior / +siguiente y un feed de relacionados que parecen enlaces del cuerpo y no lo son. +Contarlos desde el HTML público dice «cero huérfanos» cuando la realidad son 21 +de 32 (comprobado el 2026-07-28, y la primera medición salió mal por esto). + NUNCA escribe en el blog. Solo imprime. Quien escribe el enlace es seo_link.py, y publicar es decisión de Jose. @@ -25,6 +36,8 @@ MJ-12, ver seo_link.py). Uso: python3 seo_semantic.py afines --site es [-n 4] [--json] + python3 seo_semantic.py padrinos --site en [-n 5] [--json] + python3 seo_semantic.py descubrimiento --site en [--todos] [--json] python3 seo_semantic.py canibalizacion --site es [--umbral 0.86] [--json] python3 seo_semantic.py estado python3 seo_semantic.py distribucion --site es # para calibrar el umbral @@ -156,6 +169,11 @@ def carga(sitio, refrescar=False, verboso=True): "url": f"{cfg['base']}/{slug}/", "hash": hh, "chars": len(txt), + # Ghost excluye del sitemap los posts con canonical_url propio: son + # versiones consolidadas en otra URL. Siguen publicados y pueden + # arrastrar tráfico histórico, pero NO sirven de padrino — el enlace + # saldría de una página que el propio sitio declara secundaria. + "canonico": p.get("canonical_url"), # se recalcula en cada pasada (es gratis) para que no envejezca "enlaza_a": enlaces_de(p, cfg["base"]), } @@ -180,6 +198,54 @@ def carga(sitio, refrescar=False, verboso=True): return vivos +# ---------- tráfico real (Search Console) ---------- + +# Sin esto, "enlázalo desde un artículo afín" es un consejo a ciegas: da igual +# que el origen sea el artículo con más tráfico del sitio o uno que no ha +# recibido una impresión en su vida. Medido el 2026-07-28, esa diferencia es la +# que explica que dos posts de EN publicados el 21-jul siguieran siendo «URL +# desconocida para Google» una semana después: estaban enlazados, sí, pero solo +# desde páginas con 8 y 12 impresiones DE POR VIDA, que Google casi no visita. +BD_GSC = os.path.expanduser("~/.local/state/seo-gsc/gsc.db") + + +def impresiones(sitio): + """{slug: impresiones acumuladas}. Vacío si aún no hay marcador.""" + if not os.path.exists(BD_GSC): + return {} + import sqlite3 + with sqlite3.connect(f"file:{BD_GSC}?mode=ro", uri=True) as c: + return {k.rstrip("/").rsplit("/", 1)[-1]: v for k, v in c.execute( + "SELECT clave, SUM(impresiones) FROM filas " + "WHERE sitio=? AND dim='page' GROUP BY clave", (sitio,))} + + +def tercio_fuerte(regs, impr): + """Los slugs publicados del tercio superior por impresiones. + + Es el listón de «página fuerte»: relativo al sitio y no un número absoluto, + porque 200 impresiones son mucho en ES y poco en EN. + """ + pub = [s for s, r in regs.items() + if r.get("estado") == "published" and not r.get("canonico")] + if not pub: + return set() + orden = sorted(pub, key=lambda s: -impr.get(s, 0)) + return set(orden[:max(1, len(orden) // 3)]) + + +def entrantes(regs): + """Invierte enlaza_a: {slug: [slugs publicados que lo enlazan]}.""" + dentro = {s: [] for s in regs} + for origen, r in regs.items(): + if r.get("estado") != "published": + continue + for destino in r.get("enlaza_a", []): + if destino in dentro and destino != origen: + dentro[destino].append(origen) + return dentro + + # ---------- similitud ---------- def matriz(regs): @@ -245,6 +311,81 @@ def afines(sitio, slug, n, refrescar=False): for v, s in fila[:n]] +SIM_MIN_PADRINO = 0.55 # mismo listón que usa el remate para no forzar enlaces + + +def padrinos(sitio, slug, n, refrescar=False): + """Lo inverso de `afines`: qué páginas FUERTES deberían enlazar a este post. + + `afines` contesta «a quién enlazo yo», que es lo que necesita un borrador + para dar contexto al lector. Esto contesta «quién me enlaza a mí», que es lo + que necesita Google para llegar hasta aquí. No es la misma pregunta y la + respuesta rara vez coincide: el artículo más parecido suele ser otro post + pequeño del mismo rincón temático, y ése no arrastra a nadie. + + Se descartan los que YA enlazan al destino, y se ordena por tráfico del + origen — no por similitud — porque entre dos candidatos razonables el que + sirve es el que Google visita. + """ + regs = carga(sitio, refrescar) + if slug not in regs: + p = post_suelto(sitio, slug) + if p is None: + sys.exit(f"✗ '{slug}' no existe en {sitio.upper()}. ¿Sitio equivocado?") + regs = dict(regs) + regs[slug] = {"slug": slug, "titulo": p.get("title"), "estado": p.get("status"), + "url": f"{SITIOS[sitio]['base']}/{slug}/", + "enlaza_a": enlaces_de(p, SITIOS[sitio]["base"]), + "vec": embed([texto_de(p)], endpoint())[0]} + impr = impresiones(sitio) + fuertes = tercio_fuerte(regs, impr) + slugs, S = matriz(regs) + i = slugs.index(slug) + out = [] + for j, otro in enumerate(slugs): + if j == i or regs[otro].get("estado") != "published": + continue + if regs[otro].get("canonico"): + continue # consolidado en otra URL: no está ni en el sitemap + if slug in regs[otro].get("enlaza_a", []): + continue # ya nos enlaza: no hay nada que pedirle + sim = float(S[i][j]) + if sim < SIM_MIN_PADRINO: + continue + out.append({"slug": otro, "titulo": regs[otro]["titulo"], + "url": regs[otro]["url"], "similitud": round(sim, 4), + "impresiones": impr.get(otro, 0), + "fuerte": otro in fuertes}) + out.sort(key=lambda r: (-r["impresiones"], -r["similitud"], r["slug"])) + return out[:n] + + +def descubrimiento(sitio, refrescar=False): + """Posts publicados a los que solo llega enlace desde páginas flojas. + + Un post puede no ser huérfano y aun así ser invisible: si los únicos caminos + que llevan hasta él pasan por artículos que Google apenas rastrea, tarda + semanas en descubrirlo — o no lo descubre. + """ + regs = carga(sitio, refrescar) + impr = impresiones(sitio) + fuertes = tercio_fuerte(regs, impr) + dentro = entrantes(regs) + out = [] + for s, r in regs.items(): + if r.get("estado") != "published" or r.get("canonico"): + continue + orig = sorted(dentro[s], key=lambda o: -impr.get(o, 0)) + out.append({"slug": s, "titulo": r["titulo"], + "impresiones": impr.get(s, 0), + "entrantes": len(orig), + "apadrinado": any(o in fuertes for o in orig), + "mejor_origen": orig[0] if orig else None, + "mejor_origen_impr": impr.get(orig[0], 0) if orig else 0}) + out.sort(key=lambda r: (r["apadrinado"], r["mejor_origen_impr"], r["slug"])) + return out + + def canibalizacion(sitio, umbral, refrescar=False): regs = carga(sitio, refrescar) slugs, S = matriz(regs) @@ -283,6 +424,17 @@ def main(): a.add_argument("--site", required=True, choices=SITIOS) a.add_argument("-n", type=int, default=4) + pa = sub.add_parser("padrinos", help="qué páginas fuertes deberían enlazar a este post") + pa.add_argument("slug") + pa.add_argument("--site", required=True, choices=SITIOS) + pa.add_argument("-n", type=int, default=5) + + de = sub.add_parser("descubrimiento", + help="posts a los que solo llega enlace desde páginas flojas") + de.add_argument("--site", required=True, choices=SITIOS) + de.add_argument("--todos", action="store_true", + help="listar también los que sí están apadrinados") + c = sub.add_parser("canibalizacion", help="pares que compiten por la misma búsqueda") c.add_argument("--site", required=True, choices=SITIOS) # 0.78 calibrado el 2026-07-25 contra el corpus real, no a ojo. El reparto @@ -299,7 +451,7 @@ def main(): sub.add_parser("estado", help="qué hay en el caché") - for p in (a, c, d): + for p in (a, c, d, pa, de): p.add_argument("--json", action="store_true") p.add_argument("--refrescar", action="store_true", help="re-embeber todo, ignorando el caché") @@ -330,6 +482,44 @@ def main(): print(f" {r['url']}") return + if ar.cmd == "padrinos": + res = padrinos(ar.site, ar.slug, ar.n, ar.refrescar) + if ar.json: + print(json.dumps(res, ensure_ascii=False, indent=2)) + elif not res: + print(f"Nadie afín (≥{SIM_MIN_PADRINO}) que no lo enlace ya. " + f"O está bien apadrinado, o el corpus no da para un enlace honesto.") + else: + print(f"\nQuién debería enlazar a «{ar.slug}» ({ar.site.upper()}), " + f"por tráfico del origen:\n") + for r in res: + print(f" {r['impresiones']:6} impr sim {r['similitud']:.3f}" + f"{' ← tercio fuerte' if r['fuerte'] else ''}") + print(f" {r['titulo']}") + print(f" {r['slug']}") + print("\n El enlace hay que escribirlo en el post ORIGEN:") + print(f" python3 ~/seo-tools/seo_link.py {res[0]['slug']} " + f"--site {ar.site} --link \"={ar.slug}\"") + return + + if ar.cmd == "descubrimiento": + res = descubrimiento(ar.site, ar.refrescar) + if ar.json: + print(json.dumps(res, ensure_ascii=False, indent=2)) + return + malos = [r for r in res if not r["apadrinado"]] + print(f"\n{ar.site.upper()}: {len(res)} publicados, " + f"{len(malos)} sin un solo enlace desde el tercio fuerte\n") + for r in (res if ar.todos else malos): + marca = "✓" if r["apadrinado"] else "⚠" + origen = (f"mejor origen: {r['mejor_origen'][:40]} " + f"({r['mejor_origen_impr']} impr)" if r["mejor_origen"] + else "HUÉRFANO: nadie lo enlaza") + print(f" {marca} {r['impresiones']:6} impr propias " + f"{r['entrantes']} entrantes {r['slug'][:44]}") + print(f" {origen}") + return + if ar.cmd == "canibalizacion": res = canibalizacion(ar.site, ar.umbral, ar.refrescar) if ar.json: