company_banner

Пишем краулер на раз-два 1.0

    Веб-краулер (или веб-паук) — это важная составная часть поисковых систем для обхода веб-страниц с целью занесения информации о них в базы данных, в основном, для их дальнейшей индексации. Такая штука есть у поисковиков (Google, Yandex, Bing), а также у SEO-продуктов (SEMrush, MOZ, ahrefs) и не только. И штука эта — довольно интересная: как в плане потенциала и вариантов использования, так и для технической реализации.



    Этой статьей мы начнем итеративно создавать свой велосипед краулер, разбирая многие особенности и встречая подводные камни. От простой рекурсивной функции до масштабируемого и расширяемого сервиса. Должно быть интересно!

    Интро


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

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

    Это мой краулер. Таких краулеров много, но этот — мой.
    Мой краулер — мой лучший друг.

    Реализация краулера — довольно популярная задача и встречается даже на технических собеседованиях. Готовых (Apache Nutch) и самописных решений для разных условий и на множестве языков — действительно много. Поэтому, любые комментарии из личного опыта разработки или использования приветствуются и будут интересны.

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


    Задание для первой (начальной) реализации нашего тяп-ляп краулера будет следующим:

    Краулер на раз-два 1.0
    Написать краулер-скрипт, который обходит внутренние <a href /> ссылки некоторого небольшого (до 100 страниц) сайта. В качестве результата предоставить список URL'ов страниц с полученными кодами и карту их перелинковки. Правила robots.txt и атрибут ссылки rel=nofollow игнорировать.

    Внимание! Игнорировать правила robots.txt — плохая идея по понятным причинам. Мы наверстаем это упущение в дальнейшем. А пока, добавим ограничивающий количество обходимых страниц параметр limit, чтобы останавливаться и не DoS'ить подопытный сайт (лучше и вовсе для экспериментов использовать какой-нибудь свой персональный «сайт-хомячок»).

    Реализация


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

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

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


    Первое, что нам нужно уметь делать — это, собственно, отправлять запросы и получать ответы по HTTP и HTTPS. В node.js для этого есть два соответствующих клиента. Конечно, можно взять готовый клиент request, но для нашей задачи он крайне избыточен: нам всего лишь надо отправить GET-запрос и получить response с телом и заголовками.

    Необходимый нам API обоих клиентов идентичен, заведем мапу:

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

    Объявим простую функцию fetch, единственным параметром которой будет абсолютный 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);
        }
        // ...
    }
    

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

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


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

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


    Для обхода сайта достаточно обрабатывать 3 варианта ответа:

    1. OK — Был получен 2xx Status Code. Необходимо сохранить в результат тело ответа для дальнейшей обработки — экстрагирования новых ссылок.
    2. REDIRECT — Был получен 3xx Status Code. Это редирект на другую страницу. В этом случае нам понадобится заголовок ответа Location, откуда мы в дальнейшем возьмем одну единственную «исходящую» ссылку.
    3. NO_DATA — Все прочие случаи: 4xx/5xx и 3xx без заголовка Location. Здесь некуда дальше идти нашему краулеру.

    Функция fetch будет резолвить обработанный response с указанием его типа:

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

    Реализация стратегии формирования результата в лучших традициях 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 });
    }
    

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

    3. Экстрагирование ссылок


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

    Если тип результата — REDIRECT, то функция вернет массив с одной единственной ссылкой из поля location. Если NO_DATA, то пустой массив. Если же OK, то нам необходимо подключить парсер для представленного текстового content для поиска.

    Для задачи поиска <a href /> можно написать и регулярное выражение. Но это решение совершенно не масштабируется, так как в дальнейшем мы как минимум будем обращать внимание на прочие атрибуты (rel) ссылки, как максимум — подумаем про img, link, script, audio/video (source) и прочие ресурсы. Гораздо перспективнее и удобнее парсить текст документа и строить дерево его узлов для обхода привычными селекторами.

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

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

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


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

    С первой проблемой нам поможет справиться функция url.resolve, которая резолвит URL целевой страницы относительно URL страницы-источника.

    Чтобы решить вторую проблему, напишем простую утильную функцию inScope, которая проверяет хост целевой страницы относительно хоста базового 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] === '.'; 
    }
    

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

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

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

    Здесь fetched — полученный результат от функции fetch, src — URL страницы-источника, base — базовый URL краула. На выходе мы получаем список уже абсолютных внутренних ссылок (URL) для дальнейшей обработки. Код функции целиком можно увидеть здесь.

    5. Нормализация URL


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

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

    • Схема и хост не чувствительны к регистру, поэтому их стоит конвертировать в нижний.
    • Все символы с процентным указателем (например "%3A") должны быть приведены в верхний регистр.
    • Порт по умолчанию (80 для HTTP) может быть удален.
    • Фрагмент (#) никогда не виден серверу и тоже может быть удален.

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

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

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


    Да, здесь нет сортировки query-параметров, игнорирования utm-меток, обработки _escaped_fragment_ и прочего, чего нам (пока) совершенно не нужно.

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

    6. Алгоритм работы main-функции


    Ключевые компоненты (примитивы) решения уже готовы, пришло время начать собирать все вместе. Для начала определимся с сигнатурой функции crawl: на входе — стартовый URL и ограничение по страницам. Функция возвращает промис, резолв которого предоставляет аккумулированный результат; запишем его в файл output:

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

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

    1. Инициализация кэша и объекта результата
    2. ЕСЛИ URL целевой страницы (через normalize) отсутствует в кэше, ТО
    — 2.1. ЕСЛИ достигнут limit, ТО КОНЕЦ (ждать результат)
    — 2.2. Добавить URL в кэш
    — 2.3. Сохранить ссылку между источником и целевой страницей в результат
    — 2.4. Отправить асинхронный запрос за страницей (fetch)
    — 2.5. ЕСЛИ запрос выполнился успешно, ТО
    — — 2.5.1. Экстрагировать новые ссылки из результата (extract)
    — — 2.5.2. Для каждой новой ссылки выполнить алгоритм 2-3
    — 2.6. ИНАЧЕ пометить страницу состоянием ошибки
    — 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);
        });
    }
    

    Достижение лимита страниц проверяется простым счетчиком запросов. Второй счетчик — количество активных запросов в момент времени — послужит проверкой готовности отдать результат (когда значение оборачивается в ноль). Если функции fetch не удалось получить очередную страницу, то для нее Status Code выставим как null.

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

    7. Возвращаемый результат


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

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

    Для результата заведем массив pages, в который будем складывать объекты с данными по странице: id {number}, url {string} и code {number|null} (этого сейчас достаточно). Также заведем массив links для ссылок между страницами в виде объекта: from (id страницы-источника), to (id целевой страницы).

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

    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


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

    Сейчас это бесцеремонный отправитель запросов и следующим разумным шагом будет — научить его хорошим манерам. Речь пойдет о заголовке User-agent, правилах robots.txt, директиве Crawl-delay и прочем. С точки зрения реализации, это первым делом организация очереди сообщений и далее обслуживание большей нагрузки. Если, конечно, этот материал будет интересным!
    • +11
    • 6,5k
    • 8
    SEMrush
    70,04
    Компания
    Поделиться публикацией

    Похожие публикации

    Комментарии 8

      +2
      Материал интересный, продолжайте, пожалуйста.
        0
        Спасибо! надеюсь буду располагать временем )
        0
          0
          Да, у нас в компании есть краулеры как минимум на C/C++, Java. Но определенно это не то место для более подробной информации ) Настоящий материал имеет ознакомительный характер, цель которого — рассказать об основных моментах, с которыми имеешь дело вне зависимости от выбранного языка. Если же интерес вызван потенциальным желанием принять участие в разработке, то всегда можно откликнуться, прийти и узнать подробности )
          0
          А почему не использовали request или got? Пункты 1 и 2 он прекрасно покрывает.
          И есть ли смысл использовать JSDOM для данной задачи?
            0
            request тянет кучу ненужных зависимостей, тогда как сейчас нужно просто отправить GET-запрос за контентом и заголовками:

            image

            «Покрывает 2ой пункт» — в смысле, обходит редиректы? это как раз умышленно не происходит, чтобы руками собрать все цепочки и использовать более-менее общий алгоритм экстрактора.

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

            И есть ли смысл использовать JSDOM для данной задачи?

            Если есть альтернативы полегче, буду рад идеям! Задача сводится к парсингу контента для дальнейшего простого поиска элементов дерева по атрибутам и их значениям.
              0
              JSDOM умеет рендерить виртуальный дом, это удобно для парсинга SPA. Но нужно дождаться, когда фронт получит все данные с бэкенда. В остальных случаях это оверхед, легко упереться в лимит по памяти.
              Для таких задач я использую cheerio, он предоставляет такой же интерфейс как и jQuery. Это весьма удобно, можно тестировать экстрагирование данных в консоли браузера, а потом просто вставлять в код краулера
                0
                Спасибо большое за наводку!

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

          Самое читаемое