Kami menulis perayap untuk satu atau dua 1.0

Perayap web (atau laba-laba web) adalah bagian penting dari mesin pencari untuk merayapi halaman web untuk memasukkan informasi tentang mereka ke dalam basis data, terutama untuk pengindeksan lebih lanjut. Mesin pencari (Google, Yandex, Bing), serta produk SEO (SEMrush, MOZ, ahrefs) dan tidak hanya memiliki hal seperti itu. Dan hal ini cukup menarik: baik dari segi kasus potensial dan penggunaan, dan untuk implementasi teknis.



Dengan artikel ini, kita akan mulai membuat sepeda crawler Anda secara iteratif , menganalisis banyak fitur dan menemui jebakan rapat. Dari fungsi rekursif sederhana hingga layanan yang skalabel dan dapat diperluas. Pasti menarik!

Intro


Iteratif - itu berarti bahwa pada akhir setiap rilis, versi "siap pakai" produk yang siap pakai diharapkan dengan batasan, fitur, dan antarmuka yang disepakati.

Node.js dan JavaScript dipilih sebagai platform dan bahasa, karena sederhana dan tidak sinkron. Tentu saja, untuk pengembangan industri, pilihan basis teknologi harus didasarkan pada persyaratan bisnis, harapan, dan sumber daya. Sebagai demonstrasi dan prototipe, platform ini sama sekali bukan apa-apa (IMHO).

Ini adalah crawler saya. Ada banyak perayap seperti itu, tetapi ini adalah milikku.
Perayap saya adalah sahabat saya.

Implementasi crawler adalah tugas yang cukup populer dan dapat ditemukan bahkan pada wawancara teknis. Sebenarnya ada banyak solusi siap pakai ( Apache Nutch ) dan tulisan sendiri untuk kondisi yang berbeda dan dalam banyak bahasa. Karena itu, setiap komentar dari pengalaman pribadi dalam pengembangan atau penggunaan dipersilahkan dan akan menarik.

Pernyataan masalah


Tugas untuk implementasi pertama (awal) dari crawler tyap-blooper kami adalah sebagai berikut:

One-Two Crawler 1.0
Tulis skrip perayap yang melewati tautan <a href /> internal situs kecil (hingga 100 halaman). Akibatnya, berikan daftar URL halaman dengan kode yang diterima dan peta tautannya. Aturan robots.txt dan atribut tautan rel = nofollow diabaikan.

Perhatian! Mengabaikan aturan robots.txt adalah ide yang buruk karena alasan yang jelas. Kami akan menebus kelalaian ini di masa depan. Sementara itu, tambahkan parameter batas yang membatasi jumlah halaman yang akan dirayapi sehingga tidak menghentikan DoS dan coba situs eksperimental (lebih baik menggunakan "situs hamster" pribadi Anda untuk eksperimen).

Implementasi


Untuk yang tidak sabar, berikut adalah sumber solusi ini.

  1. Klien HTTP (S)
  2. Opsi jawaban
  3. Ekstraksi Tautan
  4. Persiapan tautan dan penyaringan
  5. Normalisasi URL
  6. Algoritma fungsi utama
  7. Hasil kembali

1. Klien HTTP (S)


Hal pertama yang perlu kita lakukan adalah mengirimkan permintaan dan menerima tanggapan melalui HTTP dan HTTPS. Di node.js ada dua klien yang cocok untuk ini. Tentu saja, Anda dapat mengambil permintaan klien yang sudah jadi , tetapi untuk tugas kami itu sangat berlebihan: kita hanya perlu mengirim permintaan GET dan mendapatkan respons dengan tubuh dan header.

API kedua klien yang kami butuhkan identik, kami akan membuat peta:

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

Kami menyatakan pengambilan fungsi sederhana, satu-satunya parameter yang akan menjadi URL absolut dari string sumber daya web yang diinginkan. Menggunakan modul url, kami akan mengurai string yang dihasilkan menjadi objek URL. Objek ini memiliki bidang dengan protokol (dengan titik dua), dimana kami akan memilih klien yang sesuai:

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

Selanjutnya, gunakan klien yang dipilih dan bungkus hasil dari fungsi ambil dalam janji:

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


Sekarang kita dapat menerima respons secara tidak sinkron, tetapi untuk saat ini kita tidak melakukan apa-apa dengannya.

2. Opsi Jawaban


Untuk menjelajah situs, cukup memproses 3 opsi jawaban:

  1. OK - Kode Status 2xx diterima. Diperlukan untuk menyimpan badan tanggapan sebagai hasil untuk pemrosesan lebih lanjut - mengekstraksi tautan baru.
  2. REDIRECT - Kode Status 3xx diterima. Ini adalah pengalihan ke halaman lain. Dalam hal ini, kita akan membutuhkan tajuk respons lokasi , dari mana kita akan mengambil satu tautan "keluar".
  3. NO_DATA - Semua kasus lainnya: 4xx / 5xx dan 3xx tanpa header Lokasi . Tidak ada tempat untuk melangkah lebih jauh ke perayap kami.

