Як я створив бібліотеку для витягування даних з DOM
Це невелика історія про написання npm-бібліотеки для парсингу та пошуку даних у динамічному DOM-дереві. Створено на основі нещодавнього офлайн-виступу на конференції fwdays. Це смішно, але на самій презентації я висловив припущення, що це моя остання презентація коду, який я писав руками :)
Вступ
Сучасний веб — хаотичний. І хоча HTML виглядає як дерево, у реальних умовах він нагадує зарості тропічного лісу: глибокий, непослідовний і непередбачуваний. Кожен розробник пише код по-різному, у своєму стилі та за своїми правилами. Тому веб-скрапінг іноді потребує додаткових зусиль, якщо ми хочемо отримати бажані дані. Щоб мінімізувати ці зусилля, була створена проста npm-бібліотека, яка трохи спрощує цей процес.
Коли мені було потрібно витягати щось із різних сайтів, я робив це «руками». Наприклад, якщо треба зібрати інформацію про товари на Розетці, нам були б потрібні такі поля:
- назва товару;
- ціна;
- фото;
- SKU;
- щось додаткове.
Думаю, ви не раз бачили, як виглядає цей сайт. Ось приклад однієї зі сторінок із рандомними товарами:

Наприклад, якщо нам потрібно витягнути якийсь товар, усе виглядає непогано, доки не заглянеш у DOM і не починаєш створювати query до елементів.
Проблема: динамічний DOM постійно ламає ваші селектори
Давайте спробуємо абстрагуватися та розв’язати проблему більш глобально. Ось типова HTML-картка товару в інтернет-магазині (зліва і справа один і той самий товар за різних умов):

Структури різні. Теги різні. Порядок різний. Але товар той самий. Чому так? Тому що сучасні сайти використовують різні фреймворки, які генерують HTML динамічно залежно від цих самих умов. Наприклад, для адміністратора або звичайного користувача сторінка може виглядати по-різному. І це нормально.
Для прикладу вище ваш код для пошуку потрібних елементів, імовірно, виглядає ось так:

І все це перестає працювати, якщо:
- програміст замінив <h1> на <div>;
- з’явився новий <span>;
- переставили елементи місцями;
- додалися нові елементи;
- змінили або забрали id;
- і так далі.
Проблема тут фундаментальна: для пошуку даних ми описуємо структуру DOM, а не самі дані.
Ідея: описувати дані, а не структуру DOM
Я сформулював головне питання: «як дістати дані, мінімально опираючись на структуру DOM?». Це привело мене до ідеї розробити DSL — невелику мову, яка описує саме дані, а не структуру DOM. Так з’явився проєкт Harvester.
Що таке Harvester
Це бібліотека для опису даних у вигляді простого текстового tree-like шаблону, який:
- легко писати;
- легко читати;
- слабко залежить від конкретного DOM;
- толерантний до зміни верстки;
- знаходить дані, навіть якщо немає за що зачепитися (немає id, класів і так далі).
Давайте глянемо на код для пошуку даних із прикладу, який ми розглядали вище:

Справа зверху ви можете бачити приклад шаблону, який описує продукт, його ціну, картинку та SKU (унікальний номер) для обох випадків. Давайте трохи зупинимося на його форматі.
Загалом це проста багаторядкова JS-стрічка, яка описує дерево даних. Кожна лінія — це тег у DOM-дереві, де ми вказуємо деталі пошуку тексту в цьому тезі. Структура не обов’язково має бути схожа на фактичний DOM. Вона має бути як мінімум близькою до оригінальної. Кожна стрічка має містити ім’я тегу, опціональну змінну для тексту та опціональну змінну для атрибута. Ось список прикладів рядків для формування шаблонів пошуку даних:
- * — означає «шукати будь-який тег»;
- div — означає «шукати конкретне ім’я тегу» (може бути span, head, що завгодно);
- span{price:float} — означає «шукати span із числом типу float всередині» та покласти число в змінну price.
- img[img=src] — означає «шукати тег img з атрибутом src» та покласти значення в змінну img.
- div{sku:with:SKU} — означає «шукати тег div із текстом, який має підтекст ’SKU’» та покласти в змінну sku.
- p{text}[attr=ref] — означає «шукати тег p із будь-яким текстом та атрибутом ref» та покласти текст у змінну text, а атрибут — у змінну attr.
Справа знизу на малюнку ви можете побачити приклад того, що повертає бібліотека. Це звичайний JSON, ключі якого — це наші змінні, які ми зазначили в шаблоні (product, price, img, sku).
Як працює Harvester всередині

