Пишем Краулер На Одного-Двух 1.0

Интернет гусеничный трактор (или веб-паук) — важная часть поисковых систем для сканирования веб-страниц с целью внесения информации о них в базы данных, главным образом, для их дальнейшей индексации.

Эта штука есть в поисковых системах (Google, Яндекс, Bing), а также в SEO-продуктах (SEMrush, MOZ, ahrefs) и других.

И штука эта довольно интересная: как с точки зрения потенциала и вариантов использования, так и с точки зрения технической реализации.



Пишем краулер на одного-двух 1.0

С этой статьи мы начнем итеративно создайте свой собственный гусеничный велосипед, анализируя множество особенностей и обнаруживая подводные камни.

От простой рекурсивной функции до масштабируемого и расширяемого сервиса.

Должно быть интересно!



вступление

Итеративный подход означает, что в конце каждого выпуска ожидается готовая к использованию версия «продукта» с согласованными ограничениями, характеристиками и интерфейсом.

Выбранная платформа и язык узел.

js И JavaScript потому что это просто и асинхронно.

Конечно, для промышленного развития выбор технологической базы должен основываться на требованиях, ожиданиях и ресурсах бизнеса.

Как демонстрация и прототип эта платформа совершенно ничего (ИМХО).

Это мой краулер.

Таких краулеров много, но этот мой.

Мой краулер — мой лучший друг.

Реализация краулера — довольно популярная задача, и ее можно найти даже на технических собеседованиях.

Готовый ( Апач Натч ) и самописные решения для разных условий и на многих языках - их действительно очень много.

Поэтому любые комментарии из личного опыта разработки или использования приветствуются и будут интересны.



Постановка задачи

Задача для первой (начальной) реализации нашего сканера ошибок будет следующей:
Одно-двухгусеничный 1.0 Напишите скрипт сканера, который обходит внутренние ссылки на какой-нибудь небольшой (до 100 страниц) сайт. В результате предоставить список URL-адресов страниц с полученными кодами и карту их перелинковки.

Правила robots.txt и атрибут ссылки rel=nofollow игнорировать.

Внимание! Игнорировать правила robots.txt - плохая идея по понятным причинам.

В будущем мы восполним это упущение.

А пока давайте добавим параметр, ограничивающий количество просканированных страниц.

предел , чтобы остановить и не DoS экспериментальный сайт (для экспериментов лучше использовать свой личный «хомячий сайт»).



Выполнение

Для нетерпеливых вот источники это решение.

  1. HTTP(S)-клиент
  2. Варианты ответа
  3. Эизвлечение ссылки
  4. Подготовка и фильтрация ссылок
  5. Нормализация URL-адресов
  6. Алгоритм основной функции
  7. Возврат результата


1. HTTP(S)-клиент

Первое, что нам нужно уметь — это собственно отправлять запросы и получать ответы по протоколам HTTP и HTTPS. Для этого в Node.js есть два соответствующих клиента.

Конечно, вы можете взять готовый запрос клиента , но для нашей задачи это крайне избыточно: нам достаточно отправить GET-запрос и получить ответ с телом и заголовками.

API, который нам нужен для обоих клиентов, идентичен, давайте создадим карту:

  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
   

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

Давайте объявим простую функцию принести , единственным параметром которого является абсолютный URL-адрес нужного веб-ресурса в виде строки.

Используя URL-адрес модуля мусора Мы преобразуем полученную строку в объект URL. Этот объект содержит поле с протоколом (с двоеточием), по которому мы выберем подходящего клиента:

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

}

Далее мы используем выбранный клиент и оборачиваем результат функции принести обещать:

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

Теперь мы можем получать ответ асинхронно, но пока ничего с ним не делаем.



2. Варианты ответов

Для сканирования сайта достаточно обработать 3 варианта ответа:
  1. ХОРОШО — Получен код состояния 2xx. В результате необходимо сохранить тело ответа для дальнейшей обработки — извлечения новых ссылок.

  2. ПЕРЕНАПРАВИТЬ — Получен код состояния 3xx. Это перенаправление на другую страницу.

    В этом случае нам понадобится заголовок ответа Расположение , откуда мы позже возьмем одну единственную «исходящую» ссылку.

  3. НЕТ ДАННЫХ — Все остальные случаи: 4xx/5xx и 3xx без заголовка.

    Расположение .

    Дальше нашему краулеру идти некуда.

