Saltar al contenido
crawlforgeEnglish
Producto3 de noviembre de 20268 min de lecturaRead in English

Un salto a ninguna parte

La 0.21.0 comprueba que cada #fragmento de un enlace interno exista, frena cuando el servidor se queja y pasa una auditoría de dependencias.

La 0.21.0 sale el mismo día que la 0.20.0, la versión que dejó que cada sitio tuviera sus propias reglas. Trae una regla nueva del nivel gratuito, con lo que el catálogo pasa de 71 a 72, un rastreador que afloja antes cuando el servidor va apurado, un JSON Schema para el fichero de configuración y una auditoría de todas las dependencias antes de publicar.

Un enlace interno como <a href="/pagina/#seccion"> promete llevarte a un sitio concreto de la página. Si la página de destino no tiene ningún elemento con id="seccion" ni un <a name="seccion">, el navegador la abre por arriba y la promesa se pierde. LINK-FRAGMENT-BROKEN avisa de esos enlaces.

La comparación sigue el estándar HTML. Primero se prueba el fragmento tal como está escrito, luego decodificado, y en los dos casos tiene que coincidir exacto y respetando mayúsculas. Los enlaces dentro de la misma página también cuentan. Hay fragmentos que no se juzgan nunca: el # a secas, #top en cualquier combinación de mayúsculas, las rutas de aplicaciones JavaScript (#/… y #!…) y las directivas de texto, que se cortan antes, así que #x:~:text=… se juzga como #x. El destino tiene que ser interno y responder 200, la página de origen tiene que dar 2xx, solo se leen elementos <a> y todo lo que cuelga de /cdn-cgi/ queda fuera.

Sale un hallazgo por cada pareja de página de destino y fragmento, agrupado como los demás problemas de plantilla, de modo que un fallo que repite el tema aparece en una sola línea con su número de páginas. La gravedad es baja porque un id que añade JavaScript no está en el HTML que manda el servidor, y el rastreador solo ve ese HTML.

Lo que encontró en sitios de verdad

La probé en cuatro sitios, 400 URLs cada uno. En colorvivo.com dio 396 hallazgos y todos eran #content. Cada artículo empieza con el típico enlace accesible de «saltar al contenido», <a class="skip-link" href="#content">, pero la plantilla de Elementor para las entradas no tiene ningún elemento con id="content". Lo comprobé a mano en un artículo para estar seguro, y la portada sí lo tiene. El informe lo enseña como un único problema de plantilla en 396 páginas, que es lo que es: una línea que cambiar en la plantilla. Además es un fallo de accesibilidad, porque quien navega con teclado o con lector de pantalla pulsa ese enlace y no va a ninguna parte.

Los otros tres no dieron nada, y con razón. diariodelamancha.com tiene 379 enlaces de salto a #content y el id existe. especialmundial.com tiene 398 a #contenido, que también está. carrero.es tiene 303 enlaces con fragmento, 21 de ellos en índices de contenido, y todos llegan a su sitio. También audité desde disco artículos de Wikipedia y salió 1 hallazgo entre 2.335 enlaces con fragmento: #Early_Modern_Spain, un enlace de una caja de navegación a una sección que ese artículo no tiene. Ese es un acierto de verdad.

Los rastreos reales cazaron además un error antes de publicar. WordPress escribe &#038; y &#x27; dentro de los href, y la primera versión cortaba la URL por el # antes de decodificar las entidades, así que se inventaba fragmentos como 038;… que ninguna página podía tener. Ahora las entidades se decodifican primero, y hay un test que lo vigila.

Lo que cuesta guardarlo

La migración 012 añade dos columnas. links.fragment guarda el fragmento, solo en los enlaces internos. pages.fragment_ids guarda los id que declara cada página como una lista ordenada de hashes de 32 bits, 4 bytes cada uno, y se consulta con búsqueda binaria. Una colisión de hash solo puede esconder un fragmento roto y nunca inventarse uno, que es el lado bueno para equivocarse en una auditoría.

Antes de filtrar /cdn-cgi/, los fragmentos de la protección de correo de Cloudflare, de hasta 1 KB cada uno, eran el 6% de un fichero de rastreo real. Con el filtro puesto, las columnas ocupan en torno al 1,5% del fichero en 400 URLs de colorvivo, el 1,3% en diariodelamancha y el 0,1% en especialmundial. Tres páginas de Wikipedia crecieron un 5,4%.

Lo que cuesta leerlo

Esto llevó más intentos que la propia regla. Un selector [id] de lol_html costaba un 9% con el manejador vacío y un 16% leyendo el atributo, en un artículo de Wikipedia de 2 MB con 9.187 id, porque obliga al parser a leer los atributos de todos los elementos. Una expresión regular con capturas costaba un 22%.

La versión que se ha publicado va después del rewriter. Busca el literal id= con la búsqueda SIMD de literales del crate regex, que el motor ya usaba, y mira el byte anterior a cada coincidencia para descartar data-id y ?id=. Cuesta entre un 7 y un 11% en artículos largos de Wikipedia, un 8% en uno de 1,1 MB, un 3% en un artículo de WordPress de 324 KB y entre un 0 y un 3% en una portada de 170 KB, más o menos medio milisegundo por cada página de 2 MB. El banco de regresión en release no muestra ningún cambio medible y se queda en 63.557 elementos por segundo.

Tiene dos huecos conocidos y los dos están documentados: no ve ID="x" en mayúsculas ni id = "x" con espacios.

Los ficheros de rastreo antiguos se abren y migran, y la regla calla en ellos porque las columnas nuevas están vacías. Igual que pasó con la migración de la 0.18.0, un binario 0.20.0 rechaza un fichero escrito por la 0.21.0.

