Web scraping на Node.js і проблемні сайти

Це друга стаття про створення і використання скриптів для веб-скрейпинга на Node.js.
першої статті розбиралася найпростіша задача світу з веб-скрейпинга. Саме такі завдання дістаються веб-скрейперам в переважній більшості випадків – отримання даних з незахищених HTML-сторінок стабільно працюючого сайту. Швидкий аналіз сайту, HTTP-запити за допомогою needle (організовані за допомогою tress), рекурсивний прохід по посиланнях, DOM-парсинг за допомогою cheerio – ось це ось все.
У цій статті розуміється більш складний випадок. Не з тих, коли доводиться відмовлятися від взятого з боєм замовлення, але з тих, які починаючому скрейперу можуть зірвати дедлайн. До речі, ця задача містилася в реальному замовленні на одній міжнародній біржі фріланса, і перший виконавець її провалив.
Мета цієї статті (як і минулого) – показати весь процес створення і використання скрипта від постановки задачі і до отримання кінцевого результату, проте теми, вже розкриті в першій статті, висвітлюються тут досить коротко, так що я рекомендую почати з першою статті. Тут акцент буде на аналіз сайту з точки зору веб-скрейпинга, виявлення підводних каменів і способи їх обходу.
Постановка завдання
Замовник хоче скрипт, який буде отримувати дані з маркерів на карті в одному з розділів якогось сайту 'LIS Map' (посилання на розділ додається: 'http://www.puntolis.it/storelocator/defaultsearch.aspx?idcustomer=111'). У зміст даних вникати не потрібно (все одно там все по-італійськи). Достатньо якщо скрипт зможе взяти рядки з маркерів і зберегти їх в електронну таблицю в стовпці '
Title
', '
Address
' і '
Place
'.
Маркерів на сайті багато, так що вони організовані порціями по регіонах і вибираються з випадаючих списків в два або три рівні. Маркери потрібні всі. Порядок не важливий.
Дуже схоже, що зручного API у сайту немає. Принаймні замовник про нього не знає і на сайті його не помітно. Значить доведеться скрейпить.
Аналіз сайту
Перша погана новина – весь вибір і відображення даних на сайті відбувається динамічно на одній і тій же сторінці. Виглядає це так: при оновленні показується випадаючий список, після вибору пункту – ще випадаючий список (а іноді після нього і ще один), а потім з'являється карта обраного регіону.
Пошук слів із маркерів з початкового тексту сторінки нічого не дає. Пошук слів з випадаючих списків – теж. Вже на цьому етапі може здатися, що сайт можна скрейпить тільки інструментами типу PhantomJS або Selenium WD, але зневірятися рано. Швидше за все дані або утримуються в одному з підключених скриптів, або завантажуються динамічно. У будь-якому випадку їх можна знайти на вкладці Network Chrome DevTools або в аналогічному інструменті в іншому браузері.
З самого початку разом з HTML нашої цільової сторінки підвантажується статика (картинки, CSS, пара скриптів), а також виконується кілька запитів через XMLHttpRequest. Майже всі запити подгружают додаткові скрипти і тільки один – щось ще. Його адреса ось такий:
http://www.puntolis.it/storelocator/buildMenuProv.ashx?CodSer=111

Ось так це виглядає у браузері (скріншот клікабельний):

Заглядаємо в нього і бачимо дані для першого випадаючого списку у вигляді фрагмента HTML. Кожен пункт списку (судячи з усього це називається 'Provincia') представлений фрагментом такого виду:
< option value='AG' id='Agrigento'>Agrigento</option>

Очищаємо вкладку Network і вибираємо один з пунктів списку. Відбувається ще один XHR-запит на ось таку адресу:
http://www.puntolis.it/storelocator/buildMenuLoc.ashx?CodSer=111&ProvSel=AG

Ось так це виглядає у браузері (скріншот клікабельний):

Букви AG в кінці адреси – це код провінції Agrigento з попереднього списку. На всякий випадок можна спробувати з іншими провінціями і переконатися, що так воно і працює. У відповідь на запит приходить фрагмент HTML з вмістом другого випадаючого списку (схоже це називається 'Comune'). Кожен пункт представлений ось таким фрагментом:
< option value='X084001Agrigento' id='084001'>Agrigento</option>