Fungsi ambil akan menyelesaikan respons yang diproses yang menunjukkan jenisnya:

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

Implementasi strategi menghasilkan hasil dalam tradisi terbaik jika-yang lain :

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

Fungsi ambil siap digunakan: seluruh kode fungsi .

3. Ekstraksi tautan


Sekarang, tergantung pada varian jawaban yang diterima, Anda harus dapat mengekstrak tautan dari data hasil pengambilan untuk perayapan lebih lanjut. Untuk melakukan ini, kita mendefinisikan fungsi ekstrak , yang mengambil objek hasil sebagai input dan mengembalikan array tautan baru.

Jika tipe hasil adalah REDIRECT, maka fungsi akan mengembalikan array dengan satu referensi tunggal dari bidang lokasi . Jika NO_DATA, maka array kosong. Jika OK, maka kita perlu menghubungkan parser untuk konten teks yang disajikan untuk pencarian.

Untuk tugas pencarian, <a href />, Anda juga dapat menulis ekspresi reguler. Tetapi solusi ini tidak berskala sama sekali, karena di masa depan kita setidaknya akan memperhatikan atribut ( rel ) tautan lainnya, sebagai maksimum, kita akan memikirkan img , tautan , skrip , audio / video ( sumber ) dan sumber daya lainnya. Jauh lebih menjanjikan dan lebih mudah untuk mem-parsing teks dokumen dan membangun pohon node-nya untuk mem-bypass penyeleksi yang biasa.

Kami akan menggunakan pustaka JSDOM populer untuk bekerja dengan DOM di 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); 

Kami mendapatkan semua elemen A dari dokumen, dan kemudian semua nilai yang disaring dari atribut href , jika tidak baris kosong.

4. Persiapan dan penyaringan tautan


Sebagai hasil dari ekstraktor, kami memiliki serangkaian tautan (URL) dan dua masalah: 1) URL dapat bersifat relatif dan 2) URL dapat mengarah ke sumber daya eksternal (kami hanya membutuhkan yang internal sekarang).

Masalah pertama akan dibantu oleh fungsi url.resolve , yang menyelesaikan URL halaman arahan relatif terhadap URL halaman sumber.

Untuk mengatasi masalah kedua, kami menulis fungsi utilitas sederhana diScope yang memeriksa host halaman arahan terhadap host URL dasar dari perayapan saat ini:

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

Fungsi mencari substring ( baseHost ) dengan karakter sebelumnya diperiksa jika substring ditemukan: karena wwwexample.com dan example.com adalah domain yang berbeda. Akibatnya, kami tidak meninggalkan domain yang diberikan, tetapi memotong subdomainnya.

Kami memperbaiki fungsi ekstrak dengan menambahkan "absolutisasi" dan memfilter tautan yang dihasilkan:

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

Di sini diambil adalah hasil dari fungsi pengambilan , src adalah URL dari halaman sumber, basis adalah URL dasar dari perayapan. Di bagian keluaran, kami mendapatkan daftar tautan internal (URL) yang sudah absolut untuk diproses lebih lanjut. Seluruh kode fungsi dapat dilihat di sini .

5. Normalisasi URL


Setelah bertemu dengan URL apa pun lagi, tidak perlu mengirim permintaan lain untuk sumber daya, karena data telah diterima (atau koneksi lain masih terbuka dan menunggu jawaban). Tetapi tidak selalu cukup untuk membandingkan string dari dua URL untuk memahami hal ini. Normalisasi adalah prosedur yang diperlukan untuk menentukan kesetaraan dari URL yang berbeda secara sintaksis.