Un rastreador con más mano izquierda

El rastreador ya tenía un freno adaptativo por host. Tres señales seguidas de sobrecarga reducen a la mitad la concurrencia en ese host, y recupera un paso cada 20 respuestas buenas. Hasta ahora solo contaban el 429 y el 503. Ahora cuentan también las conexiones cortadas (reinicios, rechazos, un cuerpo que se queda a medias) y los tiempos de espera agotados, porque un servidor saturado muchas veces ni llega a contestar. Los fallos de DNS y de TLS no cuentan, ya que fallarían igual con el servidor de brazos cruzados.

El Retry-After se respeta ya en las páginas del propio sitio. Un 429 o un 503 que lo lleve, en segundos o como fecha HTTP en cualquiera de los tres formatos que admite la RFC 9110, se reintenta pasado al menos ese tiempo, en lugar de con la espera fija de 1, 2 y 4 segundos. Mientras tanto se para el host entero, porque si solo esperara esa URL las demás conexiones seguirían dándole al mismo servidor. La espera tiene un tope de dos minutos. Si pide más, la página no se reintenta y un 429 queda pendiente para crawlforge resume, como pasa desde la 0.17.0. robots.txt y los sitemaps también respetan la espera.

El resumen del rastreo ahora dice cuándo ha pasado. Contra un servidor local que respondía 503 en 12 de 30 páginas, sacó esto:

The server pushed back (429, 503, dropped connections or timeouts), so the crawl
slowed down: its concurrency was halved 3 times. Next time, try a lower --concurrency.

En un sitio sano no cambia nada que se pueda medir. El banco en release se quedó dentro del ruido frente a la 0.20.0 en 8 rondas alternas, y un rastreo real de 500 URLs de especialmundial.com tardó lo mismo con las dos versiones, entre 3,7 y 5,3 segundos.

Hay un límite que prefiero decir claro. Un host que deja de contestar del todo sigue recibiendo cuatro intentos por URL, y para ese caso el tope es --max-duration.

Un esquema para la configuración

crawlforge config schema imprime un JSON Schema (draft 2020-12) del YAML que se pasa con --config, y también está publicado como docs/crawl-config.schema.json. El fichero de ejemplo empieza con esta línea, así que un editor con servidor de lenguaje YAML te completa claves e IDs de regla y te subraya las erratas mientras escribes:

# yaml-language-server: $schema=https://raw.githubusercontent.com/dcarrero/crawlforge/main/docs/crawl-config.schema.json

El esquema sale del propio código, con los IDs de regla del catálogo y los valores por defecto del motor, sin ninguna dependencia nueva. Los tests fallan si se separa de la estructura de configuración, y lo comprobé añadiendo un campo de mentira. También le viene bien a un asistente que te escriba el fichero, porque tiene las claves reales y no se las inventa.

La auditoría de dependencias

Antes de publicar pasé el fichero de bloqueo por rust-audit-box, una herramienta mía que ejecuta cargo-audit, cargo-deny, cargo-geiger y clippy en un contenedor aislado, con la fase de compilación sin red. Encontró 17 avisos.

El que importaba era el de quick-xml 0.37.5, que lee los sitemaps, y un sitemap es entrada en la que no se puede confiar. Tenía dos avisos de denegación de servicio, uno por reservar memoria sin límite para los espacios de nombres y otro por una comprobación cuadrática de atributos duplicados. Nuestro código nunca pasaba por ahí (ni NsReader ni lectura de atributos), así que subir a la 0.42 fue preventivo. La reescritura tuvo que conservar dos comportamientos que el lector nuevo habría roto. Un & suelto o un byte que no es UTF-8, como en un sitemap en ISO-8859-1 que lista /café, cuesta solo ese <loc> y no el sitemap entero. Un sitemap de 50.000 URLs se lee ahora en 26 ms, frente a los 18 ms de antes. También se actualizaron la capa TLS (rustls 0.23.46 y webpki) y tokio 1.54.1.

Los avisos de zip, críticos y altos, iban de extraer archivos comprimidos, y el motor solo escribe XLSX. Aun así compensó subir rust_xlsxwriter de la 0.79 a la 0.102, que trae zip 9: exportar a XLSX un rastreo de 700 MB pasó de un pico de 1,23 GB de memoria a 20 MB, en el mismo tiempo (unos 18 segundos) y con las celdas idénticas. Lo comprobé con openpyxl, con 14 hojas y la de enlaces en 1.048.576 filas.

La auditoría final no encontró vulnerabilidades conocidas ni avisos de RustSec, y cargo-deny dio cero errores. Un deny.toml deja escrita la política: solo crates de crates.io y nueve licencias permitidas. El workspace declara Apache-2.0 en Cargo.toml, donde ponía UNLICENSED aunque el repositorio ya era Apache 2.0. La versión mínima de Rust para compilar desde el código pasa de la 1.85 a la 1.88.

Cosas más pequeñas

Los motivos por los que se excluyó una URL (robots.txt, un patrón de URL, la auditoría de una carpeta sin red) salen traducidos en report y en inspect, igual que la línea «File:» de la cabecera de crawl y resume. crawlforge rules | head ya no revienta con «Broken pipe»: los comandos que nunca abren un socket terminan sin ruido, como cualquier filtro de Unix. crawl, list y resume mantienen a propósito el comportamiento de antes, para que un servidor que cierra la conexión a mitad de escritura no pueda acabar con un rastreo.

Se están preparando binarios compilados por la integración continua para cada versión y un tap de Homebrew.

Se construye en público

Cada dos semanas: mediciones, fallos y ejemplos de uso. Nada más.

← Volver a la bitácora