Escrevemos um rastreador para um ou dois 1.0

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.

  1. Cliente HTTP (S)
  2. Opções de resposta
  3. Extração de link
  4. Preparação e filtragem de links
  5. Normalização de URL
  6. Algoritmo da função principal
  7. 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) => { // ... let req = client.get(dstURL.href, res => { // do something with the response }); req.on('error', err => reject('Failed on the request: ' + err.message)); req.end(); }); } 


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:

  1. 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.
  2. 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".
  3. 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, // code (2xx), content 'REDIRECT': 2, // code (3xx), location 'NO_DATA': 3 // code }; 

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); // OK if (codeGroup === 2) { let body = []; res.setEncoding('utf8'); res.on('data', chunk => body.push(chunk)); res.on('end', () => resolve({ code, content: body.join(''), type: ft.OK })); } // REDIRECT else if (codeGroup === 3 && res.headers.location) { resolve({ code, location: res.headers.location, type: ft.REDIRECT }); } // NO_DATA (others) else { resolve({ code, type: ft.NO_DATA }); } 

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); // the same domain or has subdomains return i === 0 || dstHost[i - 1] === '.'; } 

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?\:\/\//i.test(dst)) .filter(dst => inScope(dst, base)); } 

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); // ignore userinfo (auth property) let origin = dstUrl.protocol + '//' + dstUrl.hostname; // ignore http(s) standart ports if (dstUrl.port && (!/^https?\:/i.test(dstUrl.protocol) || ![80, 8080, 443].includes(+dstUrl.port))) { origin += ':' + dstUrl.port; } // ignore fragment (hash property) let path = dstUrl.pathname + dstUrl.search; // convert origin to lower case return origin.toLowerCase() // and capitalize letters in escape sequences + path.replace(/%([0-9a-f]{2})/ig, (_, es) => '%' + es.toUpperCase()); } 

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) { // initialize cache & result return new Promise((resolve, reject) => { function curl(src, dst) { // check dst in the cache & pages limit // save the link (src -> dst) to the result fetch(dst).then(fetched => { extract(fetched, dst, start).forEach(ln => curl(dst, ln)); }).finally(() => { // save the page's data to the result // check completion and resolve the result }); } curl(null, start); }); } 

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 = {}; // ... let dstNorm = normalize(dst); if (dstNorm in cache === false) { cache[dstNorm] = ++id; // ... } 

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!

Source: https://habr.com/ru/post/pt441024/


All Articles