На малюнку показано, як саме бібліотека порівнює два дерева — дерево наших даних, яке ми отримали із DSL-шаблона (на малюнку зліва — з гілками klm), і DOM-дерево (на малюнку праворуч із гілками 1234...). Справа знизу ви можете побачити частину варіантів, які бібліотека порівнює по факту. Спочатку вона намагається знайти всі гілки (усі три klm) з DSL-шаблону, і якщо це неможливо, то продовжує з меншим деревом (kl, km, lm) і так до найменшого дерева (тільки одна гілка k, або l, або m).
Бібліотека використовує fuzzy tree matching:
- Розбирає DSL-шаблон у JSON-дерево.
- Проходить по DOM.
- Шукає найближчу відповідність.
- Вичитує текст/числа/атрибути згідно з типами.
- Повертає результат у вигляді JSON.
Трохи деталей
Розглянемо детальніше, як саме Harvester розуміє, які дані підходять під наш шаблон. На малюнку нижче ви можете бачити розрахунки так званого score. Score — це звичайне ціле значення, яке розраховується для кожної гілки окремо. Усе дерево (з усіма внутрішніми гілками) теж буде мати свій score, і так до root-гілки. Не стільки важливо, як саме воно розраховується, як те, що саме значення говорить нам про те, наскільки та чи інша гілка схожа на ту, що ми шукаємо.

Загальний алгоритм такий: спочатку ми розраховуємо максимально можливий score для кожної гілки та зберігаємо його десь всередині бібліотеки. Далі під час пошуку ми рахуємо score для кожної DOM-гілки, в якій ми шукаємо наші дані, та обираємо ту, в якій він максимальний. Так ми можемо бути впевненими, що обрані нами дані будуть максимально відповідати тим, які ми описали в шаблоні. Не забувайте, що все, що ми знайшли, не буде на 100% тим, що ми по факту шукали. Тому що шаблон міг бути неточним, або таких даних просто немає, або вони є і дуже схожі на ті, що нам потрібні. Тому це все скоріше ймовірнісний процес.
Що це все нам дає
Маючи таку бібліотеку, ми можемо піти далі, використовуючи її разом із такими гігантами, як Puppeteer та Playwright, для більш специфічного скрапінгу. На сторінці harvester на GitHub у папці examples можна знайти пару прикладів використання harvester із Puppeteer та Playwright. Давайте глянемо на один із таких прикладів:
import { harvestPageAll } from 'js-harvester/puppeteer.js'
import { open, goto } from './utils.js'
const NEWS_QUERY = '.section_news_list_wrapper div.article_news_list'
const TPL = `
div{time}
div
a{title}`
const page = await open()
await goto(page, async () => page.goto('https://www.pravda.com.ua/news/', { waitUntil: 'load' }))
await page.waitForSelector(NEWS_QUERY)
const news = await harvestPageAll(page, TPL, NEWS_QUERY, { inject: true, dataOnly: true })
console.log(news, '\nPress Ctrl-C to stop...')
У цьому випадку ми витягуємо всі новини за сьогодні із сайту pravda.com.ua за дуже простим шаблоном (дивіться константу TPL), у якому ми беремо час та назву новини. Це відбувається за допомогою хелпер-функції harvestPageAll(), тобто «витягнути все за заданим query» (дивіться NEWS_QUERY) зі сторінки page. Ось і все. Приклади для Playwright майже нічим не відрізняються і знаходяться так само в папці examples.
Проблеми, які довелося вирішити
- адські рекурсії та глибокий DOM;
- схожі теги;
- невідповідність рівнів даних у DOM і в шаблоні;
- часткові збіги;
- експоненційне зростання комбінацій;
- дике гальмування при глибокому парсингу DOM.
Pros and Cons
- Декларативність.
- Немає прив’язки до структури DOM, id або класів.
- Невеликий розмір (приблизно 790 рядків коду).
- Толерантна до змін у DOM.
- Швидка.
- Витягує всі поля за один виклик функції harvest().
- Підтримує різні типи даних.
- Сумісний із Puppeteer/Playwright.
- Не має залежностей від інших бібліотек.
Додаткові матеріали: репозиторій, презентація.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів