Скрапінг сторінок

Коли API немає, лишається сторінка, зроблена для очей. Як прочитати її як дерево, чому регулярний вираз тут програє — і чому будь-який скрапер колись ламається.

Попередні дві теми були про щасливий випадок. У темі 27 ми навчилися розмовляти з чужим сервером по HTTP, у темі 28 — брати в нього дані через API: акуратний JSON, документована адреса, передбачувані поля. Сьогодні розберемо те, що робити, коли API немає.

Ситуація буденна. Кавʼярня, меню якої ми возимо через увесь курс, має сайт. На сайті є сторінка «Меню»: назви, ціни, категорії — усе перед очима. А API немає, і не буде: сайт робила студія за фіксовану суму, і в технічному завданні слова «API» не було. Дані є. Доступу до них у зручному вигляді — ні.

МотивКоли даних немає ніде

Скрапінг (scraping) — це коли програма читає сторінку, зроблену для людських очей, і дістає з неї дані. Слово походить від to scrape, «зішкрібати»: ми зішкрібаємо корисне з оформлення, у яке його загорнули.

Технічно перший крок нічим не відрізняється від теми 27 — той самий requests.get, той самий код 200. Різниця в тому, що приїде у відповідь. З API приходить структура: список, у ньому словники, у них поля з іменами. Зі сторінки приходить текст розмітки, де назва напою загублена серед тегів, класів і відступів, а її «полем» є те, що верстальник випадково поставив навколо неї <h3 class="name">.

API віддає:  {"name": "Капучино", "price": 45}

сторінка віддає:  <article class="item">
                 <h3 class="name">Капучино</h3>
                 <span class="price">45 грн</span>
               </article>

Обидва рядки містять ту саму інформацію. Але перший — це контракт: поле зветься name, і той, хто його зробив, обіцяв не міняти назву просто так. Другий — це оформлення: клас name існує, щоб зробити текст жирним, і зникне того дня, коли дизайнер перемалює картку меню.

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

ПорядокПорядок пошуку даних

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

  1. Офіційний API. Те, чим ми займались у темі 28. Якщо він є — розмова закінчена. Шукати варто на сторінці /api, у розділі «Розробникам», у підвалі сайту.
  2. Готовий експорт. Дуже багато сервісів уміють віддати те саме кнопкою «Завантажити CSV» або «Експорт у Excel». Це той самий файл, який ти збирався склеювати руками, тільки повний і без помилок розбору.
  3. Відкриті дані. Державні реєстри, портали відкритих даних, дампи Вікіпедії. Якщо потрібне не з одного сайту, а «взагалі», перевір спершу тут.
  4. І тільки потім — скрапінг. Коли перші три сходинки порожні.

Найкорисніша звичка на цьому шляху — перевірка «а чи точно немає API». Відкрий сторінку в браузері, натисни F12, перейди на вкладку Мережа (Network) і онови сторінку. Ти побачиш список усього, що браузер завантажив: картинки, стилі, скрипти — і, дуже часто, запит із відповіддю в JSON.

Це трапляється частіше, ніж здається. Сучасна сторінка рідко приходить готовою. Зазвичай браузер завантажує порожній каркас, а потім скрипт іде по дані окремим запитом — і саме цей запит і є неоформленим API. Ти бачиш його адресу, бачиш відповідь, можеш повторити її через requests. Скрапінг у такому разі закінчується, не почавшись: далі звичайна робота з JSON із теми 28.

Чому це важливо настільки, що заслуговує окремої сходинки: API ламається рідко й голосно (версія змінилась, поле перейменували, про це написали в журналі змін). Скрапер ламається часто й тихо — від зайвої обгортки, від перейменованого класу, від того, що дизайнер поміняв місцями ціну й назву. Різниця не в складності коду, а в тому, скільки він проживе.

МежіПраво й етика

Розділ короткий і без залякування. Але прочитати його треба до того, як писати код, а не після.

