Як створити парсер Auto.ria.com: 5 ключових моментів з практики
Наша команда розробляє парсери понад 10 років. Зараз, з розвитком вайб-кодингу (Cursor, Copilot та подібних інструментів), багато розробників можуть написати парсер самостійно за пару годин. Це чудово. Але в кожного конкретного сайту є свої підводні камені, які не видно з першого погляду і виринають вже у продакшені. Ділимося тим, що виявили при парсингу Auto.ria.com на реальному проєкті.
1. Звідки брати дані: HTML, а не API
Перше питання при старті будь-якого парсера — чи є у сайту API? Auto.ria.com працює на Vue.js із серверним рендерингом: дані гідруються в Pinia store і водночас присутні в DOM. Жодного публічного API немає, дані потрібно витягувати з HTML.
П'ять основних полів на сторінці оголошення:
| Поле | Селектор |
|---|---|
| Модель авто | #sideTitleTitle span |
| Ціна | #sidePrice strong |
| Ім'я продавця | #sellerInfoUserName span |
| Місто | #basicInfoTableMainInfoGeo span (3-тя частина через кому) |
| Телефон | #autoPhonePopUpResponse (після кліку на кнопку) |
Щоб швидко перевірити структуру на будь-якій сторінці оголошення, вставте цей сніппет у консоль DevTools:
const get = (sel) => document.querySelector(sel)?.innerText?.trim() ?? null;
console.table({
title: get('#sideTitleTitle span')
?? document.querySelector('h1')?.innerText?.trim(),
price: get('#sidePrice strong')
?? get('#basicInfoPrice strong'),
seller: get('#sellerInfoUserName span'),
city: (() => {
const geo = get('#basicInfoTableMainInfoGeo span');
if (!geo) return null;
const parts = geo.split(',').map(s => s.trim()).filter(Boolean);
return parts[2] ?? parts[parts.length - 1];
})(),
// Телефон буде null, поки не натиснута кнопка "Показати номер"
phone: document.querySelector('#autoPhonePopUpResponse')?.innerText?.trim()
?? document.querySelector('a[href^="tel:"]')?.href?.replace('tel:', ''),
});Запускайте прямо на сторінці оголошення — одразу побачите, які поля доступні.
2. Сценарій отримання номера телефону
Це найтрудомісткіший момент на Auto.ria.com. Телефони приховані за модальним вікном, яке відкривається по кнопці. Причому кнопка навмисно показує замаскований номер виду "063 XXX XX XX" — саме за наявністю "XXX" у тексті можна переконатися, що кнопку знайдено правильно, а номер ще не розкрито.
Повний сценарій отримання номера виглядає так:
1. Перевірити URL: має містити /auto_ (сторінка оголошення)
2. Зачекати 500мс для стабілізації сторінки
3. Закрити GDPR-банер: button.fc-cta-consent (якщо є)
4. Закрити промпт "Перейти на нову версію": button#switchingVersionsSet (якщо є)
5. Знайти кнопку: button[data-action='showBottomPopUp'] з "XXX" у тексті
6. Реалістичний клік: scrollIntoView → пауза 100мс → клік із симуляцією миші
7. Чекати до 5000мс появи #autoPhonePopUpResponse
8. Якщо попап не з'явився — виключення, сторінка іде на повтор
9. Випадкова затримка 900-1500мс перед переходом до наступної сторінкиКлючовий момент — кроки 3 і 4. Якщо не закрити службові попапи до кліку на телефонну кнопку, клік піде в нікуди. Це типова помилка, яку складно зловити при ручному тестуванні, бо при ручному перегляді сайту ці попапи з'являються рідко.
3. Резолвер пагінації: чому "клікнути наступну сторінку" не працює
Auto.ria.com використовує параметр ?page=N в URL. Здавалося б, просто інкрементуй число — що тут складного?
Проблема в тому, що при роботі через проксі сторінка пагінації може завантажитися, виглядати нормально, але містити ті самі оголошення з попередньої сторінки (кеш або гео-ротація проксі). Парсер про це не дізнається і мовчки продовжить збирати дублі. Або сторінка завантажиться порожньою, і парсер пропустить кілька реальних сторінок із даними.
Рішення — контентно-залежне просування: переходити на наступну сторінку лише якщо з поточної були отримані посилання на оголошення. Немає посилань — стоп.
public string? ResolveNextPage(string sourceUrl, string html, List<string> detailLinks)
{
if (detailLinks.Count > 0)
{
var currentPage = TryGetPageFromUrl(sourceUrl) ?? 0;
// Опціональний ліміт сторінок з конфігу
if (_maxPagesToParse > 0)
{
var parsedSoFar = currentPage - (_firstPageSeen ?? currentPage) + 1;
if (parsedSoFar >= _maxPagesToParse)
return null;
}
return SetOrReplacePageParam(sourceUrl, currentPage + 1);
}
return null; // Немає посилань = кінець результатів
}Додатково — всі оброблені посилання зберігаються в HashSet, тому навіть якщо одне й те саме оголошення зустрілося на двох різних сторінках лістингу, воно буде спаршено лише один раз.
4. Експорт у Excel: робити наприкінці, не в процесі
Часта помилка — писати дані в Excel порядково в міру збору. Це створює кілька проблем: файл постійно відкритий на запис, неможливо дедуплікувати, неможливо відсортувати підсумкову вибірку, при краші втрачається весь файл.
Правильна схема:
Цикл парсингу
└- ExtractDataAsync() -> DataRow
└- запис одного рядка в .csv (thread-safe через SemaphoreSlim)
Кінець сесії
└- ConvertCsvToExcelAsync() -> .xlsx (один прохід, весь файл)CSV працює як проміжний буфер — він простий, швидкий і не вимагає тримати в пам'яті весь датасет. Excel генерується один раз наприкінці через OpenXML SDK (без сторонніх бібліотек).
Підсумкові колонки у файлі:
| Колонка | Опис |
|---|---|
SourceUrl | Повний URL оголошення |
CarModel | Марка, модель, рік |
City | Місто продавця |
Name | Ім'я або назва компанії |
Phone | Номер телефону після розкриття |
Price | Ціна з символом валюти |
5. Головна прихована проблема: детектування браузерного фінгерпринту
Auto.ria.com використовує Cloudflare з JavaScript-рівневою перевіркою. Звичайний headless Chrome через Puppeteer блокується не по заголовках, а по поведінці браузера на рівні JS.
Сигнали, які видають автоматизацію:
navigator.webdriver === true— найочевидніший флагnavigator.hardwareConcurrencyдорівнює 1 (на реальних машинах зазвичай 8–16)- WebGL повертає
"Google SwiftShader"замість реального GPU - Canvas
toDataURL()дає однаковий результат у всіх сесіях
Рішення — стелс-скрипт, який інжектується через page.EvaluateExpressionOnNewDocumentAsync() і запускається до будь-якого коду сайту:
// Прибрати флаг webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// Реалістичне залізо (офісний ПК: 8 ядер, 8GB RAM)
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
// Підмінити GPU на Intel
WebGLRenderingContext.prototype.getParameter = function(p) {
if (p === 37445) return 'Intel Inc.';
if (p === 37446) return 'Intel(R) UHD Graphics 630';
return orig.call(this, p);
};
// Додати шум у canvas — унікальний per-сесія
HTMLCanvasElement.prototype.toDataURL = function(type, quality) {
// мутувати 3 конкретних пікселі по seed з поточної сесії
};Важливо: кожен проксі-IP отримує свій унікальний фінгерпринт із 6 профілів пристроїв (Intel офіс, NVIDIA gaming, AMD workstation та ін.) та ізольований кеш браузера (BrowserCache/{proxy_host}/). Без цього Auto.ria.com починає повертати CAPTCHA або сторінки без кнопки розкриття телефону вже після перших кількох запитів.
Не вийшло або парсер заблокувався?
Дві типові ситуації, з якими до нас звертаються:
Написати парсер самому не вийшло або шкода витрачати час — ми візьмемо завдання під ключ. Опишіть що потрібно зібрати, звідки і в якому форматі — оцінимо терміни та вартість.
Парсер працює, але блокується після N записів — це вирішується. Ротація проксі, фінгерпринтинг, управління сесіями — саме на таких завданнях спеціалізуємося. Звертайтеся.
Зв'яжіться з нами