Um
rastreador da Web (ou web spider) é uma parte importante dos mecanismos de pesquisa para rastrear páginas da Web, a fim de inserir informações sobre eles nos bancos de dados, principalmente para indexação posterior. Os mecanismos de pesquisa (Google, Yandex, Bing), bem como os produtos de SEO (SEMrush, MOZ, ahrefs), e não apenas têm isso. E isso é bastante interessante: tanto em termos de casos potenciais quanto de uso, e para implementação técnica.

Com este artigo, começaremos a criar
iterativamente nossa
bicicleta de esteira, analisando muitos recursos e atendendo às armadilhas. De uma simples função recursiva a um serviço escalável e extensível. Deve ser interessante!
Introdução
Iterativamente - significa que ao final de cada versão é esperada uma versão pronta para uso do "produto", com as limitações, recursos e interface acordados.
Node.js e
JavaScript foram escolhidos como plataforma e idioma, por serem simples e assíncronos. Obviamente, para o desenvolvimento industrial, a escolha da base tecnológica deve se basear nos requisitos, expectativas e recursos do negócio. Como demonstração e protótipo, esta plataforma é completamente nada (IMHO).
Este é o meu rastreador. Existem muitos rastreadores, mas este é meu.
Meu rastreador é meu melhor amigo.
A implementação do rastreador é uma tarefa bastante popular e pode ser encontrada mesmo em entrevistas técnicas. Há realmente muitas soluções prontas (
Apache Nutch ) e auto-escritas para diferentes condições e em vários idiomas. Portanto, quaisquer comentários da experiência pessoal em desenvolvimento ou uso são bem-vindos e serão interessantes.
Declaração do problema
A tarefa para a primeira implementação (inicial) do nosso rastreador
tyap-blooper será a seguinte:
Um-dois rastreador 1.0
Escreva um script de rastreador que ignore os links internos <a href /> de um site pequeno (até 100 páginas). Como resultado, forneça uma lista de URLs de páginas com os códigos recebidos e um mapa de seus links. As regras robots.txt e o atributo do link rel = nofollow são ignorados.
Atenção! Ignorar as regras do
robots.txt é uma má ideia por razões óbvias. Iremos compensar essa omissão no futuro. Enquanto isso, adicione o parâmetro limit que limita o número de páginas a serem rastreadas para que não pare o DoS e tente o site experimental (é melhor usar o seu próprio "site de hamster" pessoal para experiências).
Implementação
Para os impacientes,
aqui estão as fontes desta solução.
- Cliente HTTP (S)
- Opções de resposta
- Extração de link
- Preparação e filtragem de links
- Normalização de URL
- Algoritmo da função principal
- Resultado de retorno
1. Cliente HTTP (S)
A primeira coisa que precisamos fazer é, de fato, enviar solicitações e receber respostas via HTTP e HTTPS. No node.js, existem dois clientes correspondentes para isso. Obviamente, você pode atender a uma
solicitação de cliente pronta , mas para nossa tarefa é extremamente redundante: basta enviar uma solicitação GET e obter uma resposta com o corpo e os cabeçalhos.
A API dos dois clientes que precisamos é idêntica, vamos obter um mapa:
const clients = { 'http:': require('http'), 'https:': require('https') };
Declaramos uma
busca de função simples, cujo único parâmetro será o URL
absoluto da cadeia de recursos da web desejada. Usando
o módulo url, analisaremos a sequência resultante em um objeto de URL. Este objeto possui um campo com o protocolo (com dois pontos), pelo qual escolheremos o cliente apropriado:
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); }
Em seguida, use o cliente selecionado e agrupe o resultado da função de
busca em uma promessa:
function fetch(dst) { return new Promise((resolve, reject) => {
Agora podemos receber resposta de forma assíncrona, mas por enquanto não estamos fazendo nada com ela.
2. Opções de resposta
Para rastrear o site, basta processar três opções de resposta:
- OK - Um código de status 2xx foi recebido. É necessário salvar o corpo da resposta como resultado para processamento adicional - extração de novos links.
- REDIRECIONAR - Um código de status 3xx foi recebido. Este é um redirecionamento para outra página. Nesse caso, precisaremos do cabeçalho de resposta da localização , de onde pegaremos um único link "de saída".
- NO_DATA - Todos os outros casos: 4xx / 5xx e 3xx sem o cabeçalho Location . Não há para onde ir além do nosso rastreador.
A função de
busca resolverá a resposta processada, indicando seu tipo:
const ft = { 'OK': 1,
Implementação da estratégia de gerar o resultado nas melhores tradições do
if-else :
let code = res.statusCode; let codeGroup = Math.floor(code / 100);
A função de
busca está pronta para uso:
todo o código da função .
3. Extração de links
Agora, dependendo da variante da resposta recebida, você precisa extrair links dos dados do resultado da
busca para rastreamento adicional. Para fazer isso, definimos a função
extrair , que pega um objeto de resultado como entrada e retorna uma matriz de novos links.
Se o tipo de resultado for REDIRECIONAR, a função retornará uma matriz com uma única referência do campo de
localização . Se NO_DATA, uma matriz vazia. Se estiver bem, precisamos conectar o analisador do
conteúdo de texto apresentado para pesquisa.
Para a tarefa de pesquisa
<a href />, você também pode escrever uma expressão regular. Mas essa solução não é escalável, já que no futuro prestaremos atenção, no mínimo, a outros atributos (
rel ) do link, no máximo, pensaremos em
img ,
link ,
script ,
áudio / vídeo (
fonte ) e outros recursos. É muito mais promissor e mais conveniente analisar o texto do documento e criar uma árvore de seus nós para ignorar os seletores comuns.
Usaremos a popular biblioteca
JSDOM para trabalhar com o DOM no 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);
Obtemos todos os elementos
A do documento e, em seguida, todos os valores filtrados do atributo
href , se não linhas vazias.
4. Preparação e filtragem de links
Como resultado do extrator, temos um conjunto de links (URL) e dois problemas: 1) o URL pode ser relativo e 2) o URL pode levar a um recurso externo (precisamos apenas de recursos internos agora).
O primeiro problema será ajudado pela função
url.resolve , que resolve o URL da página de destino em relação ao URL da página de origem.
Para resolver o segundo problema, escrevemos uma função de utilitário simples
inScope que verifica o host da página de destino com o host do URL base do rastreamento atual:
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);
A função procura uma substring (
baseHost ) com uma verificação do caractere anterior, se a substring for encontrada: como
wwwexample.com e
example.com são domínios diferentes. Como resultado, não deixamos o domínio especificado, mas ignoramos seus subdomínios.
Refinamos a função
extrair adicionando "absolutização" e filtrando os links resultantes:
function extract(fetched, src, base) { return extractRaw(fetched) .map(href => url.resolve(src, href)) .filter(dst => /^https?\:\/\
Aqui
buscado é o resultado da função de
busca ,
src é o URL da página de origem,
base é o URL de base do rastreamento. Na saída, obtemos uma lista de links internos (URLs) já absolutos para processamento adicional. Todo o código da função pode ser
visto aqui .
5. Normalização de URL
Depois de encontrar qualquer URL novamente, não há necessidade de enviar outra solicitação para o recurso, pois os dados já foram recebidos (ou outra conexão ainda está aberta e aguardando uma resposta). Mas nem sempre é suficiente comparar as sequências de dois URLs para entender isso. A normalização é o procedimento necessário para determinar a equivalência de URLs sintaticamente diferentes.
O processo de
normalização é um conjunto inteiro de transformações aplicadas ao URL de origem e seus componentes. Aqui estão apenas alguns deles:
- O esquema e o host não diferenciam maiúsculas de minúsculas; portanto, eles devem ser convertidos para mais baixos.
- Todas as porcentagens (como "% 3A") devem estar em maiúsculas.
- A porta padrão (80 para HTTP) pode ser removida.
- O fragmento ( # ) nunca é visível para o servidor e também pode ser excluído.
Você sempre pode pegar algo pronto (por exemplo,
normalizar-url ) ou escrever sua própria função simples, cobrindo os casos mais importantes e comuns:
function normalize(dst) { let dstUrl = new URL(dst);
Apenas no caso, o formato do objeto URL Sim, não há classificação dos parâmetros de consulta, ignorando as tags utm, processando
_escaped_fragment_ e outras coisas, das quais (absolutamente) não precisamos.
Em seguida, criaremos um cache local de URLs normalizados solicitados pela estrutura de rastreamento. Antes de enviar a próxima solicitação, normalizamos a URL recebida e, se ela não estiver no cache, adicione-a e só então envie uma nova solicitação.
6. O algoritmo da função principal
Os principais componentes (primitivos) da solução estão prontos, é hora de começar a coletar tudo juntos. Para começar, vamos determinar a assinatura da função de
rastreamento : na entrada, o URL inicial e o limite de páginas. A função retorna uma promessa cuja resolução fornece um resultado acumulado; escreva-o no arquivo de
saída :
crawl(start, limit).then(result => { fs.writeFile(output, JSON.stringify(result), 'utf8', err => { if (err) throw err; }); });
O fluxo de trabalho recursivo mais simples da função de rastreamento pode ser descrito nas etapas:
1. Inicialização do cache e do objeto de resultado
2. Se o URL da página de destino (via normalizar ) não estiver no cache, ENTÃO
2.1. Se o limite for atingido, END (aguarde pelo resultado)
2.2. Adicionar URL ao cache
2.3. Salvar link entre a origem e a página de destino no resultado
2.4. Enviar solicitação assíncrona por página ( busca )
- 2.5 Se a solicitação for bem-sucedida, ENTÃO
- 2.5.1. Extrair novos links do resultado ( extrair )
- - 2.5.2. Para cada novo link, execute o algoritmo 2-3
- 2.6 ELSE marcar a página como um erro
- 2.7 Salvar dados da página no resultado
- 2.8 Se essa foi a última página, traga o resultado
3. ELSE salve o link entre a fonte e a página de destino no resultado
Sim, este algoritmo passará por grandes mudanças no futuro. Agora, uma solução recursiva é usada deliberadamente na testa, para que mais tarde seja melhor “sentir” a diferença nas implementações. A peça de trabalho para a implementação da função é assim:
function crawl(start, limit = 100) {
A conquista do limite de páginas é verificada por um simples contador de solicitações. O segundo contador - o número de solicitações ativas de cada vez - servirá como um teste de prontidão para fornecer o resultado (quando o valor voltar a zero). Se a função de
busca não puder acessar a próxima página, defina o Código de Status para ele como nulo.
Você pode (opcionalmente)
se familiarizar com o código de implementação
aqui , mas antes disso, considere o formato do resultado retornado.
7. resultado retornado
Introduziremos um
identificador de identificação exclusivo com um incremento simples para as páginas pesquisadas:
let id = 0; let cache = {};
Para o resultado, vamos criar uma matriz de
páginas , na qual adicionaremos objetos com dados na página:
id {number},
url {string} e
código {number | null} (agora é suficiente). Também criamos uma matriz de
links para links entre páginas na forma de um objeto:
de (
ID da página de origem)
a (
ID da página de destino).
Para fins informativos, antes de resolver o resultado, classificamos a lista de páginas em ordem crescente de
id (afinal, as respostas virão em qualquer ordem), complementamos o resultado com o número de páginas de
contagem digitalizadas e um sinalizador para atingir o limite de
aletas 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 });
Exemplo de uso
O script do rastreador finalizado possui a seguinte sinopse:
node crawl-cli.js --start="<URL>" [--output="<filename>"] [--limit=<int>]
Complementando o registro dos pontos principais do processo, veremos uma imagem na inicialização:
$ 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"
E aqui está o resultado no 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 }
O que pode ser feito com isso já? No mínimo, a lista de páginas você pode encontrar todas as páginas quebradas do site. E, com informações sobre links internos, é possível detectar longas cadeias (e loops fechados) de redirecionamentos ou encontrar as páginas mais importantes por massa de referência.
Anúncio 2.0
Obtivemos uma variante do rastreador de console mais simples, que ignora as páginas de um site. O código fonte
está aqui . Há também um exemplo e
testes de unidade para algumas funções.
Agora, este é um remetente sem cerimônia de pedidos e o próximo passo razoável seria ensinar-lhe boas maneiras. Será sobre o cabeçalho do
agente do
usuário , regras
robots.txt , diretiva de
atraso de rastreamento e muito mais. Do ponto de vista da implementação, isso é antes de tudo enfileirar mensagens e, em seguida, atender a uma carga maior.
Se, é claro, este material será interessante!