Вибираємо пункт з другого списку й на сторінці з'являється карта з позначками, дані яких приходять у вигляді XML у відповідь на черговий XHR-запит ось за такою адресою:
http://www.puntolis.it/storelocator/Result.aspx?provincia=AG&localita=084001&cap=XXXXX&Servizio=111

Ось так це виглядає у браузері (скріншот клікабельний):

Придивившись до цього адресою легко помітити в ньому літерний код провінції Agrigento (AG) і цифровий ідентифікатор комуни Agrigento (084001). Тепер у нас є всі шаблони адрес, щоб отримати список маркерів, кожен з яких буде представлений ось таким фрагментом:
<marker id_pv="PA1150" INSEGNA="CASULA GERLANDA " INDIRIZZO="VIA DANTE ALIGHIERI 14" CAP="92100" PROVINCIA="AG" LOCALITA="AGRIGENTO " TELEFONO="" TELEFONO2="" FAX="" EMAIL="annacasula1970@libero.it" lat="37.3088220" lon="13.5788890" CODSER="101,102,103,104,105,106,107,109,110,111,112,113,114,201,202,203,204,210,220,240,250,260,261,270,290,301,302,303,306,401,402" />

Потрібні нам поля є в даних маркера, хоч і по шматочках.
Варто згадати, що на деяких сайтах дані можуть бути зашифровані, і тоді виявити їх простим пошуком характерних слів практично нереально. На щастя, таке трапляється вкрай рідко. На нашому сайті все простіше.)
Тепер згадуємо, що для деяких провінцій на сайті видається не два, а три рівня випадаючих списків. Запит даних після другого списку підставляється не цифровий ідентифікатор комуни, а її назва, адреса трохи інший, а у відповідь не приходить список маркерів, а пункти третього списку.
Ось так це виглядає у браузері (скріншот клікабельний):

Може здатися, що нам доведеться по-різному обробляти різні провінції, але перш ніж турбуватися варто все перевірити. Якщо ми руками підставимо потрібні дані в шаблон, то одержимо список всіх маркерів, наприклад, для комуни Tivoli в провінції Roma:
http://www.puntolis.it/storelocator/Result.aspx?provincia=RM&localita=058104&cap=XXXXX&Servizio=111

Таким чином можна забути про третій рівень. Мабуть веб-майстер нашого сайту теж полінувався по-різному обробляти запити для різних провінцій і просто прикрутив третій рівень поверх початкового дворівневого інтерфейсу. Такі милиці зустрічаються часто і якщо їх перевіряти, то можна заощадити час і здорово спростити скрипт.
Отримання сторінок
Використання http-клієнта в скрипті досить докладно розглядалася в першій статті. Тут варто зупинитися лише на одному моменті.
Знайдені нами адреси можна відкрити в браузері і побачити їх вміст (HTML або XML відповідно), але тільки якщо в браузері вже встановлені куки з основної сторінки розділу (посилання з завдання). У випадку з curl і з http-клієнтом в скрипті ситуація така ж. Це найпримітивніша захист від ботів, але її доведеться врахувати. Це – друга погана новина. На самому початку треба виконати запит до основної сторінки, зберегти отримані куки і передавати їх разом з кожним наступним запитом.
Краулинг
У нас є чотири типи URL, з якими нам доведеться працювати:
  1. Основна сторінка (потрібен тільки щоб отримати куки)
  2. Список провінцій (з нього будемо починати)
  3. Шаблон списку комун
  4. Шаблон списку маркерів
Адреси, побудовані на основі списків, ми будемо додавати в чергу, а дані з маркерів – зберігати в масив. Весь краулинг буде виглядати приблизно ось так:
var tress = require('tress');
var needle = require('needle');
var fs = require('fs');

// Головна сторінка (тільки заради куків):
var sCookie = 'http://www.puntolis.it/storelocator/defaultsearch.aspx?idcustomer=111';

// Стартовий URL. Список провінцій:
var sProv = 'http://www.puntolis.it/storelocator/buildMenuProv.ashx?CodSer=111';

