Wir schreiben einen Crawler für ein oder zwei 1.0

Ein Webcrawler (oder Webspider) ist ein wichtiger Bestandteil von Suchmaschinen zum Crawlen von Webseiten, um Informationen über sie in Datenbanken einzugeben, hauptsächlich für ihre weitere Indizierung. Suchmaschinen (Google, Yandex, Bing) sowie SEO-Produkte (SEMrush, MOZ, Ahrefs) haben nicht nur so etwas. Und diese Sache ist ziemlich interessant: sowohl in Bezug auf Potenziale und Anwendungsfälle als auch für die technische Implementierung.



Mit diesem Artikel werden wir beginnen, Ihr Crawler- Bike iterativ zu erstellen, viele Funktionen zu analysieren und Fallstricke zu lösen. Von einer einfachen rekursiven Funktion zu einem skalierbaren und erweiterbaren Dienst. Muss interessant sein!

Intro


Iterativ bedeutet dies, dass am Ende jeder Version eine gebrauchsfertige Version des „Produkts“ mit den vereinbarten Einschränkungen, Funktionen und Schnittstellen erwartet wird.

Node.js und JavaScript wurden als Plattform und Sprache ausgewählt, da es einfach und asynchron ist. Für die industrielle Entwicklung sollte die Wahl der technologischen Basis natürlich auf den geschäftlichen Anforderungen, Erwartungen und Ressourcen basieren. Als Demonstration und Prototyp ist diese Plattform absolut nichts (IMHO).

Das ist mein Crawler. Es gibt viele solcher Crawler, aber dieser gehört mir.
Mein Crawler ist mein bester Freund.

Die Implementierung des Crawlers ist eine recht beliebte Aufgabe und kann auch bei technischen Interviews gefunden werden. Es gibt wirklich viele fertige ( Apache Nutch ) und selbst geschriebene Lösungen für verschiedene Bedingungen und in vielen Sprachen. Kommentare aus persönlicher Erfahrung in der Entwicklung oder Nutzung sind daher willkommen und interessant.

Erklärung des Problems


Die Aufgabe für die erste (erste) Implementierung unseres Tyap-Blooper- Crawlers lautet wie folgt:

One-Two Crawler 1.0
Schreiben Sie ein Crawlerskript, das die internen <a href /> -Links einer kleinen Site (bis zu 100 Seiten) umgeht. Stellen Sie daher eine Liste der URLs von Seiten mit den empfangenen Codes und eine Karte ihrer Verknüpfung bereit. Die robots.txt- Regeln und das Attribut rel = nofollow link werden ignoriert.

Achtung! Das Ignorieren von robots.txt- Regeln ist aus offensichtlichen Gründen eine schlechte Idee. Wir werden diese Lücke in Zukunft schließen. Fügen Sie in der Zwischenzeit den Parameter limit hinzu, der die Anzahl der zu crawlenden Seiten begrenzt, damit DoS nicht gestoppt wird, und probieren Sie die experimentelle Site aus (es ist besser, Ihre eigene "Hamster-Site" für Experimente zu verwenden).

Implementierung


Für die Ungeduldigen sind hier die Quellen dieser Lösung.

  1. HTTP (S) -Client
  2. Antwortoptionen
  3. Link-Extraktion
  4. Linkvorbereitung und Filterung
  5. URL-Normalisierung
  6. Hauptfunktionsalgorithmus
  7. Ergebnis zurückgeben

1. HTTP (S) -Client


Das erste, was wir tun müssen, ist, Anfragen zu senden und Antworten über HTTP und HTTPS zu empfangen. In node.js gibt es dafür zwei übereinstimmende Clients. Natürlich können Sie eine vorgefertigte Client-Anfrage entgegennehmen , aber für unsere Aufgabe ist sie äußerst redundant: Wir müssen nur eine GET-Anfrage senden und eine Antwort mit dem Text und den Headern erhalten.

Die API beider Clients, die wir benötigen, ist identisch. Wir erstellen eine Karte:

const clients = { 'http:': require('http'), 'https:': require('https') }; 