Функция принести разрешит обработанный ответ, указав его тип:

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

Реализация стратегии достижения результата в лучших традициях если еще :

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

Функция принести готов использовать: весь код функции .



3. Извлечение ссылок

Теперь в зависимости от версии полученного ответа нужно уметь извлекать результат из данных принести ссылки для дальнейшего изучения.

Для этого определим функцию извлекать , который принимает объект результата в качестве входных данных и возвращает массив новых ссылок.

Если тип результата — REDIRECT, то функция вернет массив с одной единственной ссылкой из поля.

расположение .

Если NO_DATA, то пустой массив.

Если все в порядке, то нам необходимо подключить парсер представленного текста.

содержание Для поиска.

Для задачи поиска Вы также можете написать регулярное выражение.

Но это решение вообще не масштабируется, так как в будущем мы как минимум обратим внимание на другие атрибуты( отн.

) ссылки, как максимум - давайте подумаем изображение , связь , сценарий , аудио видео ( источник ) и другие ресурсы.

Гораздо перспективнее и удобнее парсить текст документа и строить дерево его узлов для обхода с помощью обычных селекторов.

Воспользуемся популярной библиотекой JSDOM для работы с DOM в 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);

Извлекаем все из документа А элементы, а затем все отфильтрованные значения атрибутов href , так как не пустые строки.



4. Подготовка и фильтрация ссылок

В результате работы экстрактора мы имеем набор ссылок (URL) и две проблемы: 1) URL может быть относительным и 2) URL может вести на внешний ресурс (нам сейчас нужны только внутренние).

Функция поможет нам разобраться с первой проблемой URL.resolve , который разрешает URL-адрес целевой страницы относительно URL-адреса исходной страницы.

Для решения второй проблемы напишем простую служебную функцию в области , который сравнивает хост целевой страницы с хостом базового URL-адреса текущего сканирования:

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

'; }

Функция ищет подстроку ( базовый хост ) с проверкой предыдущего символа, если подстрока найдена: так как wwwexample.com И example.com - разные домены.

В результате мы не покидаем данный домен, а обходим его поддомены.

Давайте улучшим функцию извлекать , добавив «абсолютизацию» и фильтрацию полученных ссылок:

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

Здесь принесенный — результат, полученный от функции принести , источник — URL исходной страницы, база — базовый URL сканирования.

На выходе мы получаем список уже абсолютных внутренних ссылок (URL) для дальнейшей обработки.

Весь код функции может быть глянь сюда .



5. Нормализация URL-адресов

Встретив какой-либо URL повторно, нет необходимости отправлять еще один запрос на ресурс, поскольку данные уже получены (или другое соединение еще открыто и ожидает ответа).

Но не всегда достаточно сравнить строки двух URL, чтобы это понять.

Нормализация — это процедура, необходимая для определения эквивалентности синтаксически разных URL-адресов.

Процесс нормализация представляет собой целый набор преобразований, применяемых к исходному URL-адресу и его компонентам.