Proses normalisasi adalah serangkaian transformasi yang diterapkan pada URL sumber dan komponennya. Berikut ini beberapa di antaranya:

  • Skema dan host tidak peka terhadap huruf besar-kecil, sehingga harus dikonversi menjadi lebih rendah.
  • Semua persentase (seperti "% 3A") harus dalam huruf kapital.
  • Port default (80 untuk HTTP) dapat dihapus.
  • Fragmen ( # ) tidak pernah terlihat oleh server dan juga dapat dihapus.

Anda selalu dapat mengambil sesuatu yang sudah jadi (misalnya, normalize-url ) atau menulis fungsi sederhana Anda sendiri yang mencakup kasus paling penting dan umum:

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

Untuk jaga-jaga, format objek URL


Ya, tidak ada penyortiran parameter kueri, mengabaikan tag utm, memproses _escaped_fragment_ dan hal-hal lain, yang kami (benar-benar) tidak perlu sama sekali.

Selanjutnya, kita akan membuat cache lokal dari URL yang dinormalisasi yang diminta oleh kerangka kerja Crawl. Sebelum mengirim permintaan berikutnya, kami menormalkan URL yang diterima, dan jika tidak ada dalam cache, tambahkan dan baru kemudian kirimkan permintaan baru.

6. Algoritma fungsi utama


Komponen utama (primitif) dari solusi sudah siap, sekarang saatnya untuk mulai mengumpulkan semuanya. Untuk memulainya, mari kita tentukan tanda tangan dari fungsi crawl : pada input, URL awal dan batas halaman. Fungsi mengembalikan janji yang resolusinya memberikan hasil akumulasi; tulis ke file output :

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

Alur kerja rekursif paling sederhana dari fungsi crawl dapat dijelaskan dalam langkah-langkah:

1. Inisialisasi cache dan objek hasil
2. JIKA URL halaman arahan (via normalisasi ) tidak ada dalam cache, MAKA
- 2.1. JIKA batas tercapai, END (tunggu hasil)
- 2.2. Tambahkan URL ke Cache
- 2.3. Simpan tautan antara sumber dan halaman arahan di hasil
- 2.4. Kirim permintaan tidak sinkron per halaman ( ambil )
- 2.5. JIKA permintaan berhasil, MAKA
- - 2.5.1. Ekstrak tautan baru dari hasil ( ekstrak )
- - 2.5.2. Untuk setiap tautan baru, jalankan algoritma 2-3
- 2.6. ELSE menandai halaman sebagai kesalahan
- 2.7. Simpan data halaman untuk dihasilkan
- 2.8. JIKA ini adalah halaman terakhir, Bawa hasilnya
3. ELSE menyimpan tautan antara sumber dan halaman arahan dalam hasil

Ya, algoritma ini akan mengalami perubahan besar di masa depan. Sekarang, solusi rekursif digunakan dengan sengaja di dahi, sehingga nanti lebih baik untuk "merasakan" perbedaan dalam implementasi. Benda kerja untuk implementasi fungsi terlihat seperti ini:

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

Pencapaian batas halaman diperiksa oleh penghitung permintaan sederhana. Penghitung kedua - jumlah permintaan aktif pada suatu waktu - akan berfungsi sebagai ujian kesiapan untuk memberikan hasilnya (ketika nilainya berubah menjadi nol). Jika fungsi ambil tidak bisa mendapatkan halaman berikutnya, maka atur Kode Status untuk itu sebagai nol.

Anda dapat (secara opsional) membiasakan diri dengan kode implementasi di sini , tetapi sebelum itu Anda harus mempertimbangkan format hasil yang dikembalikan.

7. Hasil yang dikembalikan


Kami akan memperkenalkan pengidentifikasi id unik dengan kenaikan sederhana untuk halaman yang disurvei:

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

Untuk hasilnya, kita akan membuat array halaman yang akan kita tambahkan objek dengan data pada halaman: id {number}, url {string} dan kode {number | null} (ini sekarang cukup). Kami juga membuat larik tautan untuk tautan antar halaman dalam bentuk objek: dari ( id halaman sumber), ke ( id halaman arahan).

Untuk tujuan informasi, sebelum menyelesaikan hasilnya, kami mengurutkan daftar halaman dalam urutan id yang naik (setelah semua, jawaban akan datang dalam urutan apa pun), kami melengkapi hasilnya dengan jumlah halaman hitungan yang dipindai dan bendera untuk mencapai batas sirip yang ditentukan:

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

Contoh penggunaan


Script perayap yang selesai memiliki sinopsis berikut:

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

Melengkapi pencatatan poin-poin utama dari proses, kita akan melihat gambar seperti itu pada saat startup:

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

Dan inilah hasilnya dalam 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 } 


Apa yang bisa dilakukan dengan ini? Minimal, daftar halaman Anda dapat menemukan semua halaman situs yang rusak. Dan memiliki informasi tentang tautan internal, Anda dapat mendeteksi rantai panjang (dan loop tertutup) pengalihan atau menemukan halaman yang paling penting dengan massa referensi.

Pengumuman 2.0


Kami telah memperoleh varian crawler crawler paling sederhana, yang mem-bypass halaman satu situs. Kode sumber ada di sini . Ada juga contoh dan unit test untuk beberapa fungsi.

Sekarang ini adalah pengirim permintaan tanpa basa-basi dan langkah selanjutnya yang masuk akal adalah mengajarinya sopan santun. Ini akan tentang header User-agent , aturan robots.txt , arahan keterlambatan perayapan , dan banyak lagi. Dari sudut pandang implementasi, ini pertama-tama mengantri pesan dan kemudian melayani beban yang lebih besar. Jika, tentu saja, materi ini akan menarik!

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


All Articles