robots.txt — текстовий файл у корені сайту: site.com/robots.txt. Читається одним запитом і виглядає так:

User-agent: *
Disallow: /admin
Disallow: /item
Crawl-delay: 1

Тут написано: будь-якому роботові не варто ходити в /admin і /item, а між запитами робити паузу в секунду. Головне про цей файл — те, чим він не є. Це прохання, а не технічний замок: сервер віддасть закриті сторінки будь-кому, хто їх попросить. Ніякої перевірки там немає. Файл існує рівно тому, що автори сайтів і автори роботів колись домовились його поважати.

Чотири перевірки перед першим запитом

Усі чотири мусять дати «так», і кожну робиш ти сам. Перемикай ситуацію й дивись, яка з перевірок падає.

пройдено—
рішення—
Головне: список короткий, але жодного пункту з нього не перевіряє машина. robots.txt — прохання, а не замок: сервер віддасть закриту сторінку тому, хто попросить, і жодного повідомлення про порушення не надішле. Тому «пройшло» тут означає «я перевірив», а не «мені дозволили».
Практичне правило. Постав собі одне питання: чи готовий ти надіслати власникові сайту листа з описом того, що робить твій скрапер? Якщо так — майже напевно все гаразд. Якщо ні — річ не в законі, а в тому, що ти вже знаєш відповідь.

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

МодельHTML як дерево

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

Слово вузол (node) далі означатиме будь-який елемент цього дерева. Розбір сторінки — це перетворення плаского тексту на дерево вузлів; далі ми лише ходимо по ньому.

Той самий шматок сторінки двома способами

Ліворуч — текст, як він лежить у файлі. Праворуч — дерево, як його бачить парсер. Наведи мишу на будь-який бік: підсвітиться відповідник на другому.

показано вузлів—
глибина—
Що тут головне: у тексті article займає два рядки, розірвані на початок і кінець, а в дереві це один вузол із трьома дітьми. Саме тому шукати в тексті рядками так незручно: те, що для парсера один обʼєкт, у файлі розкидане по різних місцях. Натисни «вище» й подивись, що зникає першим — текст. Він завжди найглибший.

СпокусаЧому не регулярками

У темі 25 ми отримали інструмент, який шукає в тексті за зразком. Спокуса величезна: назва напою лежить між <h3 class="name"> і </h3>, шаблон пишеться за десять секунд:

re.findall(r'<h3 class="name">(.*?)</h3>', text)

І на першій сторінці це працює. Тому спокуса й небезпечна: помилку видно не одразу.

Причина глибша за «верстальник поставив зайвий пробіл». HTML не є регулярною мовою. Це формальне твердження, і воно означає ось що: регулярний вираз — машина зі скінченною памʼяттю, вона не вміє рахувати вкладеність. А вкладеність — сама суть HTML. Шаблон принципово не може відрізнити цей закривний </div> від того, що закриває блок трьома рівнями вище. Не «важко», а не може: у нього немає лічильника.

До цього додаються дрібниці, кожної з яких досить, щоб шаблон розсипався: пробіли навколо знака рівності, одинарні лапки замість подвійних, порядок атрибутів, перенесення тега на новий рядок, коментар усередині розмітки. Для браузера все це — та сама сторінка. Для шаблону — інший текст.

Регулярка проти парсера

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

регулярка—
парсер—
Найважливіший варіант — четвертий. Там регулярка не падає й не повертає порожнечу: вона повертає сміття разом із розміткою. Такий скрапер працює, звіт збирається, і в колонці «назва» тихо зʼявляється Капучино <span class="new">нове</span>. Помилка, яка нічого не ламає, — найдорожча з усіх.

Отже, правило. Розмітку розбирає парсер. Регулярні вирази лишаються корисними — але вже після нього, на витягнутому тексті: дістати число з рядка «30,00 UAH» — саме та задача, для якої вони створені.

ІнструментBeautifulSoup: перший розбір

