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:
2026-06-22 08:30:18 -04:00
parent 863a3c6677
commit 66ee943da4
10 changed files with 582 additions and 0 deletions
+70
View File
@@ -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")