// Шаблон списку комун для заданої провінції (підставити код провінції замість %s):
var sLoc = 'http://www.puntolis.it/storelocator/buildMenuLoc.ashx?CodSer=111&ProvSel=%s';

// Шаблон списку маркерів (підставити коди провінції і коммунны замість %s):
var sMarker = 'http://www.puntolis.it/storelocator/Result.aspx?provincia=%s&localita=%s&cap=XXXXX&Servizio=111';

var httpOptions = {};
var results = [];

// Налаштовуємо чергу завдань
var q = tress(crawl);

q.drain = function(){
fs.writeFileSync('./data.json', JSON.stringify(results, null, 4));
}

// Ініціалізація
needle.get(sCookie, function(err, res){
if (err || res.statusCode !== 200)
throw err || res.statusCode;

// встановлюємо cookie
httpOptions.cookies = res.cookies;

// Запускаємо краулинг
q.push(sProv);
});

function crawl(url, callback){
needle.get(url, httpOptions, function(err, res){
if (err || res.statusCode !== 200)
throw err || res.statusCode;

// Тут буде парсинг
callback();
});
}

По суті все схоже на краулинг з попередньої статті, тільки додано установка куків окремим запитом, обробник завдань винесено в окрему функцію, а в обробці помилок перевіряється код відповіді.
Кілька слів про збереження в електронну таблицюЗвичайно, якщо замовник просить збереження даних в Excel, йому буде досить CSV. Іноді взагалі вдається домовитися на JSON (благо безкоштовних онлайнових конвертерів існує достатньо). Але якщо замовнику принципово потрібен файл xlsx – можна скористатися, наприклад, модулем excelize або іншої подібної обгорткою. Наприклад так:
q.drain = function(){
require('excelize')(results, './', 'ADDR.xlsx', 'sheet', function(err){
if (err) throw err;
console.log(results.length + ' adresses saved.');
});
}

Парсинг
Парсити добре організовані шматки HTML/XML набагато простіше, ніж захаращені сторінки, так що всім, хто розібрався з парсингом в минулій статті, в цій все повинно бути очевидно без пояснень. Блок коду парсинга буде виглядати так:
var $ = cheerio.load(res.body);

$('#TendinaProv option').slice(1).each(function() {
q.push(sLoc.replace('%s', $(this).attr('value')));
});

$('select[onchange="onLocSelect()"] option').slice(1).each(function() {
q.push(sMarker.replace('%s', url.slice(-2)).replace('%s', $(this).attr('id')));
});

$('marker').each(function() {
results.push({
Title: $(this).attr('insegna').trim(),
Address: $(this).attr('indirizzo').trim(),
Place: [
$(this).attr('cap').trim(),
$(this).attr('localita').trim(),
$(this).attr('provincia').trim()
].join(' ')
});
});

Особливо варто звернути увагу на метод
slice
cheerio
. Він працює точно також, як однойменний метод у масивів. Конкретно тут він використовується перед
each
для видалення з вибірок пунктів списку першого пункту, який не несе корисної інформації. Але це не те, з-за чого метод
slice
варто знати кожному скрейперу, використовує
cheerio
. Головне, що при тестуванні скрейпинга сайтів з великими вибірками можна перед викликом методу each викликати, наприклад
slice(0,5)
(або
slice(1,5)
в нашому випадку), щоб зменшити вибірку до прийнятних розмірів. Скрейпинг буде працювати повністю в бойовому режимі, але не так довго.
увага: якщо будете пробувати скрейпить LIS Map – обов'язково використовуйте
slice
. Хабраэффект вбиває.
)
Індикація
Є як мінімум дві причини, щоб тут не нехтувати індикацією, як це було зроблено в минулий раз.
По-перше, сайт LIS Map значно менш стабільний, ніж Ferra.ru, так що на етапі бойових запусків скрипта буде здорово бачити, що саме відбувається.
По-друге, на цей раз замовник хоче не готові дані, а працездатний скрипт, який зможе запускати вручну по мірі необхідності. Навряд чи сподобається замовнику раз за разом сумувати, дивлячись на завмерлий курсор у вікні терміналу і намагатися вгадати, скільки часу пройшло, скільки роботи за цей час виповнилося і все з цією роботою нормально. Але копатися в довгих файли логів він теж не захоче, так що наворочені gps логери, такі як bunyan або winston, тут будуть надмірною рішенням. Йому знадобиться щось більш просте і наочне: консольний індикатор прогресу, поєднаний з гранично лаконічним консольним ж логером, що повідомляє про основні події.
Оскільки при веб-скрейпинге практично ніколи не відомий кінцевий обсяг робіт (бо рекурсивний прохід по посиланнях і все таке), стандартний індикатор прогресу у вигляді заповнюються смужки тут не підійде. Краще зробити лічильник виконаних завдань (рядок з «бігаючими» циферками). Також знадобиться висновок термінал різних видів повідомлень, який не буде затирати лічильник. Повідомлення добре б супроводжувати автоматичними мітками часу.
Саме під такі завдання створений модуль cllc (Command line logger and counter). З його допомогою можна відображати рядок з лічильниками, і виводити повідомлення.

