Newer
Older
vmk-360-domria_parser / TECH_SPEC.md

Технічне завдання: Парсер оголошень DOM.RIA (dom.ria.com/uk/)

Версія: 3.0 (оновлено після реалізації MVP 2026-06-12) Статус: MVP реалізовано та протестовано


1. Мета та контекст

Створити сервіс-парсер, який збирає актуальні оголошення про нерухомість з порталу DOM.RIA (українська версія) та передає їх до локального сервісу data_collector (http://localhost:8020).

Важливо: на поточному етапі ми лише збираємо дані (one-shot або періодичний crawl). Інкрементальне оновлення, перевірка архівів та історія цін — не входять у MVP; ці задачі делеговані data_collector або відкладені (див. розділ 7).


2. Джерело даних — DOM.RIA

2.1 Вибраний метод: HTTP-скрейпинг через curl_cffi (імітація TLS + cookies)

Офіційний REST API (developers.ria.com) має жорсткі ліміти безкоштовного пакету: 30 запитів/годину, 1000/місяць. Цього недостатньо для обходу всієї України.

В ході дослідження знайдено робочий спосіб скрейпингу без браузера:

  • Бібліотека curl_cffi із параметром impersonate="chrome124" імітує повний TLS/JA3 fingerprint, заголовки та поведінку реального Chrome 124.
  • Потрібна requests.Session — перед запитом каталогу необхідно відкрити головну сторінку https://dom.ria.com/uk/, щоб отримати session-cookies.
  • Правильний формат URL/{operation}-{type}/{city}/ (наприклад, /uk/prodazha-kvartir/kiev/). URL із дефісом перед містом (prodazha-kvartir-kiev/) повертає 404.

2.2 Чому headless Chrome не підходить

undetected-chromedriver, Playwright (headless та non-headless) — усі повертають serverStatus: 404 і порожній каталог. Сайт використовує додатковий рівень детекції (ймовірно, IP/cookie-based + fingerprint), який блокує автоматизовані браузери навіть при повній емуляції. curl_cffi обходить цей бар’єр, оскільки не є браузером, а імітує його на мережевому рівні.

2.3 Що витягувати з відповіді

Після успішного запиту HTML-сторінки в тілі міститься inline-скрипт із глобальною змінною window.__INITIAL_STATE__:

  • Каталог: catalog.realtyForCatalog[] — масив об’єктів оголошень на поточній сторінці (20–27 шт.).
  • Детальна сторінка: listing.data.realty — повний об’єкт оголошення.
  • Лічильники: catalog.realtyCountCatalog — загальна кількість результатів.
  • Пагінація: контролюється через query-параметр ?page=N. Перевірено: page=1, page=2 — працюють.

Важливі нюанси структури даних (виявлені емпірично):

  • priceArr у каталозі — це dict {"1": "130 000", "2": "112 317", "3": "5 863 000"} (USD / EUR / UAH відповідно), а не список об’єктів.
  • price_USD часто відсутній у каталозі; детальна сторінка містить явний priceObj.priceUSD.
  • Фотографії в каталозі/деталі зберігаються як об’єкти з ключем file (відносний шлях на cdn.riastatic.com).
  • window.__INITIAL_STATE__ зустрічається у форматі window.__INITIAL_STATE__={...} без пробілів навколо =. Парсер повинен бути толерантним до цього.

2.4 URL-патерни для різних міст і категорій

Категорія / Операція Київ Львів Одеса Харків
Продаж квартир prodazha-kvartir/kiev/ prodazha-kvartir/lvov/ prodazha-kvartir/odessa/ prodazha-kvartir/kharkov/
Продаж будинків prodazha-domov/kiev/ prodazha-domov/lvov/ prodazha-domov/odessa/ prodazha-domov/kharkov/
Оренда квартир arenda-kvartir/kiev/ arenda-kvartir/lvov/ arenda-kvartir/odessa/ arenda-kvartir/kharkov/

Важливо: slug міста може відрізнятися від очікуваного (наприклад, lvov замість lviv, odessa замість odesa, kharkov замість kharkiv). Повний перелік слід витягти з головної сторінки або sitemap.

2.5 Альтернативні / резервні шляхи

  1. Офіційний API як fallback. Для невеликих тестових вибірок або відлагодження — developers.ria.com.
  2. Внутрішній API search-engine. Якщо curl_cffi перестане працювати, можна дослідити endpoint /uk/search-engine/ (вимагає apiKey з __INITIAL_STATE__).

3. Область збору (scope)

3.1 Географія

Вся Україна. Парсер повинен обходити всі доступні міста/області, а не обмежуватися Києвом.

3.2 Категорії та операції

category Тип об’єкта operation_type
1 Квартири / кімнати 1 — Продаж, 2 — Довгострокова оренда, 3 — Подобова оренда
2 Будинки / дачі / котеджі 1, 2, 3
3 Земельні ділянки 1, 2
4 Комерційна нерухомість 1, 2, 3
5 Гаражі / парковки 1, 2

Для кожної комбінації category × realty_type × operation_type × city_id формується окремий crawl.

3.3 Поля для збору

Зібрати максимально можливий набір полів. Прості та універсальні поля нормалізувати; рідкісні, вкладені або змінні — залишити в сирому вигляді в підоб’єкті characteristics_raw.

Нормалізовані поля (recommended)

Поле Джерело в __INITIAL_STATE__ Тип
external_id listing.data.realty_id string
url Сформувати з beautiful_url або шаблону string
title advert_title (якщо є) або згенерувати з адреси string
operation advert_type_name string (продаж / оренда)
category realty_type_parent_name string
realty_type realty_type_name string
price.amount price або price_total number
price.currency currency_type string
price.price_per_sqm price_item number
address.city city_name string
address.district district_name string
address.street street_name string
address.building building_number_str string
location.lat latitude number
location.lon longitude number
area.total total_square_meters number
area.living living_square_meters number
area.kitchen kitchen_square_meters number
area.plot ares_count або lot_unit (для будинків/землі) number
rooms rooms_count number
floor.current floor number
floor.total floors_count number
year_built characteristics_values[443] (з мапінгом) number
wall_type wall_type (текст) або characteristics_values[118/149] string
offer_type characteristics_values[1437] (з мапінгом) string
description description_uk (пріоритет) або description string
photos[] photos[].file з префіксом xl перед .jpg string[]
contact.name user.name string
published_at publishing_date ISO8601
expires_at date_end ISO8601
created_at created_at ISO8601
inspected inspected boolean
verified inspected_at присутній boolean

Сирі дані (raw)

Весь об’єкт characteristics_values (ID → значення) залишити в characteristics_raw для downstream-аналітики, якщо data_collector або аналітик вирішить розпарсити додаткові поля.


4. Отримувач даних — data_collector

Локальний сервіс FastAPI (http://localhost:8020). Відкрита документація: /docs, OpenAPI: /openapi.json.

4.1 Endpoint’и

Endpoint Метод Призначення для парсера
POST /api/v1/ingest Ingest Головний. Приймає "сирий" запис про оголошення.
POST /api/v1/listings/{listing_id}/archive-check Archive Check Відкладено до майбутнього апдейту (див. розділ 7).
GET /api/v1/health Health Перевірка доступності сервісу перед роботою.
GET /metrics Metrics Prometheus-метрики (моніторинг).

4.2 Модель RawDataIngestRequest

{
  "source_slug": "dom_ria",
  "external_id": "13825265",
  "payload": { }
}
  • source_slug — ідентифікатор джерела. Константа: dom_ria.
  • external_id — унікальний ID оголошення на DOM.RIA (realty_id).
  • payload — змішана структура: нормалізовані полі + сирі дані.

4.3 Модель IngestResponse

{
  "job_id": 1,
  "property_id": 123,
  "status": "accepted",
  "reason": null,
  "message": "...",
  "snapshot_id": 1
}
  • job_id — логувати для трасування.
  • snapshot_iddata_collector сам відстежує зміни між версіями.

5. Функціональні вимоги до парсера

5.1 Обхід каталогу (пагінація)

  1. Для кожної комбінації фільтрів (category, realty_type, operation, city_id) відкрити сторінку каталогу.
  2. Дочекатися повного JS-рендерингу (DOM готовий, __INITIAL_STATE__ заповнений).
  3. Витягти масив catalog.realtyForCatalog.
  4. Визначити загальну кількість сторінок через catalog.realtyCountCatalog та catalog.nextPageRealtyCount.
  5. Перейти на наступну сторінку (URL ?page=N, клік "Далі", або нескінченний скролл — з’ясувати емпірично).
  6. Для кожного оголошення в списку перейти на детальну сторінку та витягти listing.data.

5.2 Rate limiting та етика навантаження

  • Між сторінками: мінімум 10 секунд фіксованої затримки.
  • Між детальними сторінками: мінімум 10 секунд.
  • Перерви: після кожних 50 сторінок — пауза 2 хвилини (120 секунд).
  • Нічний режим: не обов’язково, але краще не штормити в години пік (09:00–18:00).
  • Retry: при 5xx, таймаутах або порожній відповіді — exponential backoff (1с, 2с, 4с, 8с), макс. 3 спроби.
  • Помилка на одному оголошенні не зупиняє обхід.

5.3 Ідемпотентність

  • Кожне оголошення обробляється незалежно.
  • Повторний запуск з тим самим фільтром не ламає data_collector — сервіс сам створює новий snapshot_id, якщо payload змінився.

6. Архітектура та компоненти

┌─────────────────┐     Браузерний рендеринг      ┌──────────────┐
│   Crawl Runner  │  ───────────────────────────>  │ dom.ria.com  │
│   (Python       │  <───────────────────────────   │   (JS SPA)   │
│    script)      │        __INITIAL_STATE__        └──────────────┘
└────────┬────────┘
         │
         │ POST /api/v1/ingest
         v
┌─────────────────┐
│  data_collector │
│ (localhost:8020)│
└─────────────────┘

6.1 Рекомендований стек

Компонент Бібліотека Призначення
HTTP-скрейпинг curl_cffi (requests.Session) Імітація TLS fingerprint Chrome + session cookies
Парсинг HTML/JSON beautifulsoup4 + lxml Пошук window.__INITIAL_STATE__ у відповіді
HTTP-клієнт (якщо API розкриється) httpx (async) Швидкі запити без браузера
Конфігурація pydantic-settings + .env DATA_COLLECTOR_URL, DELAY_MIN, DELAY_MAX, CITY_SLUGS
Логування structlog + rich Читабельні логи з прогресом
База стану (опціонально) SQLite Зберігання external_id, last_seen_at, page для resume
Планувальник systemd timer / cron / ручний запуск MVP — ручний запуск, потім таймер

6.2 Приклад payload для data_collector

{
  "source_slug": "dom_ria",
  "external_id": "13825265",
  "payload": {
    "url": "https://dom.ria.com/uk/realty-perevireno-prodaja-kvartira-kiev-goloseevskiy-onufriya-trutenko-ulitsa-13825265.html",
    "title": "Продаж квартири, Київ, Голосіївський, вул. Онуфрія Трутенка",
    "operation": "продажа",
    "category": "Квартири",
    "realty_type": "Квартира",
    "price": {
      "amount": 60000,
      "currency": "$",
      "total": 60000,
      "price_per_sqm": 645
    },
    "address": {
      "city": "Київ",
      "district": "Голосеевский",
      "street": "Онуфрія Трутенка вулиця",
      "building": "3 Г"
    },
    "location": {
      "lat": 50.39161687881484,
      "lon": 30.480281005166944
    },
    "area": {
      "total": 93,
      "living": 50,
      "kitchen": 20
    },
    "rooms": 2,
    "floor": { "current": 23, "total": 23 },
    "year_built": 2015,
    "wall_type": "кирпич",
    "offer_type": "власник",
    "description": "Світла квартира...",
    "photos": [
      "https://cdn.riastatic.com/photos/dom/photo/8070/807045/80704539/80704539xl.jpg"
    ],
    "contact": { "name": "Владимир" },
    "published_at": "2017-10-02T14:51:07",
    "expires_at": "2018-03-02T14:51:07",
    "created_at": "2017-09-20T21:12:42",
    "inspected": true,
    "verified": true,
    "characteristics_raw": {
      "118": 108,
      "209": 2,
      "214": 93,
      "216": 50,
      "218": 20,
      "227": 23,
      "228": 23,
      "234": 60000,
      "242": 239,
      "443": 1449,
      "1437": 1436,
      "...": "..."
    },
    "_raw_listing": { }
  }
}

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


7. Відкриті питання та TODO (Future Updates)

# Задача Пріоритет Примітки
1 Повний перелік slug міст 🟡 Дослідження Потрібно витягти всі доступні city-slug з головної сторінки / sitemap / меню. Перевірено: kiev, lvov, odessa, kharkov, dnepr, cherkassy, ivano-frankovsk, lutsk, nikolaev, khmelnytskyi.
2 Пагінація каталогу 🟢 Рішено Працює через ?page=N. Перевірено до page=2.
3 Земля, комерція, гаражі 🟡 Дослідження URL prodazha-zemli/kiev/, prodazha-kommercheskoj-nedvizhimosti/kiev/, prodazha-garazhej/kiev/ повертають 404. Можливо, для цих категорій slug інший або вони недоступні в окремому розділі.
4 Архівація (archive-check) 🟢 Future Реалізувати після налагодження основного crawl. Порівнювати last_seen_at з поточним запуском та викликати POST /api/v1/listings/{id}/archive-check.
5 Історія цін / статистика 🟢 Future Залишається на боці data_collector (він створює snapshot_id при зміні payload).
6 Паралелізація 🟢 Future На поточному етапі — послідовний crawl. Паралельність може підвищити ризик блокування.
7 Моніторинг curl_cffi 🟡 Дослідження Якщо DOM.RIA почне блокувати curl_cffi, потрібен fallback на інший impersonate (chrome123, safari) або на справжній браузер.

8. Нефункціональні вимоги

  • Конфіденційність: жодних ключів або паролів не комітити в репозиторій; використовувати .env.
  • Логування: кожна сторінка та кожне оголошення логуються з timestamp, URL, статусом (ok, error, skipped), часом обробки.
  • Resume: підтримка перезапуску з місця зупинки (зберігання поточного page / city_id / external_id в SQLite).
  • Тестованість: моки для data_collector; локальні HTML-файли з __INITIAL_STATE__ для unit-тестів парсера.
  • Docker: підготувати Dockerfile з curl_cffi (залежить лише від libcurl, легкий образ).

9. Етапи реалізації (оновлені)

Етап Термін Що робимо
PoC ✅ Виконано Досліджено та знайдено робочий спосіб: curl_cffi + impersonate=chrome124 + session cookies + URL /{op}-{type}/{city}/. Перевірено каталог та детальну сторінку.
MVP Crawl ✅ Реалізовано Скрипт на curl_cffi: обхід 1 міста + 1 категорії (Київ, квартири, продаж). Пагінація (?page=N), детальні сторінки, нормалізація payload, інжест у data_collector. Протестовано: 23 оголошення успішно зібрано та надіслано.
Full Scope 🔄 Наступний етап Розширити на всі підтверджені міста та категорії. Додати конфігурацію slug’ів. Rate limiting (10с між сторінками, 2 хв кожні 50). Resume з SQLite вже реалізовано.
Polish 🔄 Наступний етап Тести, обробка крайових випадків (404 на детальній, зняті оголошення), Docker-образ, моніторинг.

10. Джерела та матеріали