chore(verify): recuperar enum_urls.php y commitear tooling de verificación #121
El enumerador de URLs no se había commiteado y se perdió. Recuperado reproduciendo la composición de la corrida previa (menús×5 idiomas + categorías con contenido + contenido no-ES). Se añade también classify_gaps.py (clasifica huecos: traducir vs descargar) y run_all.sh. Outputs y .venv quedan en .gitignore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
.venv/
|
||||||
|
report.jsonl
|
||||||
|
report.json
|
||||||
|
report.csv
|
||||||
|
broken_links.json
|
||||||
|
llm_review.json
|
||||||
|
urls.json
|
||||||
|
gaps_dossier.json
|
||||||
|
gaps_traducir.csv
|
||||||
|
gaps_descargar.csv
|
||||||
|
run.log
|
||||||
|
prev-*/
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# fea-verify — Verificación automática del sitio (issue #121)
|
||||||
|
|
||||||
|
Proceso por capas para validar el multiidioma del WP local **sin coste** (todo OSS + Gemma local).
|
||||||
|
Se ejecuta **contra el LOCAL** `https://farmer.taild3aaf6.ts.net/fea/` (prod está tras Cloudflare y bloquea headless).
|
||||||
|
|
||||||
|
## Principio
|
||||||
|
Lo determinista (barato, exhaustivo) primero; el LLM solo sobre lo que las capas baratas marcan.
|
||||||
|
|
||||||
|
## Capas / scripts
|
||||||
|
| Paso | Script | Qué hace | Coste |
|
||||||
|
|------|--------|----------|-------|
|
||||||
|
| Enumerar | `enum_urls.php` (en contenedor `wordpress-web`) | Saca de la BD todas las URLs: menús×5 idiomas, categorías con contenido, contenido no-ES. → `urls.json` | 0 |
|
||||||
|
| Capa 0+1 crawl | `crawl.cjs` (Playwright) | status HTTP, URL final (redirección cross-idioma), `<html lang>`, texto visible, enlaces salientes. Resumible. → `report.jsonl` | 0 |
|
||||||
|
| Capa 1 análisis | `analyze.py` (lingua, venv) | detección de idioma offline + flags. → `report.json` + `report.csv` | 0 |
|
||||||
|
| Capa 0 enlaces | `link_audit.py` (stdlib) | comprueba status de los enlaces internos hallados. → `broken_links.json` | 0 |
|
||||||
|
| Capa 3 (opcional) | `llm_review.py` (Gemma local) | review semántica SOLO de páginas marcadas. → `llm_review.json` | 0 |
|
||||||
|
|
||||||
|
## Requisitos (una vez)
|
||||||
|
- Node + Playwright: ya en `tools/e2e/node_modules`.
|
||||||
|
- venv con lingua: `python3 -m venv verify/.venv && verify/.venv/bin/pip install lingua-language-detector`
|
||||||
|
- Docker WP arriba (`wordpress-web`, `wordpress-mysql`), LM Studio con `google/gemma-4-e4b` (solo Capa 3).
|
||||||
|
|
||||||
|
## Ejecutar (todo)
|
||||||
|
```bash
|
||||||
|
cd tools/e2e/verify
|
||||||
|
# 1) enumerar URLs desde la BD
|
||||||
|
docker cp enum_urls.php wordpress-web:/tmp/ && docker exec wordpress-web php /tmp/enum_urls.php && docker cp wordpress-web:/tmp/verify_urls.json urls.json
|
||||||
|
# 2) crawl (resumible; borra report.jsonl para empezar de cero)
|
||||||
|
CONC=5 node crawl.cjs
|
||||||
|
# 3) análisis + flags
|
||||||
|
.venv/bin/python analyze.py
|
||||||
|
# 4) enlaces rotos
|
||||||
|
python3 link_audit.py
|
||||||
|
# 5) (opcional) review LLM local sobre lo marcado
|
||||||
|
.venv/bin/python llm_review.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Leer el resultado
|
||||||
|
- **`report.csv`** — lo accionable, ordenado por gravedad. Columnas: `sev`(3 crítico→1 aviso), `flags`, `lang`, `kind`, `status`, `detected`(idioma detectado), `conf`, `redir`, `url`, `final`.
|
||||||
|
- **`broken_links.json`** — enlaces internos rotos + qué páginas los referencian.
|
||||||
|
- **`report.json`** — todo, en JSON.
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
- `HTTP_5XX` / `HTTP_4XX` / `CRAWL_ERROR` — la página falla (p.ej. el 500 de `/en/category/letters-from-other-weeks/`).
|
||||||
|
- `CROSS_LANG_REDIRECT` — pediste `/en/x/` y acabaste en otro idioma (bug de slug duplicado).
|
||||||
|
- `SPANISH_LEAK` — página no-ES cuyo texto detectado es español (sin traducir / widget en ES).
|
||||||
|
- `LANG_MISMATCH` / `HTML_LANG_MISMATCH` — idioma del texto / del `<html lang>` ≠ esperado.
|
||||||
|
|
||||||
|
## Scope de la corrida
|
||||||
|
Por defecto crawlea: menús (5 idiomas), categorías con contenido, y **todo el contenido no-ES** (~3.9k). El contenido ES masivo (~24k) se omite por volumen; añadirlo es cambiar la query de `enum_urls.php`.
|
||||||
|
|
||||||
|
## Coste
|
||||||
|
**0 €.** Sin SaaS ni APIs de pago. Detección de idioma offline (lingua) y review con Gemma local. Minimax NO se usa.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Capa 1: sobre report.jsonl añade detección de idioma (lingua) y flags. Salida CSV+JSON. Coste 0.
|
||||||
|
import json, csv, os, sys, re
|
||||||
|
from lingua import Language, LanguageDetectorBuilder
|
||||||
|
DIR=os.path.dirname(os.path.abspath(__file__))
|
||||||
|
LANGMAP={'es':Language.SPANISH,'en':Language.ENGLISH,'fr':Language.FRENCH,'it':Language.ITALIAN,'pt':Language.PORTUGUESE}
|
||||||
|
det=LanguageDetectorBuilder.from_languages(*LANGMAP.values()).build()
|
||||||
|
code={Language.SPANISH:'es',Language.ENGLISH:'en',Language.FRENCH:'fr',Language.ITALIAN:'it',Language.PORTUGUESE:'pt'}
|
||||||
|
|
||||||
|
def detect(text):
|
||||||
|
t=(text or '').strip()
|
||||||
|
if len(t)<60: return ('',0.0)
|
||||||
|
l=det.detect_language_of(t)
|
||||||
|
if not l: return ('',0.0)
|
||||||
|
conf=det.compute_language_confidence(t,l)
|
||||||
|
return (code.get(l,''),round(conf,2))
|
||||||
|
|
||||||
|
rows=[]
|
||||||
|
with open(os.path.join(DIR,'report.jsonl'),encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
line=line.strip()
|
||||||
|
if not line: continue
|
||||||
|
r=json.loads(line)
|
||||||
|
flags=[]; exp=r.get('lang')
|
||||||
|
st=r.get('status',0)
|
||||||
|
if st==-1: flags.append('CRAWL_ERROR')
|
||||||
|
elif st>=500: flags.append('HTTP_5XX')
|
||||||
|
elif st>=400: flags.append('HTTP_4XX')
|
||||||
|
# redirección cross-idioma (solo content/menu; categorías de 1 carta redirigen legítimamente)
|
||||||
|
fl=r.get('final_lang','')
|
||||||
|
if r.get('redirected') and fl and exp and fl!=exp:
|
||||||
|
# ES esperado pero acaba en /xx/ o viceversa
|
||||||
|
flags.append('CROSS_LANG_REDIRECT')
|
||||||
|
dl,conf=detect(r.get('text',''))
|
||||||
|
r['detected_lang']=dl; r['detect_conf']=conf
|
||||||
|
if exp and exp!='es' and dl=='es' and conf>=0.7:
|
||||||
|
flags.append('SPANISH_LEAK')
|
||||||
|
if exp and dl and exp!=dl and 'SPANISH_LEAK' not in flags and conf>=0.85 and exp!='es':
|
||||||
|
flags.append('LANG_MISMATCH')
|
||||||
|
hl=(r.get('htmlLang') or '').split('-')[0].lower()
|
||||||
|
if hl and exp and hl!=exp and hl in LANGMAP:
|
||||||
|
flags.append('HTML_LANG_MISMATCH')
|
||||||
|
sev=0
|
||||||
|
if any(x in flags for x in ('HTTP_5XX','CRAWL_ERROR')): sev=3
|
||||||
|
elif any(x in flags for x in ('HTTP_4XX','CROSS_LANG_REDIRECT')): sev=2
|
||||||
|
elif any(x in flags for x in ('SPANISH_LEAK','LANG_MISMATCH','HTML_LANG_MISMATCH')): sev=1
|
||||||
|
r['flags']=flags; r['severity']=sev
|
||||||
|
rows.append(r)
|
||||||
|
|
||||||
|
rows.sort(key=lambda r:(-r['severity'], r['lang'], r['url']))
|
||||||
|
# JSON completo (sin el campo links para tamaño; se usa en link_audit aparte)
|
||||||
|
with open(os.path.join(DIR,'report.json'),'w',encoding='utf-8') as f:
|
||||||
|
json.dump([{k:v for k,v in r.items() if k not in('links','text','key')} for r in rows],f,ensure_ascii=False,indent=1)
|
||||||
|
# CSV
|
||||||
|
with open(os.path.join(DIR,'report.csv'),'w',newline='',encoding='utf-8') as f:
|
||||||
|
w=csv.writer(f); w.writerow(['sev','flags','lang','kind','status','detected','conf','redir','url','final'])
|
||||||
|
for r in rows:
|
||||||
|
if not r['flags'] and r.get('status')==200: continue # CSV solo lo accionable
|
||||||
|
w.writerow([r['severity'],'|'.join(r['flags']),r['lang'],r['kind'],r.get('status'),r.get('detected_lang'),r.get('detect_conf'),'Y' if r.get('redirected') else '',r['url'],r.get('final')])
|
||||||
|
# resumen
|
||||||
|
from collections import Counter
|
||||||
|
c=Counter(x for r in rows for x in r['flags'])
|
||||||
|
print('total analizadas:',len(rows))
|
||||||
|
print('flags:',dict(c))
|
||||||
|
print('accionables (en CSV):',sum(1 for r in rows if r['flags'] or r.get('status')!=200))
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
classify_gaps.py — Clasifica los huecos de traducción aflorados por el crawler
|
||||||
|
(#121/#132) en DESCARGAR (bíblico/litúrgico) vs TRADUCIR (comentarios/artículos),
|
||||||
|
para coordinar con Minimax.
|
||||||
|
|
||||||
|
Entrada: report.csv (de analyze.py). Cruza las páginas marcadas SPANISH_LEAK /
|
||||||
|
LANG_MISMATCH / CROSS_LANG_REDIRECT con las categorías WP del post, vía wp eval.
|
||||||
|
|
||||||
|
Salida:
|
||||||
|
gaps_traducir.csv — posts ES a traducir (comentarios/artículos)
|
||||||
|
gaps_descargar.csv — posts bíblicos/litúrgicos (lecturas, evangelios, salmos)
|
||||||
|
gaps_dossier.json — todo, estructurado
|
||||||
|
"""
|
||||||
|
import csv, json, subprocess, re, os, sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPORT = os.path.join(DIR, "report.csv")
|
||||||
|
|
||||||
|
# Categorías que se DESCARGAN (texto oficial), no se traducen con LLM.
|
||||||
|
DESCARGAR_CATS = {
|
||||||
|
1645, # Lecturas bíblicas
|
||||||
|
1648, # Eucaristía (lecturas/salmos litúrgicos)
|
||||||
|
28, # evangelios-y-comentarios (los 4 evangelistas: descarga)
|
||||||
|
}
|
||||||
|
# term_id de las categorías que SÍ se traducen
|
||||||
|
TRADUCIR_CATS = {1646, 1647, 1650, 6} # editorial, comentarios evangelio, artículos, carta
|
||||||
|
|
||||||
|
FLAGS_INTERES = ("SPANISH_LEAK", "LANG_MISMATCH", "HTML_LANG_MISMATCH", "CROSS_LANG_REDIRECT")
|
||||||
|
|
||||||
|
|
||||||
|
def slug_from_url(url):
|
||||||
|
path = re.sub(r"https?://[^/]+", "", url).strip("/")
|
||||||
|
parts = [p for p in path.split("/") if p]
|
||||||
|
if parts and parts[0] in ("en", "fr", "it", "pt", "es"):
|
||||||
|
parts = parts[1:]
|
||||||
|
# saltar prefijos no-post
|
||||||
|
if parts and parts[0] in ("category", "tag", "author", "page"):
|
||||||
|
return None
|
||||||
|
return parts[-1] if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def wp_resolve(slugs):
|
||||||
|
"""Devuelve {slug: {id, lang, cats:[ids], title, es_id}} vía wp eval."""
|
||||||
|
if not slugs:
|
||||||
|
return {}
|
||||||
|
payload = json.dumps(list(slugs))
|
||||||
|
php = r'''
|
||||||
|
$slugs=json_decode(file_get_contents("php://stdin"),true);
|
||||||
|
$out=[];
|
||||||
|
foreach($slugs as $s){
|
||||||
|
$p=get_posts(["name"=>$s,"post_type"=>"post","post_status"=>["publish","draft"],"numberposts"=>1,"suppress_filters"=>true]);
|
||||||
|
if(!$p){$out[$s]=null;continue;}
|
||||||
|
$id=$p[0]->ID;
|
||||||
|
$lang=function_exists("pll_get_post_language")?pll_get_post_language($id):"";
|
||||||
|
$es=$id;
|
||||||
|
if($lang!=="es"&&function_exists("pll_get_post")){$t=pll_get_post($id,"es");if($t)$es=$t;}
|
||||||
|
$out[$s]=["id"=>$id,"lang"=>$lang,"cats"=>wp_get_post_categories($id),"title"=>get_the_title($id),"es_id"=>$es,
|
||||||
|
"es_cats"=>wp_get_post_categories($es),"es_title"=>get_the_title($es)];
|
||||||
|
}
|
||||||
|
echo json_encode($out);
|
||||||
|
'''
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "exec", "-i", "wordpress-web", "wp", "eval", php, "--allow-root"],
|
||||||
|
input=payload, capture_output=True, text=True)
|
||||||
|
try:
|
||||||
|
return json.loads(r.stdout.strip().splitlines()[-1])
|
||||||
|
except Exception as e:
|
||||||
|
print("wp_resolve error:", e, r.stdout[:300], r.stderr[:300], file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(REPORT):
|
||||||
|
print("Falta report.csv — ejecuta analyze.py primero", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
rows = list(csv.DictReader(open(REPORT, encoding="utf-8")))
|
||||||
|
flagged = [r for r in rows if any(f in (r.get("flags") or "") for f in FLAGS_INTERES)]
|
||||||
|
print(f"Páginas marcadas: {len(flagged)} / {len(rows)}")
|
||||||
|
|
||||||
|
slugs = {}
|
||||||
|
for r in flagged:
|
||||||
|
s = slug_from_url(r.get("url", ""))
|
||||||
|
if s:
|
||||||
|
slugs.setdefault(s, r)
|
||||||
|
|
||||||
|
meta = wp_resolve(set(slugs))
|
||||||
|
|
||||||
|
traducir, descargar, otros = [], [], []
|
||||||
|
for s, r in slugs.items():
|
||||||
|
m = meta.get(s)
|
||||||
|
if not m:
|
||||||
|
otros.append({"slug": s, "url": r["url"], "flags": r.get("flags"), "motivo": "no resuelve post"})
|
||||||
|
continue
|
||||||
|
cats = set(m.get("es_cats") or m.get("cats") or [])
|
||||||
|
row = {"slug": s, "lang": r.get("lang"), "flags": r.get("flags"),
|
||||||
|
"es_id": m["es_id"], "es_title": m.get("es_title"), "cats": sorted(cats), "url": r["url"]}
|
||||||
|
if cats & DESCARGAR_CATS:
|
||||||
|
descargar.append(row)
|
||||||
|
elif cats & TRADUCIR_CATS:
|
||||||
|
traducir.append(row)
|
||||||
|
else:
|
||||||
|
otros.append(row)
|
||||||
|
|
||||||
|
# dedup por es_id para traducir (lo que va a Minimax)
|
||||||
|
by_es = {}
|
||||||
|
for t in traducir:
|
||||||
|
by_es.setdefault(t["es_id"], t)
|
||||||
|
|
||||||
|
def write_csv(path, rows, cols):
|
||||||
|
with open(path, "w", newline="", encoding="utf-8") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore")
|
||||||
|
w.writeheader()
|
||||||
|
w.writerows(rows)
|
||||||
|
|
||||||
|
write_csv(os.path.join(DIR, "gaps_traducir.csv"), list(by_es.values()),
|
||||||
|
["es_id", "es_title", "lang", "cats", "flags", "url"])
|
||||||
|
write_csv(os.path.join(DIR, "gaps_descargar.csv"), descargar,
|
||||||
|
["es_id", "es_title", "lang", "cats", "flags", "url"])
|
||||||
|
json.dump({"traducir": list(by_es.values()), "descargar": descargar, "otros": otros},
|
||||||
|
open(os.path.join(DIR, "gaps_dossier.json"), "w"), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"\nTRADUCIR (Minimax): {len(by_es)} posts ES únicos")
|
||||||
|
print(f"DESCARGAR (bíblico/litúrgico): {len(descargar)} páginas")
|
||||||
|
print(f"OTROS (revisar a mano): {len(otros)}")
|
||||||
|
print("\nIDs ES a traducir:", ",".join(str(i) for i in by_es))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Capa 0+1 (crawl): status, URL final (cross-lang), html lang, texto visible, enlaces salientes.
|
||||||
|
// Resumible: añade a report.jsonl; al rearrancar salta lo ya hecho. Coste 0 (local).
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DIR = __dirname;
|
||||||
|
const URLS = JSON.parse(fs.readFileSync(path.join(DIR,'urls.json'),'utf8'));
|
||||||
|
const OUT = path.join(DIR,'report.jsonl');
|
||||||
|
const CONCURRENCY = parseInt(process.env.CONC||'3',10);
|
||||||
|
const LIMIT = parseInt(process.env.LIMIT||'0',10); // 0 = todas
|
||||||
|
|
||||||
|
// resumir
|
||||||
|
const done = new Set();
|
||||||
|
if (fs.existsSync(OUT)) for (const l of fs.readFileSync(OUT,'utf8').split('\n')) {
|
||||||
|
if(!l.trim()) continue; try { done.add(JSON.parse(l).key); } catch(e){}
|
||||||
|
}
|
||||||
|
let tasks = URLS.map(u=>({...u, key:u.lang+'|'+u.url})).filter(t=>!done.has(t.key));
|
||||||
|
if (LIMIT) tasks = tasks.slice(0,LIMIT);
|
||||||
|
console.log(`pendientes: ${tasks.length} (ya hechas ${done.size})`);
|
||||||
|
|
||||||
|
const langPrefix = (url) => {
|
||||||
|
const m = url.match(/\/fea\/(es|en|fr|it|pt)\//); if (m) return m[1];
|
||||||
|
// sin prefijo bajo /fea/ = es (hide_default)
|
||||||
|
if (/\/fea\//.test(url)) return 'es';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ args:['--ignore-certificate-errors'] });
|
||||||
|
const ctx = await browser.newContext({ ignoreHTTPSErrors:true, userAgent:'fea-verify-crawler' });
|
||||||
|
const out = fs.createWriteStream(OUT, { flags:'a' });
|
||||||
|
let i=0, done2=0;
|
||||||
|
const worker = async () => {
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
while (i < tasks.length) {
|
||||||
|
const t = tasks[i++];
|
||||||
|
const rec = { key:t.key, url:t.url, lang:t.lang, kind:t.kind, id:t.id };
|
||||||
|
let resp=null, lastErr=null;
|
||||||
|
for (let a=0; a<3 && !resp; a++) {
|
||||||
|
try { resp = await page.goto(t.url, { waitUntil:'domcontentloaded', timeout:30000 }); }
|
||||||
|
catch(e){ lastErr=e; await page.waitForTimeout(700); }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!resp) throw lastErr || new Error('no response');
|
||||||
|
rec.status = resp ? resp.status() : 0;
|
||||||
|
rec.final = page.url();
|
||||||
|
rec.redirected = rec.final !== t.url;
|
||||||
|
rec.final_lang = langPrefix(rec.final);
|
||||||
|
const data = await page.evaluate(() => {
|
||||||
|
const main = document.querySelector('main, .wp-block-post-content, .entry-content, article') || document.body;
|
||||||
|
const text = (main.innerText||'').replace(/\s+/g,' ').trim().slice(0,1200);
|
||||||
|
const htmlLang = document.documentElement.getAttribute('lang')||'';
|
||||||
|
const links = Array.from(document.querySelectorAll('a[href]')).map(a=>a.href)
|
||||||
|
.filter(h=>/^https?:/.test(h));
|
||||||
|
return { text, htmlLang, links:[...new Set(links)] };
|
||||||
|
});
|
||||||
|
rec.text = data.text; rec.htmlLang = data.htmlLang; rec.links = data.links;
|
||||||
|
} catch (e) {
|
||||||
|
rec.status = -1; rec.error = String(e).slice(0,120); rec.final=t.url; rec.links=[]; rec.text='';
|
||||||
|
}
|
||||||
|
out.write(JSON.stringify(rec)+'\n');
|
||||||
|
if (++done2 % 100 === 0) console.log(` ${done2}/${tasks.length}`);
|
||||||
|
}
|
||||||
|
await page.close();
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({length:CONCURRENCY}, worker));
|
||||||
|
out.end();
|
||||||
|
await browser.close();
|
||||||
|
console.log(`crawl terminado: ${done2} nuevas, total report = ${done.size+done2}`);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* enum_urls.php — Enumera las URLs del sitio para el crawler de verificación (#121).
|
||||||
|
*
|
||||||
|
* Recuperado 2026-06-22 (el original no se había commiteado y se perdió). Reproduce
|
||||||
|
* la composición de la corrida previa: menús×5 idiomas + categorías con contenido +
|
||||||
|
* todo el contenido no-ES (el ES masivo ~24k se omite por volumen).
|
||||||
|
*
|
||||||
|
* Uso (dentro del contenedor, carga wp-load):
|
||||||
|
* docker cp enum_urls.php wordpress-web:/tmp/ && \
|
||||||
|
* docker exec wordpress-web php /tmp/enum_urls.php && \
|
||||||
|
* docker cp wordpress-web:/tmp/verify_urls.json urls.json
|
||||||
|
*
|
||||||
|
* Salida: /tmp/verify_urls.json — array de {url, lang, kind, id}.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once '/var/www/html/wp-load.php';
|
||||||
|
|
||||||
|
$LANGS = ['es', 'en', 'fr', 'it', 'pt'];
|
||||||
|
$out = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
function add(&$out, &$seen, $url, $lang, $kind, $id) {
|
||||||
|
$url = trailingslashit($url);
|
||||||
|
$k = $lang . '|' . $url;
|
||||||
|
if (isset($seen[$k])) return;
|
||||||
|
$seen[$k] = true;
|
||||||
|
$out[] = ['url' => $url, 'lang' => $lang, 'kind' => $kind, 'id' => (int) $id];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1) MENÚ: 31 rutas base × 5 idiomas ───────────────────────────────────────
|
||||||
|
// Rutas del menú principal (wp_navigation ID 1). Para no-ES se prefija /{lang}/;
|
||||||
|
// el crawler sigue redirecciones cross-idioma y las marca.
|
||||||
|
$menu_paths = [
|
||||||
|
'', 'colaboradores', 'portal', 'paraponeraldialafe', 'category/cartasemana',
|
||||||
|
'category/carta-semana-pasada', 'category/cartas-de-otras-semanas',
|
||||||
|
'nueva-politica-de-privacidad', 'contactar', 'alta', 'alta-en-effa', 'escuela',
|
||||||
|
'category/cartas-que-nos-llegan', 'category/tablon-de-anuncios',
|
||||||
|
'category/asociacion-feadulta', 'numeros', 'category/comunidades-cristianas',
|
||||||
|
'evangelio-diario-2024', 'category/indice-cronologico',
|
||||||
|
'category/evangelios-y-comentarios', 'oraciones-eucaristicas', 'a-modo-de-salmo',
|
||||||
|
'preces-y-oraciones-varias', 'te-creia-un-capricho-mas', 'autores-lista', 'temas',
|
||||||
|
'multimedia', 'pensamientos', 'indice-cantoral', 'peliculas', 'in-memoriam',
|
||||||
|
];
|
||||||
|
$home = home_url('/'); // .../fea/
|
||||||
|
foreach ($LANGS as $lang) {
|
||||||
|
$prefix = ($lang === 'es') ? $home : $home . $lang . '/';
|
||||||
|
foreach ($menu_paths as $p) {
|
||||||
|
add($out, $seen, $p === '' ? $prefix : $prefix . $p, $lang, 'menu', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2) CATEGORÍAS con contenido ──────────────────────────────────────────────
|
||||||
|
// ES: todas las categorías hide_empty. no-ES: solo las que tienen traducción.
|
||||||
|
$terms = get_terms(['taxonomy' => 'category', 'hide_empty' => true]);
|
||||||
|
foreach ($terms as $t) {
|
||||||
|
$link = get_term_link($t);
|
||||||
|
if (!is_wp_error($link)) add($out, $seen, $link, 'es', 'category', $t->term_id);
|
||||||
|
if (function_exists('pll_get_term')) {
|
||||||
|
foreach (['en', 'fr', 'it', 'pt'] as $lang) {
|
||||||
|
$tr = pll_get_term($t->term_id, $lang);
|
||||||
|
if ($tr) {
|
||||||
|
$l = get_term_link((int) $tr, 'category');
|
||||||
|
if (!is_wp_error($l)) add($out, $seen, $l, $lang, 'category', $tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3) CONTENIDO no-ES (posts publicados en en/fr/it/pt) ─────────────────────
|
||||||
|
foreach (['en', 'fr', 'it', 'pt'] as $lang) {
|
||||||
|
$q = new WP_Query([
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => -1,
|
||||||
|
'fields' => 'ids',
|
||||||
|
'lang' => $lang,
|
||||||
|
'no_found_rows' => true,
|
||||||
|
]);
|
||||||
|
foreach ($q->posts as $pid) {
|
||||||
|
add($out, $seen, get_permalink($pid), $lang, 'content', $pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents('/tmp/verify_urls.json', json_encode($out, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||||
|
$by = [];
|
||||||
|
foreach ($out as $r) { $by[$r['kind']] = ($by[$r['kind']] ?? 0) + 1; }
|
||||||
|
fwrite(STDERR, 'enum: ' . count($out) . ' URLs ' . json_encode($by) . "\n");
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Capa 0 (enlaces): comprueba status de los enlaces internos salientes hallados en el crawl.
|
||||||
|
# Marca rotos (>=400/error) y qué páginas los referencian. stdlib, coste 0.
|
||||||
|
import json, os, ssl, urllib.request, urllib.parse
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
DIR=os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
|
||||||
|
SITE='farmer.taild3aaf6.ts.net'
|
||||||
|
CAP=int(os.environ.get('CAP','9000'))
|
||||||
|
|
||||||
|
def norm(u):
|
||||||
|
return u.split('#')[0].rstrip('/')
|
||||||
|
|
||||||
|
crawled={} # url normalizada -> status (de report)
|
||||||
|
referers={} # link -> set(paginas)
|
||||||
|
links=set()
|
||||||
|
with open(os.path.join(DIR,'report.jsonl'),encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
line=line.strip()
|
||||||
|
if not line: continue
|
||||||
|
r=json.loads(line)
|
||||||
|
if r.get('final'): crawled[norm(r['final'])]=r.get('status')
|
||||||
|
crawled.setdefault(norm(r['url']), r.get('status'))
|
||||||
|
for l in (r.get('links') or []):
|
||||||
|
if SITE in l:
|
||||||
|
n=norm(l); links.add(n); referers.setdefault(n,set()).add(r['url'])
|
||||||
|
|
||||||
|
# solo comprobar las que NO conocemos ya por el crawl
|
||||||
|
todo=[l for l in links if l not in crawled]
|
||||||
|
capped = len(todo)>CAP
|
||||||
|
todo=todo[:CAP]
|
||||||
|
print(f"enlaces internos únicos: {len(links)} | ya conocidos: {len(links)-len(todo)-(len(todo) if False else 0)} | a comprobar: {len(todo)}"+(" (CAP)" if capped else ""))
|
||||||
|
|
||||||
|
def check(u):
|
||||||
|
try:
|
||||||
|
req=urllib.request.Request(u, method='HEAD', headers={'User-Agent':'fea-link-audit'})
|
||||||
|
with urllib.request.urlopen(req,timeout=15,context=ctx) as r: return (u,r.status)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code in (405,501): # HEAD no permitido → GET
|
||||||
|
try:
|
||||||
|
req=urllib.request.Request(u, headers={'User-Agent':'fea-link-audit'})
|
||||||
|
with urllib.request.urlopen(req,timeout=20,context=ctx) as r: return (u,r.status)
|
||||||
|
except Exception as e2: return (u, getattr(e2,'code',-1))
|
||||||
|
return (u,e.code)
|
||||||
|
except Exception:
|
||||||
|
# reintento único (evita falsos -1 por saturación)
|
||||||
|
try:
|
||||||
|
req=urllib.request.Request(u, headers={'User-Agent':'fea-link-audit'})
|
||||||
|
with urllib.request.urlopen(req,timeout=25,context=ctx) as r: return (u,r.status)
|
||||||
|
except Exception: return (u,-1)
|
||||||
|
|
||||||
|
res={}
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as ex:
|
||||||
|
futs=[ex.submit(check,u) for u in todo]
|
||||||
|
for i,fu in enumerate(as_completed(futs)):
|
||||||
|
u,s=fu.result(); res[u]=s
|
||||||
|
if (i+1)%500==0: print(f" {i+1}/{len(todo)}")
|
||||||
|
|
||||||
|
# combinar
|
||||||
|
allstatus=dict(crawled); allstatus.update(res)
|
||||||
|
broken=[]
|
||||||
|
for l in links:
|
||||||
|
s=allstatus.get(l)
|
||||||
|
if s is not None and (s==-1 or s>=400):
|
||||||
|
broken.append({'link':l,'status':s,'referenced_by':sorted(referers.get(l,[]))[:8],'n_referers':len(referers.get(l,[]))})
|
||||||
|
broken.sort(key=lambda b:(-b['n_referers'], b['link']))
|
||||||
|
json.dump({'capped':capped,'checked':len(todo),'unique_links':len(links),'broken':broken},
|
||||||
|
open(os.path.join(DIR,'broken_links.json'),'w',encoding='utf-8'),ensure_ascii=False,indent=1)
|
||||||
|
print(f"ENLACES ROTOS: {len(broken)} (de {len(links)} únicos)")
|
||||||
|
for b in broken[:15]: print(f" [{b['status']}] {b['link']} <- {b['n_referers']} págs")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Capa 3 (opcional): review semántica con Gemma LOCAL, SOLO sobre páginas marcadas por la Capa 1.
|
||||||
|
# Coste 0 (LLM local). Si Gemma no responde, sale limpio y deja constancia.
|
||||||
|
import json, os, urllib.request
|
||||||
|
DIR=os.path.dirname(os.path.abspath(__file__))
|
||||||
|
GEMMA="http://172.19.128.1:1234/v1/chat/completions"; MODEL="google/gemma-4-e4b"
|
||||||
|
MAX=int(os.environ.get('MAX','40'))
|
||||||
|
LNAME={'en':'inglés','fr':'francés','it':'italiano','pt':'portugués','es':'español'}
|
||||||
|
|
||||||
|
# texto visible está en report.jsonl (no en report.json)
|
||||||
|
texts={}
|
||||||
|
for line in open(os.path.join(DIR,'report.jsonl'),encoding='utf-8'):
|
||||||
|
line=line.strip()
|
||||||
|
if not line: continue
|
||||||
|
r=json.loads(line); texts[(r['lang'],r['url'])]=r.get('text','')
|
||||||
|
|
||||||
|
rep=json.load(open(os.path.join(DIR,'report.json'),encoding='utf-8'))
|
||||||
|
flagged=[r for r in rep if any(f in r.get('flags',[]) for f in ('SPANISH_LEAK','LANG_MISMATCH'))][:MAX]
|
||||||
|
print(f"páginas marcadas para review LLM: {len(flagged)} (cap {MAX})")
|
||||||
|
|
||||||
|
def ask(lang,text):
|
||||||
|
prompt=(f"Eres revisor de QA de un sitio web. Esta página DEBERÍA estar en {LNAME.get(lang,lang)}. "
|
||||||
|
f"Evalúa el texto visible. Responde SOLO un JSON válido: "
|
||||||
|
f'{{"idioma_ok": true/false, "idioma_predominante": "es/en/fr/it/pt", "problemas": ["lista breve de mistraducciones, trozos sin traducir o sin sentido"]}}.\n\nTEXTO:\n'+text[:1500])
|
||||||
|
body=json.dumps({"model":MODEL,"messages":[{"role":"user","content":prompt}],"temperature":0.1,"max_tokens":2200}).encode()
|
||||||
|
req=urllib.request.Request(GEMMA,data=body,headers={"Content-Type":"application/json"})
|
||||||
|
r=json.load(urllib.request.urlopen(req,timeout=120))
|
||||||
|
out=r["choices"][0]["message"]["content"].strip()
|
||||||
|
s=out.find('{'); e=out.rfind('}')
|
||||||
|
return json.loads(out[s:e+1]) if s>=0 else {"raw":out[:200]}
|
||||||
|
|
||||||
|
results=[]
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen("http://172.19.128.1:1234/v1/models",timeout=6)
|
||||||
|
except Exception as e:
|
||||||
|
print("Gemma no responde, salto Capa 3:",e);
|
||||||
|
json.dump({"skipped":True,"reason":str(e)},open(os.path.join(DIR,'llm_review.json'),'w')); raise SystemExit(0)
|
||||||
|
|
||||||
|
for i,r in enumerate(flagged):
|
||||||
|
t=texts.get((r['lang'],r['url']),'')
|
||||||
|
if len(t)<60: continue
|
||||||
|
try:
|
||||||
|
v=ask(r['lang'],t); v.update({'url':r['url'],'lang':r['lang'],'flags':r['flags']}); results.append(v)
|
||||||
|
print(f" [{i+1}/{len(flagged)}] {r['lang']} ok={v.get('idioma_ok')} pred={v.get('idioma_predominante')}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{i+1}] error {e}")
|
||||||
|
json.dump(results,open(os.path.join(DIR,'llm_review.json'),'w',encoding='utf-8'),ensure_ascii=False,indent=1)
|
||||||
|
print(f"review LLM guardada: {len(results)} páginas")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Re-verifica SOLO los registros con status -1 (artefactos de carga), secuencial + reintentos.
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const fs=require('fs'), path=require('path');
|
||||||
|
const DIR=__dirname, F=path.join(DIR,'report.jsonl');
|
||||||
|
const recs=fs.readFileSync(F,'utf8').split('\n').filter(l=>l.trim()).map(l=>JSON.parse(l));
|
||||||
|
const bad=recs.filter(r=>r.status===-1);
|
||||||
|
console.log(`a re-verificar: ${bad.length}`);
|
||||||
|
const langPrefix=(url)=>{const m=url.match(/\/fea\/(es|en|fr|it|pt)\//);if(m)return m[1];if(/\/fea\//.test(url))return 'es';return '';};
|
||||||
|
(async()=>{
|
||||||
|
const b=await chromium.launch({args:['--ignore-certificate-errors']});
|
||||||
|
const ctx=await b.newContext({ignoreHTTPSErrors:true,userAgent:'fea-verify-reverify'});
|
||||||
|
const page=await ctx.newPage();
|
||||||
|
let fixed=0,stillbad=0;
|
||||||
|
for(let i=0;i<bad.length;i++){
|
||||||
|
const r=bad[i]; let ok=false;
|
||||||
|
for(let a=0;a<3 && !ok;a++){
|
||||||
|
try{
|
||||||
|
const resp=await page.goto(r.url,{waitUntil:'domcontentloaded',timeout:30000});
|
||||||
|
r.status=resp?resp.status():0; r.final=page.url(); r.redirected=r.final!==r.url; r.final_lang=langPrefix(r.final);
|
||||||
|
const d=await page.evaluate(()=>{const m=document.querySelector('main,.wp-block-post-content,.entry-content,article')||document.body;return{text:(m.innerText||'').replace(/\s+/g,' ').trim().slice(0,1200),htmlLang:document.documentElement.getAttribute('lang')||'',links:[...new Set(Array.from(document.querySelectorAll('a[href]')).map(a=>a.href).filter(h=>/^https?:/.test(h)))]};});
|
||||||
|
r.text=d.text;r.htmlLang=d.htmlLang;r.links=d.links;delete r.error; ok=true; fixed++;
|
||||||
|
}catch(e){ r.error=String(e).slice(0,120); await page.waitForTimeout(800); }
|
||||||
|
}
|
||||||
|
if(!ok) stillbad++;
|
||||||
|
if((i+1)%100===0) console.log(` ${i+1}/${bad.length} (recuperadas ${fixed}, aún mal ${stillbad})`);
|
||||||
|
}
|
||||||
|
await b.close();
|
||||||
|
fs.writeFileSync(F, recs.map(r=>JSON.stringify(r)).join('\n')+'\n');
|
||||||
|
console.log(`re-verify done: recuperadas ${fixed}, siguen mal ${stillbad}`);
|
||||||
|
})();
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /home/rafa/joomla-migration/tools/e2e/verify
|
||||||
|
exec > run.log 2>&1
|
||||||
|
echo "=== START $(date -u +%FT%TZ) ==="
|
||||||
|
echo "urls: $(python3 -c 'import json;print(len(json.load(open("urls.json"))))')"
|
||||||
|
echo "--- crawl (CONC=3) ---"
|
||||||
|
CONC=3 node crawl.cjs
|
||||||
|
echo "crawl lines: $(wc -l < report.jsonl)"
|
||||||
|
echo "--- analyze ---"
|
||||||
|
.venv/bin/python analyze.py
|
||||||
|
echo "--- link_audit ---"
|
||||||
|
python3 link_audit.py
|
||||||
|
echo "=== DONE $(date -u +%FT%TZ) ==="
|
||||||
Reference in New Issue
Block a user