Un
robot d'indexation (ou web spider) est un élément important des moteurs de recherche pour l'exploration de pages Web afin de saisir des informations à leur sujet dans des bases de données, principalement pour leur indexation ultérieure. Les moteurs de recherche (Google, Yandex, Bing), ainsi que les produits de référencement (SEMrush, MOZ, ahrefs) et non seulement ont une telle chose. Et cette chose est assez intéressante: à la fois en termes de cas potentiels et d'utilisation, et pour la mise en œuvre technique.

Avec cet article, nous allons commencer à créer de
manière itérative votre
vélo sur chenilles, en analysant de nombreuses fonctionnalités et en rencontrant les pièges. D'une simple fonction récursive à un service évolutif et extensible. Ça doit être intéressant!
Intro
Itérativement - cela signifie qu'à la fin de chaque version, une version prête à l'emploi du «produit» est attendue avec les limitations, les fonctionnalités et l'interface convenues.
Node.js et
JavaScript ont été choisis comme plate-forme et langage, car il est simple et asynchrone. Bien entendu, pour le développement industriel, le choix de la base technologique doit être basé sur les besoins, les attentes et les ressources de l'entreprise. À titre de démonstration et de prototype, cette plateforme n'est absolument rien (à mon humble avis).
Ceci est mon robot. Il existe de nombreux robots de ce type, mais celui-ci est à moi.
Mon robot est mon meilleur ami.
La mise en œuvre d'un robot est une tâche assez populaire et peut être trouvée même lors des entretiens techniques. Il y a vraiment beaucoup de solutions toutes faites (
Apache Nutch ) et auto-écrites pour différentes conditions et dans de nombreuses langues. Par conséquent, tout commentaire provenant d'une expérience personnelle dans le développement ou l'utilisation est le bienvenu et sera intéressant.
Énoncé du problème
La tâche de la première implémentation (initiale) de notre
robot tyap-blooper sera la suivante:
One-Two Crawler 1.0
Écrivez un script de robot qui contourne les liens internes <a href /> d'un petit site (jusqu'à 100 pages). En conséquence, fournissez une liste d'URL de pages avec les codes reçus et une carte de leur lien. Les règles robots.txt et l' attribut de lien rel = nofollow sont ignorés.
Attention! Ignorer les règles
robots.txt est une mauvaise idée pour des raisons évidentes. Nous compenserons cette omission à l'avenir. En attendant, ajoutez le paramètre limit limitant le nombre de pages à explorer afin qu'il n'arrête pas DoS et essayez le site expérimental (il est préférable d'utiliser votre propre "site hamster" personnel pour les expérimentations).
Implémentation
Pour les impatients,
voici les sources de cette solution.
- Client HTTP (S)
- Options de réponse
- Extraction de liens
- Préparation et filtrage des liens
- Normalisation d'URL
- Algorithme de fonction principale
- Résultat de retour
1. Client HTTP (S)
La première chose que nous devons pouvoir faire est, en fait, d'envoyer des demandes et de recevoir des réponses via HTTP et HTTPS. Dans node.js, il y a deux clients correspondants pour cela. Bien sûr, vous pouvez prendre une
demande client prête à l'emploi , mais pour notre tâche, elle est extrêmement redondante: il nous suffit d'envoyer une demande GET et d'obtenir une réponse avec le corps et les en-têtes.
L'API des deux clients dont nous avons besoin est identique, nous allons créer une carte:
const clients = { 'http:': require('http'), 'https:': require('https') };
Nous déclarons une simple fonction
fetch , dont le seul paramètre sera l'URL
absolue de la chaîne de ressource Web souhaitée. En utilisant
le module url, nous analyserons la chaîne résultante dans un objet URL. Cet objet a un champ avec le protocole (avec deux points), par lequel nous choisirons le client approprié:
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); }
Ensuite, utilisez le client sélectionné et encapsulez le résultat de la fonction d'
extraction dans une promesse:
function fetch(dst) { return new Promise((resolve, reject) => {
Nous pouvons maintenant recevoir une réponse de manière asynchrone, mais pour l'instant nous ne faisons rien avec.
2. Options de réponse
Pour explorer le site, il suffit de traiter 3 options de réponse:
- OK - Un code d'état 2xx a été reçu. Il est nécessaire d'enregistrer le corps de la réponse pour un traitement ultérieur - extraire de nouveaux liens.
- REDIRECT - Un code d'état 3xx a été reçu. Il s'agit d'une redirection vers une autre page. Dans ce cas, nous aurons besoin de l'en-tête de réponse de localisation , d'où nous prendrons un seul lien «sortant».
- NO_DATA - Tous les autres cas: 4xx / 5xx et 3xx sans en-tête d' emplacement . Il n'y a nulle part où aller plus loin pour notre robot.
La fonction d'
extraction résoudra la réponse traitée en indiquant son type:
const ft = { 'OK': 1,
Mise en œuvre de la stratégie de génération du résultat dans les meilleures traditions du
if-else :
let code = res.statusCode; let codeGroup = Math.floor(code / 100);
La fonction
fetch est prête à l'emploi:
tout le code de la fonction .
3. Extraction de liens
Maintenant, selon la variante de la réponse reçue, vous devez être en mesure d'extraire des liens des données de résultat de l'
extraction pour une analyse ultérieure. Pour ce faire, nous définissons la fonction d'
extraction , qui prend un objet de résultat en entrée et renvoie un tableau de nouveaux liens.
Si le type de résultat est REDIRECT, la fonction renverra un tableau avec une seule référence dans le champ d'
emplacement . Si NO_DATA, alors un tableau vide. Si OK, alors nous devons connecter l'analyseur pour le
contenu texte présenté pour la recherche.
Pour la tâche de recherche
<a href />, vous pouvez également écrire une expression régulière. Mais cette solution n'est pas du tout évolutive, car à l'avenir, nous ferons au moins attention aux autres attributs (
rel ) du lien, au maximum, nous penserons à
img ,
link ,
script ,
audio / video (
source ) et à d'autres ressources. Il est beaucoup plus prometteur et plus pratique d'analyser le texte du document et de construire une arborescence de ses nœuds pour contourner les sélecteurs habituels.
Nous utiliserons la bibliothèque
JSDOM populaire pour travailler avec le DOM dans 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);
Nous obtenons tous les éléments
A du document, puis toutes les valeurs filtrées de l'attribut
href , sinon des lignes vides.
4. Préparation et filtrage des liens
En raison de l'extracteur, nous avons un ensemble de liens (URL) et deux problèmes: 1) l'URL peut être relative et 2) l'URL peut conduire à une ressource externe (nous n'avons besoin que de liens internes maintenant).
Le premier problème sera résolu par la fonction
url.resolve , qui résout l'URL de la page de destination par rapport à l'URL de la page source.
Pour résoudre le deuxième problème, nous écrivons une fonction utilitaire simple
inScope qui vérifie l'hôte de la page de destination par rapport à l'hôte de l'URL de base de l'analyse actuelle:
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 fonction recherche une sous-chaîne (
baseHost ) avec une vérification du caractère précédent si la sous-chaîne a été trouvée: puisque
wwwexample.com et
example.com sont des domaines différents. En conséquence, nous ne quittons pas le domaine donné, mais contournons ses sous-domaines.
Nous affinons la fonction d'
extraction en ajoutant «absolutisation» et en filtrant les liens résultants:
function extract(fetched, src, base) { return extractRaw(fetched) .map(href => url.resolve(src, href)) .filter(dst => /^https?\:\/\
Ici
récupéré est le résultat de la fonction de
récupération ,
src est l'URL de la page source,
base est l'URL de base de l'analyse. À la sortie, nous obtenons une liste de liens internes (URL) déjà absolus pour un traitement ultérieur. Le code de fonction complet peut être
vu ici .
5. Normalisation d'URL
En rencontrant à nouveau une URL, vous n'avez pas besoin d'envoyer une autre demande pour la ressource, car les données ont déjà été reçues (ou une autre connexion est toujours ouverte et attend une réponse). Mais il ne suffit pas toujours de comparer les chaînes de deux URL pour comprendre cela. La normalisation est la procédure nécessaire pour déterminer l'équivalence d'URL syntaxiquement différentes.
Le processus de
normalisation est un ensemble complet de transformations appliquées à l'URL source et à ses composants. En voici quelques-unes:
- Le schéma et l'hôte ne sont pas sensibles à la casse, ils doivent donc être convertis en version inférieure.
- Tous les pourcentages (tels que "% 3A") doivent être en majuscules.
- Le port par défaut (80 pour HTTP) peut être supprimé.
- Le fragment ( # ) n'est jamais visible par le serveur et peut également être supprimé.
Vous pouvez toujours prendre quelque chose de prêt à l'emploi (par exemple,
normaliser l'url ) ou écrire votre propre fonction simple couvrant les cas les plus importants et les plus courants:
function normalize(dst) { let dstUrl = new URL(dst);
Au cas où, le format de l'objet URL Oui, il n'y a pas de tri des paramètres de requête, ignorant les balises utm, traitant le
_escaped_fragment_ et d'autres choses, dont nous n'avons absolument pas besoin.
Ensuite, nous allons créer un cache local d'URL normalisées demandées par le framework Crawl. Avant d'envoyer la prochaine demande, nous normalisons l'URL reçue, et si elle n'est pas dans le cache, ajoutez-la et ensuite envoyez une nouvelle demande.
6. L'algorithme de la fonction principale
Les composants clés (primitives) de la solution sont prêts, il est temps de commencer à tout rassembler. Pour commencer, déterminons la signature de la fonction d'
analyse : à l'entrée, l'URL de départ et la limite de page. La fonction renvoie une promesse dont la résolution fournit un résultat cumulé; l'écrire dans le fichier de
sortie :
crawl(start, limit).then(result => { fs.writeFile(output, JSON.stringify(result), 'utf8', err => { if (err) throw err; }); });
Le flux de travail récursif le plus simple de la fonction d'analyse peut être décrit par étapes:
1. Initialisation du cache et de l'objet résultat
2. SI l'URL de la page de destination (via normaliser ) n'est pas dans le cache, ALORS
- 2.1. SI la limite est atteinte, END (attendre le résultat)
- 2.2. Ajouter une URL au cache
- 2.3. Enregistrer le lien entre la source et la page de destination dans le résultat
- 2.4. Envoyer une demande asynchrone par page ( récupération )
- 2.5. SI la demande est acceptée, ALORS
- - 2.5.1. Extraire de nouveaux liens du résultat ( extrait )
- - 2.5.2. Pour chaque nouveau lien, exécutez l'algorithme 2-3
- 2.6. ELSE marque la page comme une erreur
- 2.7. Enregistrer les données de la page pour obtenir
- 2.8. SI c'était la dernière page, apportez le résultat
3. ELSE enregistre le lien entre la source et la page de destination dans le résultat
Oui, cet algorithme subira des changements majeurs à l'avenir. Maintenant, une solution récursive est utilisée délibérément sur le front, de sorte que plus tard, il est préférable de «sentir» la différence dans les implémentations. La pièce pour la mise en œuvre de la fonction ressemble à ceci:
function crawl(start, limit = 100) {
La réalisation de la limite de pages est vérifiée par un simple compteur de requêtes. Le deuxième compteur - le nombre de demandes actives à la fois - servira de test de préparation pour donner le résultat (lorsque la valeur devient nulle). Si la fonction d'
extraction n'a pas pu obtenir la page suivante, définissez le code d'état pour elle sur null.
Vous pouvez (facultativement)
vous familiariser avec le code d'implémentation
ici , mais avant cela, vous devez considérer le format du résultat renvoyé.
7. Résultat retourné
Nous allons introduire un
identifiant unique avec un incrément simple pour les pages interrogées:
let id = 0; let cache = {};
Pour le résultat, nous allons créer un tableau de
pages dans lequel nous ajouterons des objets avec des données sur la page:
id {numéro},
url {chaîne} et
code {numéro | null} (cela suffit maintenant). Nous créons également un tableau de
liens pour les liens entre les pages sous la forme d'un objet:
de (
id de la page source)
à (
id de la page de destination).
À des fins d'information, avant de résoudre le résultat, nous trions la liste des pages dans l'ordre croissant des
identifiants (après tout, les réponses viendront dans n'importe quel ordre), nous complétons le résultat avec le nombre de pages numérisées et un indicateur pour atteindre la limite d'
aileron spécifiée:
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 });
Exemple d'utilisation
Le script du robot terminé a le synopsis suivant:
node crawl-cli.js --start="<URL>" [--output="<filename>"] [--limit=<int>]
En complément de la journalisation des points clés du processus, nous verrons une telle image au démarrage:
$ 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"
Et voici le résultat au format 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 }
Que peut-on déjà faire avec ça? Au minimum, la liste des pages vous permet de retrouver toutes les pages cassées du site. Et ayant des informations sur les liens internes, vous pouvez détecter de longues chaînes (et des boucles fermées) de redirections ou trouver les pages les plus importantes par masse de référence.
Annonce 2.0
Nous avons obtenu une variante du robot de console le plus simple, qui contourne les pages d'un site. Le code source
est ici . Il existe également un exemple et des
tests unitaires pour certaines fonctions.
Maintenant, c'est un expéditeur sans cérémonie de demandes et la prochaine étape raisonnable serait de lui apprendre les bonnes manières. Il s'agira de l'en
- tête
User-agent , des règles
robots.txt , de la directive
Crawl-delay , etc. Du point de vue de l'implémentation, il s'agit tout d'abord de mettre les messages en file d'attente puis de desservir une charge plus importante.
Si, bien sûr, ce matériel sera intéressant!