diff --git a/src/markdown_ast.py b/src/markdown_ast.py index 6c2b70f3..1b069ebf 100644 --- a/src/markdown_ast.py +++ b/src/markdown_ast.py @@ -7,52 +7,71 @@ class MarkdownSanitizer: def __init__(self): # Captura [texto](url) self.link_pattern = re.compile(r'\[([^\]]+)\]\((https?://[^\)]+)\)') - # Dominios de alta confianza que NUNCA se borran + + # 1. WHITELIST: Dominios de alta confianza que NUNCA se borran. + # No validamos estos para evitar bloqueos de bots o errores temporales. self.safe_domains = [ 'github.com', 'medium.com', 'dev.to', 'twitter.com', 'x.com', - 'hashnode.com', 'linkedin.com', 'dzone.com', 'youtube.com', - 'aws.amazon.com', 'kubernetes.io', 'cncf.io', 'microsoft.com', - 'google.com', 'oracle.com', 'redhat.com', 'ibm.com', 'stackoverflow.com' + 'hashnode.com', 'hashnode.dev', 'linkedin.com', 'dzone.com', + 'youtube.com', 'aws.amazon.com', 'kubernetes.io', 'cncf.io', + 'microsoft.com', 'google.com', 'oracle.com', 'redhat.com', + 'ibm.com', 'stackoverflow.com', 'substack.com', 'vimeo.com', + 'infoq.com', 'reddit.com', 'docker.com', 'terraform.io' ] - async def _check_url_robust(self, session: aiohttp.ClientSession, url: str, retries: int = 5) -> Tuple[bool, Optional[str]]: + async def _check_url_robust(self, session: aiohttp.ClientSession, url: str, retries: int = 4) -> Tuple[bool, Optional[str]]: """ + Validador conservador de URLs. Retorna (is_alive, final_url). - Implementa User-Agent realista y backoff exponencial. """ - if any(domain in url.lower() for domain in self.safe_domains): + url_lower = url.lower() + + # Regla de oro: Si está en la whitelist, es válido. + if any(domain in url_lower for domain in self.safe_domains): return True, None + # Headers realistas para evitar ser detectados como bot básico headers = { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', + 'Referer': 'https://www.google.com/' } for attempt in range(retries): try: - # Usamos HEAD primero por eficiencia, si falla probamos GET + # Usamos GET porque muchos sitios bloquean HEAD async with session.get(url, timeout=15, allow_redirects=True, headers=headers) as response: if response.status < 400: final_url = str(response.url).rstrip('/') original_url = url.split('#')[0].rstrip('/') + # Detectar redirecciones permanentes para limpiar el repo if final_url != original_url and response.status in [301, 308]: + print(f"[REDIRECCIÓN] {url} -> {final_url}") return True, str(response.url) return True, None - if response.status == 429 or response.status >= 500: - # Rate limiting o error de servidor: esperar y reintentar - await asyncio.sleep((2 ** attempt) + 1) - continue + # SOLO borramos si es un 404 confirmado + if response.status == 404: + if attempt < retries - 1: + await asyncio.sleep(2 ** attempt) + continue + print(f"[BORRADO 404] {url}") + return False, None + + # Ante 403, 401, 429 o 5xx, MANTENEMOS el enlace por seguridad. + # Son errores que suelen ser temporales o bloqueos de seguridad al bot. + print(f"[AVISO] Saltando validación de {url} por estado {response.status}") + return True, None - # Si es un error 404/403 definitivo después de varios reintentos - return False, None except Exception as e: if attempt < retries - 1: - await asyncio.sleep((2 ** attempt) + 1) + # Backoff exponencial: 1s, 2s, 4s... + await asyncio.sleep(2 ** attempt) continue + # Si hay timeout persistente, lo mantenemos vivo. + return True, None - # Por defecto, si hay duda (timeout persistente), lo mantenemos vivo return True, None async def sanitize_document(self, markdown_content: str) -> Tuple[str, dict]: @@ -61,8 +80,8 @@ class MarkdownSanitizer: stats = {"fixed": 0, "removed": 0, "duplicates": 0} seen_in_file = set() - # Reducimos concurrencia de 30 a 10 para ser más "amigables" con los servidores - connector = aiohttp.TCPConnector(limit=10) + # Limitamos concurrencia para no saturar + connector = aiohttp.TCPConnector(limit=5) async with aiohttp.ClientSession(connector=connector) as session: for line in lines: match = self.link_pattern.search(line) @@ -73,6 +92,7 @@ class MarkdownSanitizer: text, url = match.groups() clean_url = url.split('#')[0].rstrip('/') + # Limpieza de duplicados en el mismo archivo if clean_url in seen_in_file: stats["duplicates"] += 1 continue @@ -86,25 +106,25 @@ class MarkdownSanitizer: stats["fixed"] += 1 new_lines.append(line) else: - print(f"[-] Eliminando enlace caído confirmado: {url}") stats["removed"] += 1 return "\n".join(new_lines), stats def inject_curated_link(self, markdown_text: str, category: str, title: str, url: str, description: str) -> str: - # Evitar duplicados antes de inyectar + # No inyectar si ya existe if url.split('#')[0].rstrip('/') in markdown_text: return markdown_text new_entry = f" - [{title}]({url}) - {description}" lines = markdown_text.splitlines() - # Buscar el mejor sitio (debajo del encabezado de la categoría o al final) + # Buscar encabezado de categoría for i, line in enumerate(lines): if category.lower() in line.lower() and (line.startswith("#") or line.startswith("-")): lines.insert(i + 1, new_entry) return "\n".join(lines) + # Si no existe la sección, crearla al final lines.append(f"\n## {category}\n") lines.append(new_entry) return "\n".join(lines)