Нам знадобляться наступні можливості модуля
cllc
:
var log = require('cllc')();

log('Message'); // Вивести звичайне повідомлення
log.e('Error message'); // Вивести повідомлення про помилку з ярликом <ERROR>

// Створити індикатор з трьома лічильниками:
log.start('Знайдено провінцій %s Знайдено комун %s Знайдено маркерів %s.');
log.step(); // Збільшити перший лічильник на 1. (Те ж, що і log.step(1))
log.step(0, 1); // Збільшити другий лічильник на 1.
log.step(0, 0, 1); // Збільшити третій лічильник на 1.
log.finish(); // Зупинити індикатор.

Між викликами
log.start
та
log.finish
усі повідомлення будуть виводитися над індикатором нічого не затираючи. Всі повідомлення супроводжуються тимчасовими мітками.
Повний код скрипта з індикацією за допомогою `cllc`
var log = require('cllc')();
var tress = require('tress');
var needle = require('needle');
var cheerio = require('cheerio');
var fs = require('fs');

var sCookie = 'http://www.puntolis.it/storelocator/defaultsearch.aspx?idcustomer=111';
var sProv = 'http://www.puntolis.it/storelocator/buildMenuProv.ashx?CodSer=111';
var sLoc = 'http://www.puntolis.it/storelocator/buildMenuLoc.ashx?CodSer=111&ProvSel=%s';
var sMarker = 'http://www.puntolis.it/storelocator/Result.aspx?provincia=%s&localita=%s&cap=XXXXX&Servizio=111';

var httpOptions = {};
var results = [];

var q = tress(crawl);

q.drain = function(){
fs.writeFileSync('./data.json', JSON.stringify(results, null, 4));
log.finish();
log('Робота закінчена');
}

needle.get(sCookie, function(err, res){
if (err || res.statusCode !== 200)
throw err || res.statusCode;

httpOptions.cookies = res.cookies;
log('Початок');
log.start('Знайдено провінцій %s Знайдено комун %s Знайдено маркерів %s.');
q.push(sProv);
});

function crawl(url, callback){
needle.get(url, httpOptions, function(err, res){
if (err || res.statusCode !== 200) {
log.e((err || res.statusCode) + ' - ' + url);
log.finish();
process.exit();
}

var $ = cheerio.load(res.body);

$('#TendinaProv option').slice(1).each(function() {
q.push(sLoc.replace('%s', $(this).attr('value')));
log.step();
});

$('select[onchange="onLocSelect()"] option').slice(1).each(function() {
q.push(sMarker.replace('%s', url.slice(-2)).replace('%s', $(this).attr('id')));
log.step(0, 1);
});

$('marker').each(function() {
results.push({
Title: $(this).attr('insegna').trim(),
Address: $(this).attr('indirizzo').trim(),
Place: [
$(this).attr('cap').trim(),
$(this).attr('localita').trim(),
$(this).attr('provincia').trim()
].join(' ')
});
log.step(0, 0, 1);
});
callback();
});
}

Запускаємо скрипт і бачимо на індикаторі, що все працює. Скрипт знаходить 110 провінцій і 5116 комун, а потім починає збирати маркери. Але дуже швидко валиться з помилкою
socket hang up
. При перезапуску помилка вилазить відразу, ще на стадії ініціалізації. У браузері в цей час видається сторінка помилки з кодом 500.

