Попередні дві теми були про щасливий випадок. У темі 27 ми навчилися розмовляти з чужим сервером по HTTP, у темі 28 — брати в нього дані через API: акуратний JSON, документована адреса, передбачувані поля. Сьогодні розберемо те, що робити, коли API немає.
Ситуація буденна. Кавʼярня, меню якої ми возимо через увесь курс, має сайт. На сайті є сторінка «Меню»: назви, ціни, категорії — усе перед очима. А API немає, і не буде: сайт робила студія за фіксовану суму, і в технічному завданні слова «API» не було. Дані є. Доступу до них у зручному вигляді — ні.
МотивКоли даних немає ніде
Скрапінг (scraping) — це коли програма читає сторінку, зроблену для людських очей, і дістає з неї дані. Слово походить від to scrape, «зішкрібати»: ми зішкрібаємо корисне з оформлення, у яке його загорнули.
Технічно перший крок нічим не відрізняється від теми 27 — той самий requests.get,
той самий код 200. Різниця в тому, що приїде у відповідь. З API приходить структура:
список, у ньому словники, у них поля з іменами. Зі сторінки приходить текст розмітки,
де назва напою загублена серед тегів, класів і відступів, а її «полем» є те, що верстальник
випадково поставив навколо неї <h3 class="name">.
сторінка віддає: <article class="item">
<h3 class="name">Капучино</h3>
<span class="price">45 грн</span>
</article>
Обидва рядки містять ту саму інформацію. Але перший — це контракт: поле зветься
name, і той, хто його зробив, обіцяв не міняти назву просто так. Другий — це
оформлення: клас name існує, щоб зробити текст жирним, і зникне того дня,
коли дизайнер перемалює картку меню.
Звідси головна теза теми, і вона не про мораль, а про інженерію. Скрапінг — не спосіб отримати дані. Це спосіб отримати дані тоді, коли всі інші не спрацювали. Не тому, що він поганий сам собою, а тому, що він тримається на речах, які ніхто не обіцяв тримати.
ПорядокПорядок пошуку даних
Перш ніж писати перший рядок скрапера, пройди чотири сходинки згори вниз. Кожна наступна дорожча в підтримці за попередню, і зупинятись треба на першій, що спрацювала.
- Офіційний API. Те, чим ми займались
у темі 28. Якщо він є — розмова закінчена.
Шукати варто на сторінці
/api, у розділі «Розробникам», у підвалі сайту. - Готовий експорт. Дуже багато сервісів уміють віддати те саме кнопкою «Завантажити CSV» або «Експорт у Excel». Це той самий файл, який ти збирався склеювати руками, тільки повний і без помилок розбору.
- Відкриті дані. Державні реєстри, портали відкритих даних, дампи Вікіпедії. Якщо потрібне не з одного сайту, а «взагалі», перевір спершу тут.
- І тільки потім — скрапінг. Коли перші три сходинки порожні.
Найкорисніша звичка на цьому шляху — перевірка «а чи точно немає API». Відкрий сторінку в браузері, натисни F12, перейди на вкладку Мережа (Network) і онови сторінку. Ти побачиш список усього, що браузер завантажив: картинки, стилі, скрипти — і, дуже часто, запит із відповіддю в JSON.
requests. Скрапінг у такому разі закінчується, не
почавшись: далі звичайна робота з JSON із теми 28.Чому це важливо настільки, що заслуговує окремої сходинки: API ламається рідко й голосно (версія змінилась, поле перейменували, про це написали в журналі змін). Скрапер ламається часто й тихо — від зайвої обгортки, від перейменованого класу, від того, що дизайнер поміняв місцями ціну й назву. Різниця не в складності коду, а в тому, скільки він проживе.
МежіПраво й етика
Розділ короткий і без залякування. Але прочитати його треба до того, як писати код, а не після.
robots.txt — текстовий файл у корені сайту:
site.com/robots.txt. Читається одним запитом і виглядає так:
Disallow: /admin
Disallow: /item
Crawl-delay: 1
Тут написано: будь-якому роботові не варто ходити в /admin і /item,
а між запитами робити паузу в секунду. Головне про цей файл — те, чим він не є.
Це прохання, а не технічний замок: сервер віддасть закриті сторінки будь-кому, хто їх
попросить. Ніякої перевірки там немає. Файл існує рівно тому, що автори сайтів і автори роботів
колись домовились його поважати.
- Умови користування. Багато сайтів прямо забороняють автоматичний збір
у своїх правилах. Це окремий документ від
robots.txt, і читати його треба очима — автоматично він не перевіряється. - Навантаження. Сотня запитів на секунду з одного скрипта — це реальна шкода чужому сервісу, за який хтось платить. Для маленького сайту такий скрапер невідрізнимий від атаки, і поводитись із ним будуть відповідно.
- Персональні дані. Імена, телефони, адреси, фото людей — окрема відповідальність, яка не зникає від того, що дані лежали у відкритому доступі. «Було видно на сайті» не означає «можна зберігати й публікувати».
- Правила різняться. Що дозволено в одній країні, заборонено в іншій, і судова практика змінюється. Цей курс не дає юридичної поради. Якщо збираєш дані для роботи, а не для навчання, — питай юриста, а не форум.
Чотири перевірки перед першим запитом
Усі чотири мусять дати «так», і кожну робиш ти сам. Перемикай ситуацію й дивись, яка з перевірок падає.
robots.txt — прохання, а не замок: сервер віддасть закриту сторінку
тому, хто попросить, і жодного повідомлення про порушення не надішле. Тому «пройшло» тут
означає «я перевірив», а не «мені дозволили».І звідси випливає правило, з яким варто вийти з цієї теми: чужий сайт без
дозволу не чіпають — на ньому не вчаться. Вправлятися треба на власному сервері:
підняти його на 127.0.0.1 і віддавати з нього свої ж сторінки коштує кількох
рядків, а ламати селектори й повторювати запити на ньому можна скільки завгодно, нікого
не питаючи. Тут збігаються дві причини, і кожної окремо було б досить. Перша — етична:
навантаження, яке ти створюєш собі, нікому не заважає. Друга — інженерна: чужа сторінка
змінюється без попередження, і код, який сьогодні знаходить вісім позицій, за півроку
знайде нуль — не тому, що він поганий, а тому, що дизайнер перемалював картку. На власній
сторінці той самий код дає той самий результат і через рік.
МодельHTML як дерево
Файл сторінки — плаский текст. Але описує він дерево, і саме як дерево з ним треба працювати. Чотири поняття, яких вистачить на всю тему:
- Тег — назва в кутових дужках. Здебільшого їх двоє: відкривний
<h3>і закривний</h3>. Усе між ними — вміст тега. - Атрибут — іменована властивість усередині відкривного тега:
class="name",href="/item?id=2". Атрибутclassособливий: він може містити кілька імен через пробіл. - Вкладеність — теги лежать один в одному. Тег усередині іншого зветься дитиною (child), зовнішній — батьком (parent), а все, що лежить усередині на будь-якій глибині, — нащадками (descendants).
- Текст — те, що не тег. У дереві він теж вузол, просто без імені й без атрибутів.
Слово вузол (node) далі означатиме будь-який елемент цього дерева. Розбір сторінки — це перетворення плаского тексту на дерево вузлів; далі ми лише ходимо по ньому.
Той самий шматок сторінки двома способами
Ліворуч — текст, як він лежить у файлі. Праворуч — дерево, як його бачить парсер. Наведи мишу на будь-який бік: підсвітиться відповідник на другому.
СпокусаЧому не регулярками
У темі 25 ми отримали інструмент, який шукає в тексті
за зразком. Спокуса величезна: назва напою лежить між <h3 class="name"> і
</h3>, шаблон пишеться за десять секунд:
І на першій сторінці це працює. Тому спокуса й небезпечна: помилку видно не одразу.
Причина глибша за «верстальник поставив зайвий пробіл». HTML не є регулярною мовою.
Це формальне твердження, і воно означає ось що: регулярний вираз — машина зі скінченною
памʼяттю, вона не вміє рахувати вкладеність. А вкладеність — сама суть HTML. Шаблон
принципово не може відрізнити цей закривний </div> від того, що
закриває блок трьома рівнями вище. Не «важко», а не може: у нього немає лічильника.
До цього додаються дрібниці, кожної з яких досить, щоб шаблон розсипався: пробіли навколо знака рівності, одинарні лапки замість подвійних, порядок атрибутів, перенесення тега на новий рядок, коментар усередині розмітки. Для браузера все це — та сама сторінка. Для шаблону — інший текст.
Регулярка проти парсера
Один і той самий пункт меню, записаний чотирма способами. Для браузера всі чотири однакові. Перемикай і дивись, що знаходить кожен інструмент.
Отже, правило. Розмітку розбирає парсер. Регулярні вирази лишаються
корисними — але вже після нього, на витягнутому тексті: дістати число з рядка
«30,00 UAH» — саме та задача, для якої вони створені.
ІнструментBeautifulSoup: перший розбір
BeautifulSoup — бібліотека, яка перетворює текст сторінки на дерево
обʼєктів. Вона не входить у стандартну поставку, тому ставиться окремо
(тема 18) — і тут перша пастка новачка: пакет
зветься beautifulsoup4, а імпортується з bs4.
from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, "html.parser")
Другий аргумент — парсер, тобто той, хто власне читає розмітку. BeautifulSoup сама розбором не займається: вона зручна обгортка над чужим двигуном. Вибір невеликий:
| Парсер | Звідки | Коли брати |
|---|---|---|
| html.parser | стандартна бібліотека | за замовчуванням: нічого не треба ставити, працює всюди |
| lxml | окремий пакет | коли сторінок тисячі й швидкість почала мати значення |
| html5lib | окремий пакет | коли розмітка настільки крива, що перші два розходяться з браузером |
Другий аргумент можна не передавати — тоді бібліотека візьме, що знайде, і надрукує попередження. Не роби так: код, який мовчки залежить від того, які пакети встановлені на конкретній машині, дає різний результат у тебе й у колеги. Пиши парсер явно.
Далі — два методи, з яких усе починається. find повертає перший
відповідний вузол або None, find_all — список усіх:
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 > article | article — прямий нащадок блоку menu, рівно один рівень |
Різниця між останніми двома рядками — головне, що варто винести з цього розділу. Пробіл
означає «десь усередині», а стрілка — «безпосередньо всередині». Стрілка точніша й тому
крихкіша: досить обгорнути вміст ще одним <div>, і вона перестане знаходити
будь-що.
Конструктор селектора
Дерево сторінки меню. Обери спосіб пошуку й подивись, скільки вузлів він захопив — і чи саме ті.
Коли що зручніше? find_all виграє, коли умова описується словами Python:
шукати за кількома атрибутами одразу, підставити імʼя класу зі змінної, передати функцію
як умову. select виграє, коли умова стосується місця вузла в дереві:
«ціна всередині картки всередині меню» одним рядком. На практиці в одному скрапері живуть
обидва, і це нормально.
ЗдобичТекст і атрибути
Знайти вузол — половина роботи. Вузол не рядок: це обʼєкт із власними дітьми. Дістати з нього корисне можна двома шляхами.
Текст. Властивість .text віддає весь текст усередині вузла
як є — разом із переходами рядка й відступами верстальника. Метод
.get_text(strip=True) обрізає пробіли по краях кожного шматка. Тонкість, на якій
спотикаються: strip=True обрізає кожен шматок окремо, а потім склеює їх
упритул. Якщо всередині назви трапився вкладений тег, два слова злипнуться в одне.
tag.text → 'Капучино нове'
tag.get_text(strip=True) → 'Капучинонове' ← злиплось
tag.get_text(" ", strip=True) → 'Капучино нове' ← перший аргумент — роздільник
Атрибути. Беруться як зі словника з теми 09 — і з тим самим вибором:
tag.get("href") # немає атрибута → None
tag.get("href", "") # немає атрибута → ""
Обидві поведінки правильні — питання в тому, чого ти хочеш від скрапера. Якщо посилання
обовʼязкове й без нього запис безглуздий, квадратні дужки корисні: програма
зупиниться там, де щось пішло не так, і ти дізнаєшся про це сьогодні. Якщо необовʼязкове —
get із запасним значенням.
get там, де мало
бути []. Тоді None тихо їде далі, потрапляє в словник, з нього
в CSV, і ти дізнаєшся про порожню колонку, коли звіт уже пішов замовникові. Мовчазна
відсутність даних завжди дорожча за гучну помилку.МетаВід сторінки до даних
Легко забути, навіщо все це. Мета скрапінгу — не «розібрати HTML». Мета — отримати таблицю, з якою далі працює все, що ми вже вміємо: CSV, pandas, звіт. Дерево вузлів — проміжна станція, а не результат.
Тому конвеєр завжди однаковий і завжди складається з чотирьох кроків. Кожен наступний працює з тим, що віддав попередній, і жоден не намагається робити дві речі одразу.
Від сторінки до таблиці
Чотири кроки конвеєра. Тисни «крок» і дивись, у що перетворюються дані.
Практичний наслідок цього поділу: функція, яка перетворює сторінку на словники, має бути
тупою. Вона перекладає теги в поля й нічого більше — не рахує, не фільтрує,
не нормалізує. Ціна « 55 грн » так і лягає в поле price_raw
рядком. Перетворити її на число — робота наступного кроку, і робити її окремо треба тому,
що ламаються ці два кроки з різних причин.
КрихкістьЧому скрапер ламається
Тепер найважливіше в темі. Скрапер ламається не «якщо», а «коли». Питання лише в тому, чи ти про це дізнаєшся.
Селектори бувають різної живучості. Крихкий тримається за форму дерева:
div > div > div:nth-child(3) означає «третій блок у другому блоці в першому».
Він точний, він працює — рівно до першої зайвої обгортки. Стійкий тримається
за сенс: клас price названий так тому, що там ціна, і при перемальовуванні
картки його швидше збережуть, ніж структуру вкладеності.
Що переживе редизайн
Ліворуч — розмітка після зміни. Праворуч — два селектори, крихкий і стійкий, та скільки вузлів знайшов кожен.
І ось відповідь на це питання. Найгірший результат зламаного скрапера — не помилка, а мовчазний порожній список. Програма відпрацювала, файл записався, у ньому самий заголовок — і ніхто цього не читає до кінця кварталу. Тому в кожен скрапер вбудовується дешева перевірка: чи знайшлось хоч щось.
if len(rows) < 3:
raise ValueError("знайшли лише {} позицій — схоже, верстка змінилась".format(len(rows)))
Три рядки, які перетворюють тиху поломку на гучну. Але цього мало: перевіряти треба не тільки «чи знайшлось щось», а й «чи знайшлось саме те». Восьмеро позицій із порожніми назвами пройдуть перевірку на кількість і не пройдуть перевірку на зміст. Тому корисно друкувати після кожного збирання короткий звіт: скільки рядків, скільки з ціною, скільки з посиланням. Коли «з назвою» раптом стає нулем, це видно того ж дня.
МежаДинамічні сторінки
Рано чи пізно станеться таке. Ти відкриваєш сторінку в браузері — меню на місці, вісім
позицій. Запускаєш скрапер — select(".name") повертає порожній список. Код
правильний, селектор правильний, сторінка правильна.
Причина в тому, що requests — це завантажувач, а не браузер.
Він забирає текст, який віддав сервер, і на цьому його робота закінчується. Браузер після
завантаження робить ще одну річ: виконує JavaScript. А скрипт може домалювати вміст —
сходити по дані окремим запитом і вставити картки в порожній контейнер. У тексті, який приїхав,
цього вмісту справді немає.
Що бачить requests, а що браузер
Та сама адреса, три погляди на неї. Перемикай і дивись, звідки береться порожній список.
Отже, порядок дій при порожньому списку такий:
- Перевір, що приїхало насправді. Надрукуй
response.textі пошукай у ньому назву, яку бачиш у браузері. Немає — сторінка динамічна. - Шукай прихований запит. Вкладка «Мережа», фільтр за
XHR/Fetch. Це дешево, швидко й дає стабільніший результат за будь-який скрапінг. - Браузерна автоматизація — останній варіант. Playwright і Selenium піднімають справжній браузер, чекають на JavaScript і віддають готову сторінку. Це працює, але коштує на порядок дорожче: сотні мегабайтів залежностей, секунди на сторінку замість мілісекунд і ще одна річ, яка ламається. У цьому курсі ми їх не вивчаємо — важливо знати, що вони існують і коли до них тягнутись.
МанериВвічливість
Скрапер, на відміну від людини, не втомлюється. Цикл на двісті сторінок без жодної паузи виконається за десять секунд і дасть двадцять запитів на секунду — навантаження, якого маленький сайт не бачить і в години пік. Три речі роблять збирання прийнятним.
- Пауза між запитами. Найпростіше —
time.sleep(0.5)у циклі. Якщо вrobots.txtєCrawl-delay, бери його. - Чесний
User-Agent. Заголовок, у якому клієнт називає себе. За замовчуваннямrequestsпишеpython-requests/2.34.2; краще написати назву свого скрипта й спосіб звʼязатися. Так адміністратор, побачивши незвичне навантаження, зможе написати тобі, а не просто заблокувати адресу. - Локальний кеш. Найдешевша ввічливість із усіх: не питати двічі те саме. Поки ти налагоджуєш селектори, сторінка потрібна десятки разів — і жоден із цих разів не мусить доходити до чужого сервера.
Ціна паузи
Двісті сторінок, кожен запит триває приблизно 0.05 с. Крути повзунок і дивись на обидва числа одразу: навантаження й час збирання.
ПідсумокЩо ми тепер уміємо
Тема була про останній засіб, і саме так її й треба тримати в голові:
- порядок пошуку даних — API, експорт, відкриті дані, і тільки потім скрапінг; перевірка «а чи точно немає API» через вкладку «Мережа» окупається найчастіше з усього, що є в цій темі;
- право й етика —
robots.txtяк прохання, а не замок; умови користування; навантаження; персональні дані; - HTML як дерево — тег, атрибут, вкладеність, текст; вузол, батько, нащадок;
- чому не регулярками — HTML не регулярна мова, а найгірша поразка шаблону тиха: він повертає сміття, а не помилку;
BeautifulSoup—find,find_all,select,select_one; парсер задається явно;- текст і атрибути —
.get_text(strip=True)і роздільник першим аргументом;tag["href"]протиtag.get("href"); - конвеєр — сторінка, вузли, словники, CSV; функція розбору лишається тупою;
- крихкість — безсмертного селектора немає, тому в скрапер вбудовується перевірка, яка перетворює тиху поломку на гучну;
- динамічні сторінки —
requestsне виконує JavaScript; шукай прихований запит, перш ніж тягнутись по браузерну автоматизацію.
На цьому блок 6 закінчено. Ми пройшли шлях від протоколу до API й до сторінок, які API не мають, — і тепер уміємо дістати дані майже звідки завгодно.
А тепер подивись на власний скрапер збоку. У ньому вже живуть fetch,
parse_page, price_to_number, crawl,
quality_report — і всі вони передають одна одній ті самі дві речі: «сторінку»
й «позицію меню». Позиція меню кочує зі словника в словник, і щоразу ми памʼятаємо напамʼять,
які в ній поля, які з них можуть бути None і що з нею взагалі можна робити.
Поки функцій пʼять, це терпимо. На пʼятнадцятьох — ні.
Тобто нам бракує способу сказати: «ось тип під назвою «позиція меню», у ньому такі поля, і ось що з ним можна зробити». Саме це дають класи й обʼєкти — тема 30, з якої починається наступний блок.