feat(22d): domrf_catalog.py scraper for per-flat price/status from SSR HTML #309

Merged
lekss361 merged 1 commit from fix/22d-catalog-scraper into main 2026-05-17 15:40:02 +00:00
Owner

Summary

Issue #297 sub-task 22d (2d, Phase 5). NEW module backend/app/services/scrapers/domrf_catalog.py (563 lines) — scraper для DOM.РФ catalog-квартир SSR HTML (price/status/finishing per-flat).

kn-API не возвращает price для 91.5% квартир. Прайсы + finishing/ceiling/section живут на /сервисы/каталог-квартир/квартира/{hash} (SSR HTML).

Module API

  • fetch_catalog_html(session, catalog_url_hash) -> str — in-browser fetch через BrowserSession (тот же WAF bypass что get_json, returns text/html через _FETCH_HTML_JS snippet, auth=None для public pages)
  • parse_catalog_flat(html) -> dict — 2-level extraction: regex на raw HTML + stdlib html.parser blocks fallback. No new deps (beautifulsoup4 TODO в vault если structured parsing later)
  • upsert_catalog_data(db, ods_id, data)UPDATE domrf_kn_flats SET ... WHERE ods_id = :ods_id, COALESCE guards, SAVEPOINT per row, НЕ trog kn-API metadata
  • scrape_catalog_batch(db, flats) — batch orchestrator over single shared BrowserSession

Fields extracted

price_rub, price_per_m2, status, finishing_type, ceiling_height_m, section_no, catalog_updated_at

Тестовый flat d8c7a8103f26c52e427ace5a996706ba (Малевич obj=65136): 7 890 000 ₽, Предчистовая, 2.7 м, подъезд 1.

Implementation notes

  • _FETCH_HTML_JS — variant of stealth.py _FETCH_JS для HTML вместо JSON
  • auth=None — catalog pages public, не нужен Authorization: Basic
  • No new deps; stdlib html.parser достаточен для current extraction

Depends on (already merged)

  • PR #306 (22begh): добавил colonки section_no, finishing_type, ceiling_height_m, catalog_updated_at, catalog_url_hash в domrf_kn_flats — необходимы для UPSERT

Wiring TODO (separate PR)

  • backend/app/workers/tasks/scrape_catalog.py — Celery task
  • backend/app/workers/beat_schedule.py — beat entry (~30 days interval)

Test plan

  • ruff check backend/app/services/scrapers/domrf_catalog.py — passes
  • Manual smoke: scrape_catalog_batch(db, [{"ods_id": "65136/1/1.4.3", "catalog_url_hash": "d8c7a8103f26c52e427ace5a996706ba"}])

Related: issue #297 sub-task 22d · Phase 5 main feature · companion для 22c (flat_plans plan_image_url extraction)

## Summary Issue #297 sub-task **22d** (2d, Phase 5). NEW module `backend/app/services/scrapers/domrf_catalog.py` (563 lines) — scraper для DOM.РФ catalog-квартир SSR HTML (price/status/finishing per-flat). kn-API не возвращает price для 91.5% квартир. Прайсы + finishing/ceiling/section живут на `/сервисы/каталог-квартир/квартира/{hash}` (SSR HTML). ## Module API - `fetch_catalog_html(session, catalog_url_hash) -> str` — in-browser fetch через `BrowserSession` (тот же WAF bypass что `get_json`, returns text/html через `_FETCH_HTML_JS` snippet, `auth=None` для public pages) - `parse_catalog_flat(html) -> dict` — 2-level extraction: regex на raw HTML + stdlib `html.parser` blocks fallback. **No new deps** (`beautifulsoup4` TODO в vault если structured parsing later) - `upsert_catalog_data(db, ods_id, data)` — `UPDATE domrf_kn_flats SET ... WHERE ods_id = :ods_id`, COALESCE guards, SAVEPOINT per row, **НЕ trog kn-API metadata** - `scrape_catalog_batch(db, flats)` — batch orchestrator over single shared BrowserSession ## Fields extracted `price_rub`, `price_per_m2`, `status`, `finishing_type`, `ceiling_height_m`, `section_no`, `catalog_updated_at` Тестовый flat `d8c7a8103f26c52e427ace5a996706ba` (Малевич obj=65136): 7 890 000 ₽, Предчистовая, 2.7 м, подъезд 1. ## Implementation notes - `_FETCH_HTML_JS` — variant of stealth.py `_FETCH_JS` для HTML вместо JSON - `auth=None` — catalog pages public, не нужен `Authorization: Basic` - No new deps; stdlib `html.parser` достаточен для current extraction ## Depends on (already merged) - **PR #306 (22begh)**: добавил colonки `section_no`, `finishing_type`, `ceiling_height_m`, `catalog_updated_at`, `catalog_url_hash` в `domrf_kn_flats` — необходимы для UPSERT ## Wiring TODO (separate PR) - `backend/app/workers/tasks/scrape_catalog.py` — Celery task - `backend/app/workers/beat_schedule.py` — beat entry (~30 days interval) ## Test plan - [ ] `ruff check backend/app/services/scrapers/domrf_catalog.py` — passes - [ ] Manual smoke: `scrape_catalog_batch(db, [{"ods_id": "65136/1/1.4.3", "catalog_url_hash": "d8c7a8103f26c52e427ace5a996706ba"}])` Related: issue #297 sub-task 22d · Phase 5 main feature · companion для 22c (flat_plans plan_image_url extraction)
lekss361 added 1 commit 2026-05-17 15:36:32 +00:00
Adds new scraper module for DOM.RF catalog-квартир SSR pages
(issue #297 Phase 5). kn-API returns price for only 0.3% of EKB flats;
prices + finishing/ceiling/section data live on per-flat HTML pages at
/сервисы/каталог-квартир/квартира/{hash}.

- fetch_catalog_html(): in-browser fetch via BrowserSession (same WAF
  bypass pattern as get_json, but returns text/html instead of JSON)
- parse_catalog_flat(): 2-level extraction via stdlib html.parser +
  regex on raw HTML (no new deps; beautifulsoup4 noted as TODO)
- upsert_catalog_data(): UPDATE domrf_kn_flats SET catalog-only cols
  with COALESCE guards; SAVEPOINT per row; does not touch kn-API metadata
- scrape_catalog_batch(): full batch orchestrator over one BrowserSession

Wiring (Celery task + beat schedule) deferred to a follow-up PR.
Depends on migration 22b (section_no, finishing_type, ceiling_height_m,
catalog_updated_at, catalog_url_hash columns) — also separate PR.

Closes #297 (partial — 22d code delivery)
lekss361 merged commit 4bed58b43c into main 2026-05-17 15:40:02 +00:00
Author
Owner

Deep code review — verdict APPROVE, merged (4bed58b4).

Findings:

  • WAF bypass корректный: _FETCH_HTML_JS — точная HTML-вариация _FETCH_JS из stealth.py (тот же credentials: 'include', тот же error envelope {ok,status,body,contentType}). Использует session._page.evaluate() через session._sem (concurrency 3) — идентично get_json.
  • Retry/backoff: 5 attempts с экспоненциальным 2**attempt, корректно ретраит на 429/5xx/0, fail-fast на 404. 404RuntimeError, не silent skip (правильно для batch — caller увидит fail в outcome).
  • Parser robustness: 2-level (regex на raw HTML + _TextCollector fallback) + sanity-bounds (1M ≤ price ≤ 500M, 2.0 ≤ ceiling ≤ 6.0) — best-effort, errors swallowed (logger.warning) → пустой dict вместо raise. Поля extraction defensive.
  • psycopg v3 / SQL: text(...) + named binds (:ods_id, etc.), без f-string, без ::type cast trap. SAVEPOINT через db.begin_nested() per-row, COALESCE-guards (повторный scrape не затирает заполненные поля).
  • auth=None для catalog (public pages) — корректно.
  • Cross-impact: новый модуль, нет callers (Celery wiring отложен на отдельный PR) → safe deploy. Колонки из миграции 113 (PR #306) уже на проде.
  • Vault: fixes/Fix_BUG22d_CatalogScraper_May17.md присутствует.

Nits (не блокирующее):

  • Регекс цены [\d][\d\s]{3,12}[\d] может матчить телефоны/площадь+₽-неподалёку — sanity-bounds спасают, но при дрейфе HTML возможны false positives. Recommend unit-тесты с fixture-HTML.
  • WafBlockedError импортирован паттерн в docstring, но fetch его не raise'ит — non-html content-type только warning logger. Может стоить strict-mode raise при <html от ServicePipe.

Follow-ups (отдельные PR):

  1. Smoke: parse_catalog_flat(html) на тестовом flat d8c7a8103f26c52e427ace5a996706ba (Малевич) — verify все 7 полей extracted.
  2. Wiring: backend/app/workers/tasks/scrape_catalog.py + beat schedule (~30 days interval).
  3. Unit-тесты для parser с fixed HTML samples (сейчас zero coverage парсера).
Deep code review — verdict APPROVE, merged (`4bed58b4`). **Findings:** - WAF bypass корректный: `_FETCH_HTML_JS` — точная HTML-вариация `_FETCH_JS` из `stealth.py` (тот же `credentials: 'include'`, тот же error envelope `{ok,status,body,contentType}`). Использует `session._page.evaluate()` через `session._sem` (concurrency 3) — идентично `get_json`. - Retry/backoff: 5 attempts с экспоненциальным `2**attempt`, корректно ретраит на `429/5xx/0`, fail-fast на `404`. `404` → `RuntimeError`, не silent skip (правильно для batch — caller увидит fail в `outcome`). - Parser robustness: 2-level (regex на raw HTML + `_TextCollector` fallback) + sanity-bounds (`1M ≤ price ≤ 500M`, `2.0 ≤ ceiling ≤ 6.0`) — best-effort, errors swallowed (logger.warning) → пустой dict вместо raise. Поля extraction defensive. - psycopg v3 / SQL: `text(...)` + named binds (`:ods_id`, etc.), без f-string, без `::type` cast trap. SAVEPOINT через `db.begin_nested()` per-row, COALESCE-guards (повторный scrape не затирает заполненные поля). - `auth=None` для catalog (public pages) — корректно. - Cross-impact: новый модуль, нет callers (Celery wiring отложен на отдельный PR) → safe deploy. Колонки из миграции 113 (PR #306) уже на проде. - Vault: `fixes/Fix_BUG22d_CatalogScraper_May17.md` присутствует. **Nits (не блокирующее):** - Регекс цены `[\d][\d\s]{3,12}[\d]` может матчить телефоны/площадь+₽-неподалёку — sanity-bounds спасают, но при дрейфе HTML возможны false positives. Recommend unit-тесты с fixture-HTML. - `WafBlockedError` импортирован паттерн в docstring, но fetch его не raise'ит — non-html content-type только warning logger. Может стоить strict-mode raise при `<html` от ServicePipe. **Follow-ups (отдельные PR):** 1. Smoke: `parse_catalog_flat(html)` на тестовом flat `d8c7a8103f26c52e427ace5a996706ba` (Малевич) — verify все 7 полей extracted. 2. Wiring: `backend/app/workers/tasks/scrape_catalog.py` + beat schedule (~30 days interval). 3. Unit-тесты для parser с fixed HTML samples (сейчас zero coverage парсера).
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#309
No description provided.