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

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

---

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

Створити сервіс-парсер, який збирає актуальні оголошення про нерухомість з порталу [DOM.RIA](https://dom.ria.com/uk/) (українська версія) та передає їх до локального сервісу **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`

```json
{
  "source_slug": "dom_ria",
  "external_id": "13825265",
  "payload": { }
}
```

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

### 4.3 Модель `IngestResponse`

```json
{
  "job_id": 1,
  "property_id": 123,
  "status": "accepted",
  "reason": null,
  "message": "...",
  "snapshot_id": 1
}
```

- `job_id` — логувати для трасування.
- `snapshot_id` — `data_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`

```json
{
  "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. Джерела та матеріали

- [Офіційна документація DOM.RIA API](https://developers.ria.com/docs/)
- [Неофіційна документація (GitHub)](https://github.com/yaroslavshostak/api_docs/tree/master/dom_ria)
- [curl_cffi](https://github.com/lexiforest/curl_cffi) — HTTP-клієнт з імітацією TLS fingerprint
- [undetected-chromedriver](https://github.com/ultrafunkamsterdam/undetected-chromedriver) — досліджувався, але не спрацював
- [Playwright](https://playwright.dev/python/) — досліджувався, але не спрацював
- OpenAPI `data_collector`: `http://localhost:8020/openapi.json`