Вот лишь некоторые из них:

  • Схема и хост не чувствительны к регистру, поэтому их следует преобразовать в нижний регистр.

  • Все символы процентов (например, «%3A») должны быть преобразованы в верхний регистр.

  • Порт по умолчанию (80 для HTTP) можно удалить.

  • Фрагмент ( # ) никогда не виден серверу и также может быть удален.

Всегда можно взять что-то готовое (например нормализовать-url ) или напишите свою простую функцию, охватывающую наиболее важные и распространенные случаи:

function normalize(dst) { let dstUrl = new URL(dst); // ignore userinfo (auth property) let origin = dstUrl.protocol + 'http://' + 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()); }

На всякий случай формат объекта URL такой:

Пишем краулер на одного-двух 1.0

Да, нет сортировки параметров запроса, игнорирования utm-тегов, обработки _экранированный_фрагмент_ и другие вещи, которые нам (пока) совершенно не нужны.

Далее мы создадим локальный кеш нормализованных URL-адресов, запрошенных при сканировании.

Перед отправкой следующего запроса мы нормализуем полученный URL, и если его нет в кеше, добавим его и только потом отправим новый запрос.



6. Алгоритм выполнения основной функции

Ключевые компоненты (примитивы) решения уже готовы, пора начинать все собирать.

Сначала давайте определим сигнатуру функции ползти : при вводе — начальный URL и лимит страниц.

Функция возвращает обещание, разрешение которого обеспечивает накопленный результат; записать это в файл выход :

crawl(start, limit).

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

Простейшую схему рекурсивной работы функции сканирования можно описать следующими шагами:

1. Инициализируйте кеш и объект результата.

2. ЕСЛИ URL целевой страницы (через нормализовать ) нет в кеше, ТОГДА — 2.1. ЕСЛИ достигнуто предел THEN END (дождитесь результата) — 2.2. Добавить URL в кеш — 2.3. Сохраните ссылку между источником и целевой страницей в результате — 2.4. Отправьте асинхронный запрос за страницей ( принести ) — 2,5. ЕСЛИ запрос был выполнен успешно, ТО — — 2.5.1. Эизвлечь новые ссылки из результата ( извлекать ) — — 2.5.2. Для каждой новой ссылки выполнять алгоритм 2-3 — 2,6. ELSE пометить страницу как статус ошибки — 2,7. Сохранить данные страницы в результат — 2,8. ЕСЛИ это была последняя страница, ТО ВЕРНИТЕ результат 3. ЛИБО сохраните ссылку между источником и целевой страницей в результате

Да, этот алгоритм в будущем претерпит серьезные изменения.

Сейчас рекурсивное решение намеренно используется в лоб, чтобы потом лучше «почувствовать» разницу в реализациях.

Шаблон реализации функции выглядит следующим образом:

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

Достижение лимита страниц проверяется простым счетчиком запросов.

Второй счетчик — количество активных запросов за раз — будет служить проверкой готовности вернуть результат (когда значение обратится в ноль).

Если функции принести Если мы не смогли получить следующую страницу, то мы установим для нее код состояния равным нулю.

С помощью кода реализации вы можете (не обязательно) читай здесь , но перед этим стоит учитывать формат возвращаемого результата.



7. Возврат результата

Введем уникальный идентификатор идентификатор с простым увеличением числа опрошенных страниц:

let id = 0; let cache = {}; // .

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

}

Давайте создадим массив для результата страницы , в который мы добавим объекты с данными страницы: идентификатор {число}, URL {строка} и код {number|null} (на данный момент этого достаточно).

Давайте также создадим массив ссылки для ссылок между страницами как объекта: от ( идентификатор исходная страница), к ( идентификатор целевая страница).

Чтобы было более информативно, перед вычислением результата отсортируем список страниц по возрастанию.

идентификатор (ведь ответы придут в любом порядке), дополним результат количеством отсканированных страниц считать и флаг, указывающий, что указанный предел достигнут. плавник :

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



Пример использования

Готовый скрипт сканера имеет следующий краткий обзор:

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

Дополнив логированием ключевых точек процесса, при запуске мы увидим следующую картину:

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

И вот результат в формате 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 }

Что вы можете с этим поделать? Как минимум, из списка страниц можно найти все битые страницы сайта.

А имея информацию о внутренних перелинковках, вы можете обнаружить длинные цепочки (и замкнутые циклы) редиректов или найти самые важные страницы по ссылочной массе.



Анонс 2.0

Мы создали версию простейшего консольного сканера, сканирующего страницы одного сайта.

Источник лежит здесь .

Там же есть пример модульные тесты для некоторых функций.

Теперь он бесцеремонно просит, и следующим разумным шагом будет научить его манерам.

Речь идет о названии Пользовательский агент , правила robots.txt , директива Задержка сканирования и так далее.

С точки зрения реализации — это в первую очередь организация очереди сообщений, а затем обслуживание большей нагрузки.

Если, конечно, этот материал интересен! Теги: #JavaScript #node.js #поисковая оптимизация #curl #SEO #SEO #запросы #crawler #web-паук

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.