BeautifulSoup — бібліотека, яка перетворює текст сторінки на дерево обʼєктів. Вона не входить у стандартну поставку, тому ставиться окремо (тема 18) — і тут перша пастка новачка: пакет зветься beautifulsoup4, а імпортується з bs4.

pip install beautifulsoup4

from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, "html.parser")

Другий аргумент — парсер, тобто той, хто власне читає розмітку. BeautifulSoup сама розбором не займається: вона зручна обгортка над чужим двигуном. Вибір невеликий:

ПарсерЗвідкиКоли брати
html.parserстандартна бібліотеказа замовчуванням: нічого не треба ставити, працює всюди
lxmlокремий пакетколи сторінок тисячі й швидкість почала мати значення
html5libокремий пакетколи розмітка настільки крива, що перші два розходяться з браузером

Другий аргумент можна не передавати — тоді бібліотека візьме, що знайде, і надрукує попередження. Не роби так: код, який мовчки залежить від того, які пакети встановлені на конкретній машині, дає різний результат у тебе й у колеги. Пиши парсер явно.

Далі — два методи, з яких усе починається. find повертає перший відповідний вузол або None, find_all — список усіх:

soup.find("h3")                # перший h3 або None
soup.find_all("article")     # список усіх article
soup.find_all(class_="name")  # усі вузли з класом name

Підкреслення в class_ не помилка й не примха бібліотеки: слово class у Python зайняте під оголошення класу, і назвати ним аргумент неможливо. Автори додали підкреслення — це усталений спосіб обійти зіткнення з ключовим словом.

Дрібниця, яка економить години. find повертає None, коли нічого не знайшов, — не порожній список і не помилку. Тому soup.find("h3").text на сторінці без заголовків дасть AttributeError: 'NoneType' object has no attribute 'text'. Це найчастіший traceback усього скрапінгу, і майже завжди він означає не помилку в коді, а зміну верстки.

МоваCSS-селектори

Другий спосіб шукати — селектори, та сама мова, якою верстальник описує сторінку у стилях. Метод select повертає список, select_one — перший збіг або None.

СелекторЧитається
h3усі теги h3
.nameусі вузли з класом name
#pageвузол з ідентифікатором page (він на сторінці один)
h3.nameтег h3, у якого є клас name
.menu .nameклас name усередині блоку з класом menu, на будь-якій глибині
.menu > articlearticle — прямий нащадок блоку menu, рівно один рівень

Різниця між останніми двома рядками — головне, що варто винести з цього розділу. Пробіл означає «десь усередині», а стрілка — «безпосередньо всередині». Стрілка точніша й тому крихкіша: досить обгорнути вміст ще одним <div>, і вона перестане знаходити будь-що.

Конструктор селектора

Дерево сторінки меню. Обери спосіб пошуку й подивись, скільки вузлів він захопив — і чи саме ті.

селектор—
знайдено—
з них у меню—
Обовʼязково натисни «за класом». Селектор .name знаходить чотири вузли замість трьох: у підвалі сторінки лежить назва закладу, і верстальник цілком законно дав їй той самий клас. Скрапер цього не помітить — він просто допише в таблицю зайвий рядок. Рятує вкладений селектор: .menu .name звужує пошук до блоку меню.

Коли що зручніше? find_all виграє, коли умова описується словами Python: шукати за кількома атрибутами одразу, підставити імʼя класу зі змінної, передати функцію як умову. select виграє, коли умова стосується місця вузла в дереві: «ціна всередині картки всередині меню» одним рядком. На практиці в одному скрапері живуть обидва, і це нормально.

ЗдобичТекст і атрибути

Знайти вузол — половина роботи. Вузол не рядок: це обʼєкт із власними дітьми. Дістати з нього корисне можна двома шляхами.

Текст. Властивість .text віддає весь текст усередині вузла як є — разом із переходами рядка й відступами верстальника. Метод .get_text(strip=True) обрізає пробіли по краях кожного шматка. Тонкість, на якій спотикаються: strip=True обрізає кожен шматок окремо, а потім склеює їх упритул. Якщо всередині назви трапився вкладений тег, два слова злипнуться в одне.

<h3 class="name">Капучино <span>нове</span></h3>

tag.text                      → 'Капучино нове'
tag.get_text(strip=True)     → 'Капучинонове'  ← злиплось
tag.get_text(" ", strip=True) → 'Капучино нове' ← перший аргумент — роздільник

Атрибути. Беруться як зі словника з теми 09 — і з тим самим вибором:

tag["href"]                 # немає атрибута → KeyError
tag.get("href")             # немає атрибута → None
tag.get("href", "")        # немає атрибута → ""

Обидві поведінки правильні — питання в тому, чого ти хочеш від скрапера. Якщо посилання обовʼязкове й без нього запис безглуздий, квадратні дужки корисні: програма зупиниться там, де щось пішло не так, і ти дізнаєшся про це сьогодні. Якщо необовʼязкове — get із запасним значенням.

Найгірший із трьох варіантів — get там, де мало бути []. Тоді None тихо їде далі, потрапляє в словник, з нього в CSV, і ти дізнаєшся про порожню колонку, коли звіт уже пішов замовникові. Мовчазна відсутність даних завжди дорожча за гучну помилку.

МетаВід сторінки до даних

Легко забути, навіщо все це. Мета скрапінгу — не «розібрати HTML». Мета — отримати таблицю, з якою далі працює все, що ми вже вміємо: CSV, pandas, звіт. Дерево вузлів — проміжна станція, а не результат.

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

Від сторінки до таблиці

Чотири кроки конвеєра. Тисни «крок» і дивись, у що перетворюються дані.

крок—
позицій—
Зверни увагу на третій крок. Саме там дані перестають бути «сторінкою» і стають звичайним списком словників — тим, з чим ми працювали ще в темі 09. Усе, що після нього, до скрапінгу вже не має стосунку. Чим раніше конвеєр доходить до цього кроку, тим менше коду залежить від чужої верстки.

Практичний наслідок цього поділу: функція, яка перетворює сторінку на словники, має бути тупою. Вона перекладає теги в поля й нічого більше — не рахує, не фільтрує, не нормалізує. Ціна « 55 грн » так і лягає в поле price_raw рядком. Перетворити її на число — робота наступного кроку, і робити її окремо треба тому, що ламаються ці два кроки з різних причин.

КрихкістьЧому скрапер ламається

Тепер найважливіше в темі. Скрапер ламається не «якщо», а «коли». Питання лише в тому, чи ти про це дізнаєшся.

Селектори бувають різної живучості. Крихкий тримається за форму дерева: div > div > div:nth-child(3) означає «третій блок у другому блоці в першому». Він точний, він працює — рівно до першої зайвої обгортки. Стійкий тримається за сенс: клас price названий так тому, що там ціна, і при перемальовуванні картки його швидше збережуть, ніж структуру вкладеності.

Що переживе редизайн

Ліворуч — розмітка після зміни. Праворуч — два селектори, крихкий і стійкий, та скільки вузлів знайшов кожен.

крихкий—
стійкий—
Висновок не той, якого чекаєш. Стійкий селектор виграє у двох випадках із чотирьох і програє в одному — коли клас перейменували, він падає, а крихкий виживає. Безсмертного селектора немає. Тому питання не «як не зламатись», а «як помітити, що зламався».

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

rows = parse_page(html)
if len(rows) < 3:
    raise ValueError("знайшли лише {} позицій — схоже, верстка змінилась".format(len(rows)))

Три рядки, які перетворюють тиху поломку на гучну. Але цього мало: перевіряти треба не тільки «чи знайшлось щось», а й «чи знайшлось саме те». Восьмеро позицій із порожніми назвами пройдуть перевірку на кількість і не пройдуть перевірку на зміст. Тому корисно друкувати після кожного збирання короткий звіт: скільки рядків, скільки з ціною, скільки з посиланням. Коли «з назвою» раптом стає нулем, це видно того ж дня.

МежаДинамічні сторінки

Рано чи пізно станеться таке. Ти відкриваєш сторінку в браузері — меню на місці, вісім позицій. Запускаєш скрапер — select(".name") повертає порожній список. Код правильний, селектор правильний, сторінка правильна.

Причина в тому, що requests — це завантажувач, а не браузер. Він забирає текст, який віддав сервер, і на цьому його робота закінчується. Браузер після завантаження робить ще одну річ: виконує JavaScript. А скрипт може домалювати вміст — сходити по дані окремим запитом і вставити картки в порожній контейнер. У тексті, який приїхав, цього вмісту справді немає.

Що бачить requests, а що браузер

Та сама адреса, три погляди на неї. Перемикай і дивись, звідки береться порожній список.

вузлів .name—
позицій—
Третій перемикач — найважливіший. Скрипт, який домальовує меню, мусить десь узяти дані — і бере їх запитом на /api/menu.json. Ця адреса видна у вкладці «Мережа» будь-якого браузера. Знайшов її — і скрапінг закінчився: далі звичайний JSON із теми 28, без тегів і без крихкості.

Отже, порядок дій при порожньому списку такий:

  1. Перевір, що приїхало насправді. Надрукуй response.text і пошукай у ньому назву, яку бачиш у браузері. Немає — сторінка динамічна.
  2. Шукай прихований запит. Вкладка «Мережа», фільтр за XHR/Fetch. Це дешево, швидко й дає стабільніший результат за будь-який скрапінг.
  3. Браузерна автоматизація — останній варіант. Playwright і Selenium піднімають справжній браузер, чекають на JavaScript і віддають готову сторінку. Це працює, але коштує на порядок дорожче: сотні мегабайтів залежностей, секунди на сторінку замість мілісекунд і ще одна річ, яка ламається. У цьому курсі ми їх не вивчаємо — важливо знати, що вони існують і коли до них тягнутись.

МанериВвічливість

Скрапер, на відміну від людини, не втомлюється. Цикл на двісті сторінок без жодної паузи виконається за десять секунд і дасть двадцять запитів на секунду — навантаження, якого маленький сайт не бачить і в години пік. Три речі роблять збирання прийнятним.

Ціна паузи

Двісті сторінок, кожен запит триває приблизно 0.05 с. Крути повзунок і дивись на обидва числа одразу: навантаження й час збирання.

запитів/с—
усе збирання—
Постав повзунок у нуль. Двадцять запитів на секунду — і збирання справді триває десять секунд. Але «швидко» тут означає «до першого блокування»: після нього збирання триватиме не десять секунд, а ніколи. Пауза 0.2 с розтягує роботу до пʼятдесяти секунд і знімає навантаження впʼятеро — обмін, вигідний обом сторонам.

ПідсумокЩо ми тепер уміємо

Тема була про останній засіб, і саме так її й треба тримати в голові:

На цьому блок 6 закінчено. Ми пройшли шлях від протоколу до API й до сторінок, які API не мають, — і тепер уміємо дістати дані майже звідки завгодно.

А тепер подивись на власний скрапер збоку. У ньому вже живуть fetch, parse_page, price_to_number, crawl, quality_report — і всі вони передають одна одній ті самі дві речі: «сторінку» й «позицію меню». Позиція меню кочує зі словника в словник, і щоразу ми памʼятаємо напамʼять, які в ній поля, які з них можуть бути None і що з нею взагалі можна робити. Поки функцій пʼять, це терпимо. На пʼятнадцятьох — ні.

Тобто нам бракує способу сказати: «ось тип під назвою «позиція меню», у ньому такі поля, і ось що з ним можна зробити». Саме це дають класи й обʼєкти — тема 30, з якої починається наступний блок.