На сторінці помилки говориться, що можлива причина – перевищення допустимої кількості підключень. Маються на увазі підключення до бази даних, а не по http. Простіше кажучи, установка
connection: 'Keep-Alive'
на
needle
нам не допоможе. Така ж сторінка видається в іншому браузері і з іншого IP (тобто це не блокування проксі не допоможуть). Таким чином сайт лежить протягом приблизно 20-30 хвилин, як пощастить. Потім ситуація повторюється. Як ви напевно здогадалися, це – третя погана новина.
Обробка помилок
Найгірше з такими сайтами те, що їх скрейпинг важко тестувати. Варто запустити скрипт не з тим параметром – і доведеться чекати більше 20 хвилин, щоб повторити спробу. Без індикації було б зовсім сумно, а так ми всього за кілька запусків можемо визначити, що сайт щоразу падає після трьох з невеликим збережених маркерів. Трохи погравшись з лічильниками можна встановити, що мова йде приблизно про півтори сотні комун. Це означає, що якщо наш скрипт не буде завершуватися після помилки, а буде чекати поки сайт підніметься і продовжувати, то він зупиниться більше 30 разів. Тобто робота скрипта займе 10-15 годин.
Не факт, що замовника такий варіант влаштує. Можливо він захоче закрити замовлення сплативши вже витрачений час. Однак перш ніж засмучувати замовника варто перевірити ще один варіант.
Цілком можливо, що ми занадто часто бомбимо сайт запитами. Варто спробувати встановити між запитами затримку і подивитися що вийде. Технічно це зробити дуже легко, нам навіть нічого не доведеться писати. У модуля
tress
є властивість
concurrency
, добре знайомий користувачам async.queue. Це властивість задається при створенні черзі другим параметром (за замовчуванням
concurrency
1, і вказує у скільки паралельних потоків будуть оброблятися завдання. Ось тільки у
tress
властивість
concurrency
може мати і негативні значення, означають, що між завданнями в єдиному потоці повинна бути затримка. Наприклад, якщо встановити concurrency в -1000, то це буде означати затримку в 1000 мілісекунд. Очевидність інтерфейсу тут принесена в жертву сумісності з
async.queue
, але якщо знати – все просто.
Залишилося вирішити, якою має бути затримка. Прості розрахунки показують, що при затримці в 10 секунд наші 5227 запитів (1 список провінцій, 110 списків комун і 5116 списків маркерів) будуть виконуватися більше 14 годин. Тобто навіть якщо за цей час сайт не впаде з часу ми нічого не виграємо. З іншого боку, навіть 100 мілісекунд в світі http – це цілком помітна затримка. Для початку спробуємо поставити затримку в 1 секунду.
var q = tress(crawl, -1000);

Нічого не змінилося, сайт все так само падає після трьох з невеликим знайдених маркерів. Для очищення совісті спробуємо затримку в 3 секунди.
var q = tress(crawl, -3000);

Сайт падає після перших семи сотень маркерів. Можна було б продовжувати експериментувати з затримками побільше, але по-перше за часом кардинального виграшу вже не буде, а по-друге, ми не зможемо гарантувати, що сайт точно не впаде. Таким чином у нас залишається тільки один робочий варіант – у разі помилки повертати завдання в чергу, ставити всю чергу на паузу, після чого відновлювати скрейпинг до наступної помилки, і так – 10-20 годин.
Ось на цьому етапі можна цікавитися у замовника, влаштує його настільки повільний скрипт, враховуючи, що помітно швидше йому не зробить ніхто. Тут ми будемо виходити з припущення, що замовник погодився.
Отже, нам треба зробити, щоб якщо запит http завершується помилкою, то відповідний адреса повертається в чергу, а сама чергу ставиться на паузу на вказаний час. Все це відмінно реалізується стандартними можливостями модуля
tress
, що вигідно відрізняють його від
async.queue
.
У черзі tress будь-яка задача знаходиться в одному з чотирьох станів:
  1. Очікує виконання
  2. Виконується
  3. Успішно виконана
  4. Визнана нездійсненним
Розробнику всі ці задачі представлені у вигляді чотирьох масивів, доступних за властивостями q.waiting, q.active, q.finished і q.failed. Під час всяких налагоджувальних експериментів вміст цих масивів навіть можна міняти по живому, але в робочих скриптах так робити не варто. Та й потреби в такому хакерстве немає, адже все відбувається автоматично. Коли завдання передається обробникові, вона переноситься з масиву waiting в масив active, де і залишається поки не буде викликаний колбек. Після виклику колбэка завдання переноситься з active в один з трьох масивів. У будь – залежить від параметрів колбэка:
  • Якщо колбек викликаний без параметрів або якщо перший параметр null – завдання визнається виконаним і поміщається в масив finished.
  • Якщо перший параметр колбэка має тип boolean, то завдання повертається на повторну обробку і ставиться в початок черги (початок масиву waiting), якщо параметр дорівнює true, або в кінець черги (кінець масиву waiting), якщо параметр дорівнює false.
  • Якщо перший параметр колбэка – об'єкт помилки (instanceof Error), то завдання переміщається в масив failed.
  • За будь-яких інших значеннях першого параметра колбэка поведінку модуля не визначено і може змінюватися в наступних версіях (так що краще не треба).
Після переміщення завдання з active в інший масив tress викликає один з трьох обробників: q.success, q.retry або q.error відповідно. Важливо, що в однопоточному режимі (concurrency <= 1) виконання обробника завершується до того, як стартує наступна задача. Це дозволяє нам зробити наступне:
Обробку помилки запиту зробимо так:
function crawl(url, callback){
needle.get(url, httpOptions, function(err, res){
if (err || res.statusCode !== 200) {
log.e((err || res.statusCode) + ' - ' + url);
return callback(true); // повертаємо url в початок черги
}

// парсинг

callback();
});
}

І додамо, наприклад, такий обробник q.retry:
var q = tress(crawl);

q.retry = function(){
q.pause();
// this лежить повернута в чергу завдання.
log.i('Призупинено on:', this);
setTimeout(function(){
q.resume();
log.i('Resumed');
}, 300000); // 5 хвилин
}

Такий скрипт успішно завершує скрейпинг за 14 з невеликим годин, як і очікувалося.
Затримку я поставив на 5 хвилин. Якщо сайт ще не прокинувся – просто ще раз випаде помилка, а якщо прокинувся – не доведеться чекати даремно. Найменше втрати часу будуть, якщо паузу взагалі не включати, але тоді скрипт буде безглуздо бомбити сайт запитами і смітити в лог однаковими повідомленнями про помилку.
Ще один спосіб – у разі помилки знижувати швидкість (ставити негативну concurrency). Наприклад ось так:
var q = tress(crawl);

q.success = function(){
q.concurrency = 1;
}

q.retry = function(){
q.concurrency = -300000; // 5 хвилин
}

Висновок
В такому вигляді скрипт можна віддавати замовнику (для бажаючих код останнього варіанту на gist). У реальному житті в комплект постачання можуть додатися різні дрібниці, такі як інструкція по інсталяції і запуску node-скрипта на Windows, але технічно одного скрипта достатньо.
Варто відзначити, що при падіннях сайтів часто злітають сесії, так що доводиться перед зняттям черзі з паузи (див. вище перший варіант) пробувати провести ініціалізацію. У нашому випадку цього не відбувається, але так буває далеко не завжди. При проектуванні власної універсальної бібліотеки для веб-скрейпинга взагалі варто передбачити можливість передавати в неї окрему асинхронну функцію для ініціалізації, всередині якої можна виконувати будь http-запити або щось ще.
Ще варто відзначити, що в таких популярних скриптах варто передбачити можливість перервати роботу скрипту (наприклад, Ctrl-C, або вимикання комп'ютера), а потім продовжити з того ж місця без втрати даних. Скрипти для веб-скрейпинга досить часто запускаються не на надійних віддалених серверах, а на персональних комп'ютерах замовників, а 14 годин – це далеко не межа, тому що переривання скрипта з збереженням даних – це важливо. Навіть якщо замовник цього не просить – потім він пошкодує, що не попросив. Я планую зупинитися детальніше на цій темі в іншій статті.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.