Wir deklarieren einen einfachen Funktionsabruf, dessen einziger Parameter die absolute URL der gewünschten Webressourcenzeichenfolge ist. Mit dem URL-Modul analysieren wir die resultierende Zeichenfolge in ein URL-Objekt. Dieses Objekt hat ein Feld mit dem Protokoll (mit einem Doppelpunkt), anhand dessen wir den entsprechenden Client auswählen:

 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); } // ... } 

Verwenden Sie als Nächstes den ausgewählten Client und verpacken Sie das Ergebnis der Abruffunktion in ein Versprechen:

 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(); }); } 


Jetzt können wir asynchron eine Antwort erhalten, aber im Moment machen wir nichts damit.

2. Antwortoptionen


Um die Site zu crawlen, müssen 3 Antwortoptionen verarbeitet werden:

  1. OK - Ein 2xx-Statuscode wurde empfangen. Es ist notwendig, den Antworttext als Ergebnis für die weitere Verarbeitung zu speichern - neue Links zu extrahieren.
  2. REDIRECT - Ein 3xx-Statuscode wurde empfangen. Dies ist eine Weiterleitung zu einer anderen Seite. In diesem Fall benötigen wir den Standortantwort- Header, von dem aus wir einen einzelnen "ausgehenden" Link verwenden.
  3. NO_DATA - Alle anderen Fälle: 4xx / 5xx und 3xx ohne den Location- Header. Es gibt keinen weiteren Weg zu unserem Crawler.

Die Abruffunktion löst die verarbeitete Antwort unter Angabe ihres Typs auf:

 const ft = { 'OK': 1, // code (2xx), content 'REDIRECT': 2, // code (3xx), location 'NO_DATA': 3 // code }; 

Umsetzung der Strategie zur Erzielung des Ergebnisses in den besten Traditionen von 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 }); } 

Die Abruffunktion ist einsatzbereit: der gesamte Funktionscode .

3. Extraktion von Links


Abhängig von der Variante der empfangenen Antwort müssen Sie nun in der Lage sein, Links aus den Ergebnisdaten von fetch für das weitere Crawlen zu extrahieren. Dazu definieren wir die Extraktionsfunktion , die ein Ergebnisobjekt als Eingabe verwendet und ein Array neuer Links zurückgibt.

Wenn der Ergebnistyp REDIRECT ist, gibt die Funktion ein Array mit einer einzelnen Referenz aus dem Standortfeld zurück. Wenn NO_DATA, dann ein leeres Array. Wenn OK, müssen wir den Parser für den präsentierten Textinhalt für die Suche verbinden.

Für die Suchaufgabe <a href /> können Sie auch einen regulären Ausdruck schreiben. Diese Lösung lässt sich jedoch überhaupt nicht skalieren, da wir in Zukunft zumindest auf andere Attribute ( rel ) des Links achten werden. Maximal werden wir über img , Link , Skript , Audio / Video ( Quelle ) und andere Ressourcen nachdenken. Es ist vielversprechender und bequemer, den Text des Dokuments zu analysieren und einen Baum seiner Knoten zu erstellen, um die üblichen Selektoren zu umgehen.

Wir werden die beliebte JSDOM- Bibliothek für die Arbeit mit dem DOM in node.js verwenden:

 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); 

Wir erhalten alle A- Elemente aus dem Dokument und dann alle gefilterten Werte des href- Attributs, wenn nicht leere Zeilen.

4. Vorbereitung und Filterung von Links


Aufgrund des Extraktors haben wir eine Reihe von Links (URLs) und zwei Probleme: 1) Die URL kann relativ sein und 2) Die URL kann zu einer externen Ressource führen (wir benötigen jetzt nur interne).

Das erste Problem wird durch die Funktion url.resolve behoben , mit der die URL der Zielseite relativ zur URL der Quellseite aufgelöst wird .

Um das zweite Problem zu lösen, schreiben wir eine einfache Dienstprogrammfunktion inScope , die den Host der Zielseite mit dem Host der Basis-URL des aktuellen Crawls vergleicht:

 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] === '.'; } 

Die Funktion sucht nach einem Teilstring ( baseHost ) mit einer Überprüfung des vorherigen Zeichens, wenn der Teilstring gefunden wurde: da wwwexample.com und example.com unterschiedliche Domänen sind. Infolgedessen verlassen wir die angegebene Domain nicht, sondern umgehen ihre Subdomains.

Wir verfeinern die Extraktionsfunktion , indem wir "Absolutisierung" hinzufügen und die resultierenden Links filtern:

 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)); } 

Hier ist abgerufen das Ergebnis der Abruffunktion , src ist die URL der Quellseite, base ist die Basis-URL des Crawls. Am Ausgang erhalten wir eine Liste bereits absoluter interner Links (URLs) zur weiteren Verarbeitung. Der gesamte Funktionscode ist hier zu sehen .

5. URL-Normalisierung


Wenn Sie erneut auf eine URL stoßen, müssen Sie keine weitere Anforderung für die Ressource senden, da die Daten bereits empfangen wurden (oder eine andere Verbindung noch offen ist und auf eine Antwort wartet). Es reicht jedoch nicht immer aus, die Zeichenfolgen zweier URLs zu vergleichen, um dies zu verstehen. Die Normalisierung ist das Verfahren, das erforderlich ist, um die Äquivalenz syntaktisch unterschiedlicher URLs zu bestimmen.

Der Normalisierungsprozess besteht aus einer ganzen Reihe von Transformationen, die auf die Quell-URL und ihre Komponenten angewendet werden. Hier sind nur einige davon:

  • Das Schema und der Host unterscheiden nicht zwischen Groß- und Kleinschreibung, daher sollten sie in niedrigere konvertiert werden.
  • Alle Prozentsätze (wie "% 3A") müssen in Großbuchstaben geschrieben werden.
  • Der Standardport (80 für HTTP) kann entfernt werden.
  • Das Fragment ( # ) ist für den Server nie sichtbar und kann auch gelöscht werden.

Sie können jederzeit etwas Fertiges nehmen (z. B. URL normalisieren ) oder Ihre eigene einfache Funktion schreiben, die die wichtigsten und häufigsten Fälle abdeckt:

 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()); } 

Nur für den Fall, das Format des URL-Objekts


Ja, es gibt keine Sortierung von Abfrageparametern, Ignorieren von utm-Tags, Verarbeiten von _escaped_fragment_ und anderen Dingen, die wir (absolut) überhaupt nicht benötigen.

Als Nächstes erstellen wir einen lokalen Cache mit normalisierten URLs, die vom Crawl-Framework angefordert werden. Bevor wir die nächste Anfrage senden, normalisieren wir die empfangene URL. Wenn sie sich nicht im Cache befindet, fügen Sie sie hinzu und senden erst dann eine neue Anfrage.

6. Der Algorithmus der Hauptfunktion


Die Schlüsselkomponenten (Grundelemente) der Lösung sind fertig. Es ist Zeit, alles zusammen zu sammeln. Lassen Sie uns zunächst die Signatur der Durchforstungsfunktion bestimmen: an der Eingabe die Start-URL und das Seitenlimit. Die Funktion gibt ein Versprechen zurück, dessen Auflösung ein akkumuliertes Ergebnis liefert. Schreiben Sie es in die Ausgabedatei :

 crawl(start, limit).then(result => { fs.writeFile(output, JSON.stringify(result), 'utf8', err => { if (err) throw err; }); }); 

Der einfachste rekursive Workflow der Crawling-Funktion kann in folgenden Schritten beschrieben werden:

1. Initialisierung des Caches und des Ergebnisobjekts
2. WENN sich die Zielseiten-URL (über Normalisieren ) nicht im Cache befindet, DANN
- 2.1. WENN das Limit erreicht ist, ENDE (auf Ergebnis warten)
- 2.2. URL zum Cache hinzufügen
- 2.3. Speichern Sie den Link zwischen Quelle und Zielseite im Ergebnis
- 2.4. Asynchrone Anfrage pro Seite senden ( Abruf )
- 2.5. WENN die Anfrage erfolgreich ist, DANN
- - 2.5.1. Neue Links aus dem Ergebnis extrahieren ( extrahieren )
- - 2.5.2. Führen Sie für jede neue Verbindung den Algorithmus 2-3 aus
- 2.6. Andernfalls markieren Sie die Seite als Fehler
- 2.7. Speichern Sie die Seitendaten, um das Ergebnis zu erhalten
- 2.8. WENN dies die letzte Seite war, bringen Sie das Ergebnis
3. Speichern Sie andernfalls die Verknüpfung zwischen der Quelle und der Zielseite im Ergebnis

