Як я створив бібліотеку для витягування даних з DOM

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Це невелика історія про написання npm-бібліотеки для парсингу та пошуку даних у динамічному DOM-дереві. Створено на основі нещодавнього офлайн-виступу на конференції fwdays. Це смішно, але на самій презентації я висловив припущення, що це моя остання презентація коду, який я писав руками :)

Вступ

Сучасний веб — хаотичний. І хоча HTML виглядає як дерево, у реальних умовах він нагадує зарості тропічного лісу: глибокий, непослідовний і непередбачуваний. Кожен розробник пише код по-різному, у своєму стилі та за своїми правилами. Тому веб-скрапінг іноді потребує додаткових зусиль, якщо ми хочемо отримати бажані дані. Щоб мінімізувати ці зусилля, була створена проста npm-бібліотека, яка трохи спрощує цей процес.

Коли мені було потрібно витягати щось із різних сайтів, я робив це «руками». Наприклад, якщо треба зібрати інформацію про товари на Розетці, нам були б потрібні такі поля:

  • назва товару;
  • ціна;
  • фото;
  • SKU;
  • щось додаткове.

Думаю, ви не раз бачили, як виглядає цей сайт. Ось приклад однієї зі сторінок із рандомними товарами:

image

Наприклад, якщо нам потрібно витягнути якийсь товар, усе виглядає непогано, доки не заглянеш у DOM і не починаєш створювати query до елементів.

Проблема: динамічний DOM постійно ламає ваші селектори

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

image

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

Для прикладу вище ваш код для пошуку потрібних елементів, імовірно, виглядає ось так:

image

І все це перестає працювати, якщо:

  • програміст замінив <h1> на <div>;
  • з’явився новий <span>;
  • переставили елементи місцями;
  • додалися нові елементи;
  • змінили або забрали id;
  • і так далі.

Проблема тут фундаментальна: для пошуку даних ми описуємо структуру DOM, а не самі дані.

Ідея: описувати дані, а не структуру DOM

Я сформулював головне питання: «як дістати дані, мінімально опираючись на структуру DOM?». Це привело мене до ідеї розробити DSL — невелику мову, яка описує саме дані, а не структуру DOM. Так з’явився проєкт Harvester.

Що таке Harvester

Це бібліотека для опису даних у вигляді простого текстового tree-like шаблону, який:

  • легко писати;
  • легко читати;
  • слабко залежить від конкретного DOM;
  • толерантний до зміни верстки;
  • знаходить дані, навіть якщо немає за що зачепитися (немає id, класів і так далі).

Давайте глянемо на код для пошуку даних із прикладу, який ми розглядали вище:

image

Справа зверху ви можете бачити приклад шаблону, який описує продукт, його ціну, картинку та 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 всередині

image

На малюнку показано, як саме бібліотека порівнює два дерева — дерево наших даних, яке ми отримали із DSL-шаблона (на малюнку зліва — з гілками klm), і DOM-дерево (на малюнку праворуч із гілками 1234...). Справа знизу ви можете побачити частину варіантів, які бібліотека порівнює по факту. Спочатку вона намагається знайти всі гілки (усі три klm) з DSL-шаблону, і якщо це неможливо, то продовжує з меншим деревом (kl, km, lm) і так до найменшого дерева (тільки одна гілка k, або l, або m).

Бібліотека використовує fuzzy tree matching:

  1. Розбирає DSL-шаблон у JSON-дерево.
  2. Проходить по DOM.
  3. Шукає найближчу відповідність.
  4. Вичитує текст/числа/атрибути згідно з типами.
  5. Повертає результат у вигляді JSON.

Трохи деталей

Розглянемо детальніше, як саме Harvester розуміє, які дані підходять під наш шаблон. На малюнку нижче ви можете бачити розрахунки так званого score. Score — це звичайне ціле значення, яке розраховується для кожної гілки окремо. Усе дерево (з усіма внутрішніми гілками) теж буде мати свій score, і так до root-гілки. Не стільки важливо, як саме воно розраховується, як те, що саме значення говорить нам про те, наскільки та чи інша гілка схожа на ту, що ми шукаємо.

image

Загальний алгоритм такий: спочатку ми розраховуємо максимально можливий 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.
  • Не має залежностей від інших бібліотек.

Додаткові матеріали: репозиторій, презентація.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Трохи дьогтю в бочку з медом. Зараз LLM робить виколупування даних зі сторінки набагато краще, ніж будь-яка бібліотека. Дорожче, але універсальніше та не боїться змін структури сторінок.

Але, якщо ми кажемо про парсинг та мапінг, то з практики можу сказати, що ця процедура мусить бути каскадною та з можливістю трансформацій структур посередині.

Потрібна інформація знаходиться тільки в конкретних гілках DOM дерева. Не треба все дерево аналізувати, щоб щось знайти. Інколи аналіз затруднений, бо структура має недолік у вигляді однакових тегів за невідомою послідовністю (пропуски). Тут багато різних способів є. Але інколи, для спрощення, краще змінити структуру (щось перейменувати або спростити) перед тим як відправляти на розбір. Наприклад, у вас є послідовність A -> B -> C, яка має різне значення в залежності від позиції в DOM дереві. Тоді перейменування хоча б одного з елементів створить нову унікальну послідовність, що набагато легше шукати.

Саме про це я і писав на початку про можливо останній писаний руками крд :) Бібліотека писалась ще в часи коли ai не був на стільки сильним. Але так, це має сенс.

В статті не згадується XPath. Чому?

Сучасні фронтендирі зазвичай гадки не мають що це таке. Через це вони починають збирати власні велосипеди, на кшталт JOLT, від яких потім з очей криваві сльози течуть.

Але це не значить, що автор топіку не молодець. Я поважаю людей, які в своєму житті хоч раз хоч одну бібліотеку написали.

Це є в презентації. Стаття обзорна і поверхнева насправді. Задача була якраз мати простіший за css query & xpath механізм.

Підписатись на коментарі