Un
rastreador web (o araña web) es una parte importante de los motores de búsqueda para rastrear páginas web con el fin de ingresar información sobre ellas en bases de datos, principalmente para su posterior indexación. Los motores de búsqueda (Google, Yandex, Bing), así como los productos SEO (SEMrush, MOZ, ahrefs) y no solo tienen tal cosa. Y esto es bastante interesante: tanto en términos de potencial y casos de uso, como para la implementación técnica.

Con este artículo, comenzaremos a crear
iterativamente su
bicicleta de orugas, analizando muchas características y enfrentando dificultades. Desde una simple función recursiva hasta un servicio escalable y extensible. Debe ser interesante!
Introducción
Iterativamente: significa que al final de cada lanzamiento se espera una versión lista para usar del "producto" con las limitaciones, características e interfaz acordadas.
Se eligieron
Node.js y
JavaScript como plataforma e idioma, porque es simple y asíncrono. Por supuesto, para el desarrollo industrial, la elección de la base tecnológica debe basarse en los requisitos, expectativas y recursos del negocio. Como demostración y prototipo, esta plataforma es completamente nada (en mi humilde opinión).
Este es mi rastreador. Hay muchos de estos rastreadores, pero este es el mío.
Mi rastreador es mi mejor amigo.
La implementación del rastreador es una tarea bastante popular y se puede encontrar incluso en entrevistas técnicas. Realmente hay muchas soluciones preparadas (
Apache Nutch ) y
autoescritas para diferentes condiciones y en muchos idiomas. Por lo tanto, cualquier comentario de experiencia personal en desarrollo o uso es bienvenido y será interesante.
Declaración del problema.
La tarea para la primera implementación (inicial) de nuestro
rastreador tyap-blooper será la siguiente:
One-Two Crawler 1.0
Escriba un script de rastreador que omita los enlaces internos <a href /> de un sitio pequeño (hasta 100 páginas). Como resultado, proporcione una lista de URL de páginas con los códigos recibidos y un mapa de sus enlaces. Se ignoran las reglas de robots.txt y el atributo de enlace rel = nofollow .
Atencion Ignorar las reglas de
robots.txt es una mala idea por razones obvias. Vamos a compensar esta omisión en el futuro. Mientras tanto, agregue el parámetro límite que limita el número de páginas a rastrear para que no detenga DoS y pruebe el sitio experimental (es mejor usar su propio "sitio de hámster" personal para experimentos).
Implementación
Para los impacientes,
aquí están las fuentes de esta solución.
- Cliente HTTP (S)
- Opciones de respuesta
- Extracción de enlaces
- Preparación de enlaces y filtrado
- Normalización de URL
- Algoritmo de función principal
- Resultado devuelto
1. Cliente HTTP (S)
Lo primero que debemos poder hacer es, de hecho, enviar solicitudes y recibir respuestas a través de HTTP y HTTPS. En node.js hay dos clientes coincidentes para esto. Por supuesto, puede tomar una
solicitud de cliente ya hecha , pero para nuestra tarea es extremadamente redundante: solo necesitamos enviar una solicitud GET y obtener una respuesta con el cuerpo y los encabezados.
La API de los dos clientes que necesitamos es idéntica, crearemos un mapa:
const clients = { 'http:': require('http'), 'https:': require('https') };
Declaramos una función simple
fetch , cuyo único parámetro será la URL
absoluta de la cadena de recursos web deseada. Usando
el módulo url, analizaremos la cadena resultante en un objeto URL. Este objeto tiene un campo con el protocolo (con dos puntos), por el cual elegiremos el cliente apropiado:
const url = require('url'); function fetch(dst) { let dstURL = new URL(dst); let client = clients[dstURL.protocol]; if (!client) { throw new Error('Could not select a client for ' + dstURL.protocol); }
A continuación, use el cliente seleccionado y ajuste el resultado de la función de
búsqueda en una promesa:
function fetch(dst) { return new Promise((resolve, reject) => {
Ahora podemos recibir una respuesta asincrónica, pero por ahora no estamos haciendo nada con ella.
2. Opciones de respuesta
Para rastrear el sitio, es suficiente procesar 3 opciones de respuesta:
- OK : se recibió un código de estado 2xx. Es necesario guardar el cuerpo de la respuesta como resultado para un procesamiento posterior, extrayendo nuevos enlaces.
- REDIRECT : se recibió un código de estado 3xx. Esta es una redirección a otra página. En este caso, necesitaremos el encabezado de respuesta de ubicación , desde donde tomaremos un solo enlace "saliente".
- NO_DATA : todos los demás casos: 4xx / 5xx y 3xx sin el encabezado Ubicación . No hay ningún lugar para ir más allá de nuestro rastreador.
La función de
búsqueda resolverá la respuesta procesada que indica su tipo:
const ft = { 'OK': 1,
Implementación de la estrategia de generar el resultado en las mejores tradiciones de
if-else :
let code = res.statusCode; let codeGroup = Math.floor(code / 100);
La función de
recuperación está lista para usar:
el código de función completo .
3. Extracción de enlaces.
Ahora, dependiendo de la variante de la respuesta recibida, debe poder extraer enlaces de los datos de resultados de la
búsqueda para un mayor rastreo. Para hacer esto, definimos la función de
extracción , que toma un objeto de resultado como entrada y devuelve una matriz de nuevos enlaces.
Si el tipo de resultado es REDIRECTO, la función devolverá una matriz con una sola referencia desde el campo de
ubicación . Si NO_DATA, entonces una matriz vacía. Si está bien, entonces necesitamos conectar el analizador para el
contenido de texto presentado para la búsqueda.
Para la tarea de búsqueda
<a href />, también puede escribir una expresión regular. Pero esta solución no escala en absoluto, ya que en el futuro al menos prestaremos atención a otros atributos (
rel ) del enlace, como máximo, pensaremos en
img ,
enlace ,
script ,
audio / video (
fuente ) y otros recursos. Es mucho más prometedor y más conveniente analizar el texto del documento y construir un árbol de sus nodos para evitar los selectores habituales.
Usaremos la popular biblioteca
JSDOM para trabajar con DOM en node.js:
const { JSDOM } = require('jsdom'); let document = new JSDOM(fetched.content).window.document; let elements = document.getElementsByTagName('A'); return Array.from(elements) .map(el => el.getAttribute('href')) .filter(href => typeof href === 'string') .map(href => href.trim()) .filter(Boolean);
Obtenemos todos los elementos
A del documento y luego todos los valores filtrados del atributo
href , si no líneas vacías.
4. Preparación y filtrado de enlaces.
Como resultado del extractor, tenemos un conjunto de enlaces (URL) y dos problemas: 1) la URL puede ser relativa y 2) la URL puede conducir a un recurso externo (ahora solo necesitamos los internos).
El primer problema será ayudado por la función
url.resolve , que
resuelve la URL de la página de destino en relación con la URL de la página de origen.
Para resolver el segundo problema, escribimos una función de utilidad simple
enScope que verifica el host de la página de destino con el host de la URL base del rastreo actual:
function getLowerHost(dst) { return (new URL(dst)).hostname.toLowerCase(); } function inScope(dst, base) { let dstHost = getLowerHost(dst); let baseHost = getLowerHost(base); let i = dstHost.indexOf(baseHost);
La función busca una subcadena (
baseHost ) con una comprobación del carácter anterior si se encontró la subcadena: dado que
wwwexample.com y
example.com son dominios diferentes. Como resultado, no abandonamos el dominio dado, sino que omitimos sus subdominios.
Refinamos la función de
extracción agregando "absolutización" y filtrando los enlaces resultantes:
function extract(fetched, src, base) { return extractRaw(fetched) .map(href => url.resolve(src, href)) .filter(dst => /^https?\:\/\
Aquí
recuperado es el resultado de la función de
obtención ,
src es la URL de la página de origen,
base es la URL base del rastreo. En la salida, obtenemos una lista de enlaces internos (URL) ya absolutos para su posterior procesamiento. El código completo de la función se puede
ver aquí .
5. Normalización de URL
Una vez que haya encontrado cualquier URL nuevamente, no es necesario enviar otra solicitud para el recurso, ya que los datos ya se han recibido (u otra conexión aún está abierta y esperando una respuesta). Pero no siempre es suficiente comparar las cadenas de dos URL para comprender esto. La normalización es el procedimiento necesario para determinar la equivalencia de URL sintácticamente diferentes.
El proceso de
normalización es un conjunto completo de transformaciones aplicadas a la URL de origen y sus componentes. Estos son solo algunos de ellos:
- El esquema y el host no distinguen entre mayúsculas y minúsculas, por lo que se deben convertir a inferiores.
- Todos los porcentajes (como "% 3A") deben estar en mayúsculas.
- El puerto predeterminado (80 para HTTP) se puede eliminar.
- El fragmento ( # ) nunca es visible para el servidor y también se puede eliminar.
Siempre puede tomar algo listo (por ejemplo,
normalizar-url ) o escribir su propia función simple que cubra los casos más importantes y comunes:
function normalize(dst) { let dstUrl = new URL(dst);
Por si acaso, el formato del objeto URL Sí, no hay clasificación de los parámetros de consulta, ignorando las etiquetas utm, procesando
_escaped_fragment_ y otras cosas, que (absolutamente) no necesitamos en absoluto.
A continuación, crearemos un caché local de URL normalizadas solicitadas por el marco de rastreo. Antes de enviar la siguiente solicitud, normalizamos la URL recibida y, si no está en el caché, la agregamos y solo luego enviamos una nueva solicitud.
6. El algoritmo de la función principal.
Los componentes clave (primitivos) de la solución están listos, es hora de comenzar a recopilar todo junto. Para comenzar, determinemos la firma de la función de
rastreo : en la entrada, la URL de inicio y el límite de página. La función devuelve una promesa cuya resolución proporciona un resultado acumulado; escríbelo en el archivo de
salida :
crawl(start, limit).then(result => { fs.writeFile(output, JSON.stringify(result), 'utf8', err => { if (err) throw err; }); });
El flujo de trabajo recursivo más simple de la función de rastreo se puede describir en pasos:
1. Inicialización de la caché y el objeto de resultado.
2. SI la URL de la página de destino (a través de normalizar ) no está en el caché, ENTONCES
- 2.1. Si se alcanza el límite , FIN (esperar el resultado)
- 2.2. Agregar URL a la caché
- 2.3. Guardar el enlace entre la fuente y la página de destino en el resultado
- 2.4. Enviar solicitud asincrónica por página ( buscar )
- 2.5. SI la solicitud es exitosa, ENTONCES
- - 2.5.1. Extraer nuevos enlaces del resultado ( extraer )
- - 2.5.2. Para cada nuevo enlace, ejecute el algoritmo 2-3
- 2.6. ELSE marca la página como un error
- 2.7. Guardar los datos de la página para obtener el resultado
- 2.8. SI esta fue la última página, traiga el resultado
3. ELSE guarda el enlace entre la fuente y la página de destino en el resultado
Sí, este algoritmo sufrirá cambios importantes en el futuro. Ahora, una solución recursiva se usa deliberadamente en la frente, para que luego sea mejor "sentir" la diferencia en las implementaciones. La pieza de trabajo para la implementación de la función se ve así:
function crawl(start, limit = 100) {
El logro del límite de página se verifica mediante un simple contador de solicitudes. El segundo contador, el número de solicitudes activas a la vez, servirá como una prueba de preparación para dar el resultado (cuando el valor se convierte en cero). Si la función de
recuperación no pudo obtener la siguiente página, configure el Código de estado como nulo.
Puede (opcionalmente)
familiarizarse con el código de implementación
aquí , pero antes de eso debe considerar el formato del resultado devuelto.
7. Resultado devuelto
Introduciremos un
identificador de identificación único con un incremento simple para las páginas encuestadas:
let id = 0; let cache = {};
Para el resultado, crearemos una matriz de
páginas en las que agregaremos objetos con datos en la página:
id {número},
url {cadena} y
código {número | nulo} (esto es suficiente). También creamos una matriz de
enlaces para enlaces entre páginas en forma de un objeto:
desde (
id de la página de origen)
hasta (
id de la página de destino).
Con fines informativos, antes de resolver el resultado, clasificamos la lista de páginas en orden ascendente de
identificación (después de todo, las respuestas vendrán en cualquier orden), complementamos el resultado con el número de páginas de
conteo escaneadas y una marca al llegar al límite de
aleta especificado:
resolve({ pages: pages.sort((p1, p2) => p1.id - p2.id), links: links.sort((l1, l2) => l1.from - l2.from || l1.to - l2.to), count, fin: count < limit });
Ejemplo de uso
El script del rastreador terminado tiene la siguiente sinopsis:
node crawl-cli.js --start="<URL>" [--output="<filename>"] [--limit=<int>]
Complementando el registro de los puntos clave del proceso, veremos dicha imagen al inicio:
$ node crawl-cli.js --start="https://google.com" --limit=20 [2019-02-26T19:32:10.087Z] Start crawl "https://google.com" with limit 20 [2019-02-26T19:32:10.089Z] Request (#1) "https://google.com/" [2019-02-26T19:32:10.721Z] Fetched (#1) "https://google.com/" with code 301 [2019-02-26T19:32:10.727Z] Request (#2) "https://www.google.com/" [2019-02-26T19:32:11.583Z] Fetched (#2) "https://www.google.com/" with code 200 [2019-02-26T19:32:11.720Z] Request (#3) "https://play.google.com/?hl=ru&tab=w8" [2019-02-26T19:32:11.721Z] Request (#4) "https://mail.google.com/mail/?tab=wm" [2019-02-26T19:32:11.721Z] Request (#5) "https://drive.google.com/?tab=wo" ... [2019-02-26T19:32:12.929Z] Fetched (#11) "https://www.google.com/advanced_search?hl=ru&authuser=0" with code 200 [2019-02-26T19:32:13.382Z] Fetched (#19) "https://translate.google.com/" with code 200 [2019-02-26T19:32:13.782Z] Fetched (#14) "https://plus.google.com/108954345031389568444" with code 200 [2019-02-26T19:32:14.087Z] Finish crawl "https://google.com" on count 20 [2019-02-26T19:32:14.087Z] Save the result in "result.json"
Y aquí está el resultado en formato JSON:
{ "pages": [ { "id": 1, "url": "https://google.com/", "code": 301 }, { "id": 2, "url": "https://www.google.com/", "code": 200 }, { "id": 3, "url": "https://play.google.com/?hl=ru&tab=w8", "code": 302 }, { "id": 4, "url": "https://mail.google.com/mail/?tab=wm", "code": 302 }, { "id": 5, "url": "https://drive.google.com/?tab=wo", "code": 302 }, // ... { "id": 19, "url": "https://translate.google.com/", "code": 200 }, { "id": 20, "url": "https://calendar.google.com/calendar?tab=wc", "code": 302 } ], "links": [ { "from": 1, "to": 2 }, { "from": 2, "to": 3 }, { "from": 2, "to": 4 }, { "from": 2, "to": 5 }, // ... { "from": 12, "to": 19 }, { "from": 19, "to": 8 } ], "count": 20, "fin": false }
¿Qué se puede hacer con esto ya? Como mínimo, en la lista de páginas puede encontrar todas las páginas rotas del sitio. Y al tener información sobre la vinculación interna, puede detectar cadenas largas (y bucles cerrados) de redireccionamientos o encontrar las páginas más importantes por masa de referencia.
Anuncio 2.0
Hemos obtenido una variante del rastreador de consola más simple, que omite las páginas de un sitio. El código fuente
está aquí . También hay un ejemplo y
pruebas unitarias para algunas funciones.
Ahora, este es un remitente de solicitudes sin ceremonias y el siguiente paso razonable sería enseñarle buenos modales. Se tratará del encabezado
User-agent , las reglas de
robots.txt , la directiva
Crawl-delay y más. Desde el punto de vista de la implementación, primero se trata de poner en cola los mensajes y luego atender una carga mayor.
Si, por supuesto, este material será interesante!