Ja, dieser Algorithmus wird in Zukunft große Änderungen erfahren. Jetzt wird bewusst eine rekursive Lösung auf der Stirn verwendet, so dass es später besser ist, den Unterschied in den Implementierungen zu „spüren“. Das Werkstück für die Implementierung der Funktion sieht folgendermaßen aus:

 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); }); } 

Das Erreichen des Seitenlimits wird durch einen einfachen Anforderungszähler überprüft. Der zweite Zähler - die Anzahl der aktiven Anforderungen gleichzeitig - dient als Test für die Bereitschaft, das Ergebnis zu liefern (wenn der Wert auf Null geht). Wenn die Abruffunktion die nächste Seite nicht erhalten konnte, setzen Sie den Statuscode dafür auf null.

Sie können sich hier (optional) mit dem Implementierungscode vertraut machen , aber vorher sollten Sie das Format des zurückgegebenen Ergebnisses berücksichtigen.

7. Zurückgegebenes Ergebnis


Wir werden eine eindeutige ID-ID mit einem einfachen Inkrement für die abgefragten Seiten einführen:

 let id = 0; let cache = {}; // ... let dstNorm = normalize(dst); if (dstNorm in cache === false) { cache[dstNorm] = ++id; // ... } 

Für das Ergebnis erstellen wir ein Array von Seiten, in das wir Objekte mit Daten auf der Seite einfügen : ID {Nummer}, URL {Zeichenfolge} und Code {Nummer | Null} (dies ist jetzt genug). Wir erstellen auch ein Array von Links für Links zwischen Seiten in Form eines Objekts: von ( ID der Quellseite) bis ( ID der Zielseite).

Zu Informationszwecken sortieren wir vor dem Auflösen des Ergebnisses die Liste der Seiten in aufsteigender Reihenfolge der ID (schließlich werden die Antworten in beliebiger Reihenfolge angezeigt). Wir ergänzen das Ergebnis mit der Anzahl der gescannten Zählseiten und einem Flag beim Erreichen der angegebenen Flossengrenze :

 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 }); 

Anwendungsbeispiel


Das fertige Crawlerskript enthält die folgende Zusammenfassung:

 node crawl-cli.js --start="<URL>" [--output="<filename>"] [--limit=<int>] 

Ergänzend zur Protokollierung der wichtigsten Punkte des Prozesses sehen wir beim Start ein solches Bild:

 $ 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" 

Und hier ist das Ergebnis im JSON-Format:

 { "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 } 


Was kann man damit schon machen? In der Liste der Seiten finden Sie mindestens alle beschädigten Seiten der Site. Mit Informationen zur internen Verknüpfung können Sie lange Ketten (und geschlossene Schleifen) von Weiterleitungen erkennen oder die wichtigsten Seiten anhand der Referenzmasse finden.

Ankündigung 2.0


Wir haben eine Version des einfachsten Konsolen-Crawlers, der die Seiten einer Site umgeht. Der Quellcode ist hier . Es gibt auch ein Beispiel und Unit-Tests für einige Funktionen.

Dies ist ein kurzer Absender von Anfragen, und der nächste vernünftige Schritt wäre, ihm gute Manieren beizubringen. Es geht um den User-Agent- Header, die robots.txt- Regeln, die Crawl-Delay- Direktive und vieles mehr. Unter dem Gesichtspunkt der Implementierung bedeutet dies zunächst, Nachrichten in die Warteschlange zu stellen und dann eine größere Last zu bedienen. Wenn dieses Material natürlich interessant sein wird!

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


All Articles