feat(tradein): avito_detail.py — detail page parser (30+ fields incl. domoteka) #443

Merged
lekss361 merged 1 commit from feat/tradein-avito-detail-parser into main 2026-05-23 12:17:15 +00:00
Owner

Summary

Stage 2b of Avito Scraper v2. NEW module avito_detail.py — парсер detail page Avito с extraction 30+ полей (apartment params, location, domoteka/Росреестр verification badges, gallery).

Closes гэп: search lots (Stage 2a) теперь enriched через detail page → точные lat/lon + kitchen_area + owners_count + encumbrances_clean + 25+ других полей.

LOC: avito_detail.py ~737 lines, test_avito_detail_parse.py 175 lines (10 tests).

Changes

  • NEW tradein-mvp/backend/app/services/scrapers/avito_detail.py
    • fetch_detail(item_url) — async HTTP via curl_cffi.AsyncSession (impersonate=chrome120)
    • parse_detail_html(html, source_url) — pure selectolax parser (fixture-testable, no network)
    • DetailEnrichment dataclass — 30+ fields:
      • Identity (item_id, title, price, publish_date, views_total/today)
      • Apartment params (rooms/area/floor/balcony_loggia/room_layout/bathroom_type/windows_view/repair_state/sale_type/mortgage_available + kitchen_area_m2)
      • Location (lat/lon/avito_location_id/metro_stations[]/address_full)
      • House params for retrofit (house_type/total_floors_house/lifts/concierge/closed_yard/house_catalog_url) — НЕ сохраняем в БД из 2b, canonical = Stage 2c
      • Description (full text)
      • Domoteka 5 fields (owners_count + owners_at_least + last_owner_change_date + encumbrances_clean + registry_match) — Росреестр verification badges
      • Gallery (photo_urls[])
    • save_detail_enrichment(db, e) — UPDATE listings SET 25+ cols WHERE source='avito' AND source_id, sets detail_enriched_at=NOW()
  • NEW tradein-mvp/backend/tests/test_avito_detail_parse.py (10 tests, all pass)
  • NEW tradein-mvp/backend/tests/__init__.py (needed для pytest package resolution)

Smoke

  • ruff clean
  • pytest: 10/10 passed (0.42s) — minimal HTML fixture validates ВСЕ 25+ полей extraction
  • ⏸ Network smoke deferred to deploy (Avito TLS impersonate требует prod env)

Behavior

Detail enrichment работает в pull mode — cron will call fetch_detail per listing (queue = listings WHERE detail_enriched_at IS NULL). Уже добавлен partial index listings_detail_enrich_idx в Stage 1.

Test plan

  • ruff
  • pytest unit
  • Deploy → trigger manual enrichment of one listing → verify detail_enriched_at IS NOT NULL + owners_count IS NOT NULL + kitchen_area_m2 IS NOT NULL
  • Stage 2c (avito_houses.py) — независимо параллельно (НЕ блокирует)
  • Stage 2e (orchestrator) — соединит search + detail + houses

Notes

  • _VIEWS_TODAY_RE адаптирован: spec был \+(\d+)\s+сегодня, fixture HTML содержит (+ 12 сегодня) с пробелом → regex обновлён на \+\s*(\d+)\s+сегодня (покрывает оба формата)
  • House-level fields (passenger_elevators, has_concierge etc) — собираются в DetailEnrichment но НЕ сохраняются в БД (canonical из Stage 2c Houses Catalog parser, избегаем конфликтов)
## Summary **Stage 2b of Avito Scraper v2**. NEW module `avito_detail.py` — парсер detail page Avito с extraction 30+ полей (apartment params, location, domoteka/Росреестр verification badges, gallery). Closes гэп: search lots (Stage 2a) теперь enriched через detail page → точные lat/lon + kitchen_area + owners_count + encumbrances_clean + 25+ других полей. LOC: `avito_detail.py` ~737 lines, `test_avito_detail_parse.py` 175 lines (10 tests). ## Changes - **NEW** `tradein-mvp/backend/app/services/scrapers/avito_detail.py` - `fetch_detail(item_url)` — async HTTP via `curl_cffi.AsyncSession` (impersonate=chrome120) - `parse_detail_html(html, source_url)` — pure selectolax parser (fixture-testable, no network) - `DetailEnrichment` dataclass — 30+ fields: - Identity (item_id, title, price, publish_date, views_total/today) - Apartment params (rooms/area/floor/balcony_loggia/room_layout/bathroom_type/windows_view/repair_state/sale_type/mortgage_available + **kitchen_area_m2**) - Location (lat/lon/avito_location_id/metro_stations[]/address_full) - House params for retrofit (house_type/total_floors_house/lifts/concierge/closed_yard/house_catalog_url) — НЕ сохраняем в БД из 2b, canonical = Stage 2c - Description (full text) - **Domoteka 5 fields** (`owners_count` + `owners_at_least` + `last_owner_change_date` + `encumbrances_clean` + `registry_match`) — Росреестр verification badges - Gallery (photo_urls[]) - `save_detail_enrichment(db, e)` — UPDATE listings SET 25+ cols WHERE source='avito' AND source_id, sets `detail_enriched_at=NOW()` - **NEW** `tradein-mvp/backend/tests/test_avito_detail_parse.py` (10 tests, all pass) - **NEW** `tradein-mvp/backend/tests/__init__.py` (needed для pytest package resolution) ## Smoke - ✅ ruff clean - ✅ pytest: 10/10 passed (0.42s) — minimal HTML fixture validates ВСЕ 25+ полей extraction - ⏸ Network smoke deferred to deploy (Avito TLS impersonate требует prod env) ## Behavior Detail enrichment работает в pull mode — cron will call `fetch_detail` per listing (queue = `listings WHERE detail_enriched_at IS NULL`). Уже добавлен partial index `listings_detail_enrich_idx` в Stage 1. ## Test plan - [x] ruff - [x] pytest unit - [ ] Deploy → trigger manual enrichment of one listing → verify `detail_enriched_at IS NOT NULL` + `owners_count IS NOT NULL` + `kitchen_area_m2 IS NOT NULL` - [ ] Stage 2c (avito_houses.py) — независимо параллельно (НЕ блокирует) - [ ] Stage 2e (orchestrator) — соединит search + detail + houses ## Notes - `_VIEWS_TODAY_RE` адаптирован: spec был `\+(\d+)\s+сегодня`, fixture HTML содержит `(+ 12 сегодня)` с пробелом → regex обновлён на `\+\s*(\d+)\s+сегодня` (покрывает оба формата) - House-level fields (passenger_elevators, has_concierge etc) — собираются в DetailEnrichment но НЕ сохраняются в БД (canonical из Stage 2c Houses Catalog parser, избегаем конфликтов)
lekss361 added 1 commit 2026-05-23 12:11:46 +00:00
Stage 2b of AvitoScraper_v2.

- fetch_detail(item_url) — async HTTP via curl_cffi.AsyncSession (chrome120)
- parse_detail_html(html, source_url) — pure selectolax parser (fixture-testable)
- DetailEnrichment dataclass — 30+ fields:
  * Identity (item_id, title, price, publish_date, views)
  * Apartment params (rooms/area/floor/balcony_loggia/room_layout/bathroom/windows/repair/sale_type/mortgage)
  * Location (lat/lon/avito_location_id/metro_stations[]/address_full)
  * House params (house_type/total_floors_house/lifts/concierge/closed_yard/house_catalog_url)
  * Description
  * Domoteka 5 fields (owners_count + owners_at_least + last_owner_change_date + encumbrances_clean + registry_match)
  * Gallery (photo_urls[])
- save_detail_enrichment(db, e) — UPDATE listings SET 25+ cols WHERE source_id, sets detail_enriched_at=NOW()
- pytest test_avito_detail_parse — 10 fixture tests, all passing
lekss361 merged commit 7cdde30b13 into main 2026-05-23 12:17:15 +00:00
Author
Owner

Deep code review verdict: APPROVE — merged via squash.

Scope

  • 3 files, +912/-0. P1 (scraper service + tests).
  • Stage 2b of Avito Scraper v2 — pure parser + UPDATE-side save.

Cross-file impact

  • Schema: все 25 колонок UPDATE существуют в 011_listings_alter.sql (kitchen_area_m2, owners_count, owners_at_least, metro_stations jsonb, detail_enriched_at timestamptz, encumbrances_clean, registry_match, ...).
  • House-level поля (passenger_elevators, has_concierge, closed_yard, total_floors_house, house_type) собираются в dataclass но НЕ в UPDATE — корректное разделение с Stage 2c Houses Catalog.
  • Stage 2a avito.py интенциально пишет lat=lon=None для search lots (C-5 fix) — Stage 2b detail UPDATE заполняет. Pipeline coherent.
  • Legacy listings с jittered ±1km coords из pre-PR версии получат корректные lat/lon из detail (incidental fix).
  • PR #444 __init__.py — оба файла empty (hash e69de29), git auto-merge dedupe → no conflict.

Security

  • Все 27 bind params через :param dict (psycopg v3 safe).
  • photo_urls собираются но НЕ сохраняются в БД (UPDATE clause без photo_urls) — sidesteps PR #435 follow-up XSS concern для photo URLs.
  • house_catalog_url собирается в dataclass но НЕ в save — нет SSRF surface через urljoin(AVITO_BASE, untrusted_href) (защита нужна когда Stage 2c будет писать).

Correctness

  • Pure parser (parse_detail_html) отделён от network (fetch_detail) — fixture-testable, 10/10 pytest pass per PR описанию.
  • COALESCE pattern в UPDATE предохраняет existing values от перезаписи на None.
  • Domoteka 5 fields корректно мапятся per Schema sec 29 (owners_count + owners_at_least заменяют owners_2plus).
  • _VIEWS_TODAY_RE flex на whitespace покрывает +12, + 12, (+12 сегодня), (+ 12 сегодня).
  • Graceful None для missing selectors (_text returns None, _try_float/int returns None on parse fail).
  • Selectors based on data-marker атрибутах (Avito-stable) — anti-bot resilient (НЕ хрупкие CSS classes).

Minor non-blocking (follow-up идеи, не блокируют merge)

  1. db.commit() внутри save_detail_enrichment — не позволяет batching по N listings (caller заинтересован в per-listing commit, OK для MVP, но в Stage 2e orchestrator может потребоваться overrride).
  2. import datetime локально внутри _extract_meta дублирует top-level from datetime import date — cosmetic refactor.
  3. Нет UNIQUE constraint на listings(source, source_id) (dedup via dedup_hash). UPDATE WHERE source AND source_id теоретически может match >1 row если дубликаты — на практике Avito source_id уникален. Future invariant constraint = good hygiene.
  4. publish_date year = today().year — некорректно на Jan-1 boundary для Dec-31 объявлений. Acceptable для MVP.

Pre-merge checks

  • mergeable: true
  • state: open, not draft
  • head SHA unchanged между scan и merge
  • no CI (workflow только build/deploy) — pytest/ruff trusted от PR описания.

Post-merge

  • deploy-tradein.yml триггернётся на push в main (paths tradein-mvp/**) → build + SSH deploy.
  • pytest НЕ в CI — ruff/pytest защита через pre-commit (local).
  • Verify после Stage 2e orchestrator merge: trigger manual enrichment одного listing → SELECT detail_enriched_at, owners_count, kitchen_area_m2 FROM listings WHERE source='avito' ORDER BY detail_enriched_at DESC NULLS LAST LIMIT 1.

Coordination

  • PR #444 (avito_imv.py) — оба добавляют empty tests/__init__.py. Merge order безразличен — git считает empty blob = одинаковый, auto-merge тривиальный.
Deep code review verdict: APPROVE — merged via squash. ## Scope - 3 files, +912/-0. P1 (scraper service + tests). - Stage 2b of Avito Scraper v2 — pure parser + UPDATE-side save. ## Cross-file impact - Schema: все 25 колонок UPDATE существуют в `011_listings_alter.sql` (`kitchen_area_m2`, `owners_count`, `owners_at_least`, `metro_stations jsonb`, `detail_enriched_at timestamptz`, `encumbrances_clean`, `registry_match`, ...). ✅ - House-level поля (`passenger_elevators`, `has_concierge`, `closed_yard`, `total_floors_house`, `house_type`) собираются в dataclass но НЕ в UPDATE — корректное разделение с Stage 2c Houses Catalog. ✅ - Stage 2a `avito.py` интенциально пишет `lat=lon=None` для search lots (C-5 fix) — Stage 2b detail UPDATE заполняет. Pipeline coherent. ✅ - Legacy listings с jittered ±1km coords из pre-PR версии получат корректные lat/lon из detail (incidental fix). - PR #444 `__init__.py` — оба файла empty (hash `e69de29`), git auto-merge dedupe → no conflict. ## Security - Все 27 bind params через `:param` dict (psycopg v3 safe). ✅ - `photo_urls` собираются но НЕ сохраняются в БД (UPDATE clause без `photo_urls`) — sidesteps PR #435 follow-up XSS concern для photo URLs. - `house_catalog_url` собирается в dataclass но НЕ в save — нет SSRF surface через `urljoin(AVITO_BASE, untrusted_href)` (защита нужна когда Stage 2c будет писать). ## Correctness - Pure parser (`parse_detail_html`) отделён от network (`fetch_detail`) — fixture-testable, 10/10 pytest pass per PR описанию. ✅ - COALESCE pattern в UPDATE предохраняет existing values от перезаписи на None. - Domoteka 5 fields корректно мапятся per Schema sec 29 (owners_count + owners_at_least заменяют owners_2plus). ✅ - `_VIEWS_TODAY_RE` flex на whitespace покрывает `+12`, `+ 12`, `(+12 сегодня)`, `(+ 12 сегодня)`. ✅ - Graceful None для missing selectors (`_text` returns None, `_try_float/int` returns None on parse fail). ✅ - Selectors based on `data-marker` атрибутах (Avito-stable) — anti-bot resilient (НЕ хрупкие CSS classes). ## Minor non-blocking (follow-up идеи, не блокируют merge) 1. `db.commit()` внутри `save_detail_enrichment` — не позволяет batching по N listings (caller заинтересован в per-listing commit, OK для MVP, но в Stage 2e orchestrator может потребоваться overrride). 2. `import datetime` локально внутри `_extract_meta` дублирует top-level `from datetime import date` — cosmetic refactor. 3. Нет UNIQUE constraint на `listings(source, source_id)` (dedup via `dedup_hash`). UPDATE WHERE source AND source_id теоретически может match >1 row если дубликаты — на практике Avito source_id уникален. Future invariant constraint = good hygiene. 4. `publish_date` year = `today().year` — некорректно на Jan-1 boundary для Dec-31 объявлений. Acceptable для MVP. ## Pre-merge checks - mergeable: true ✅ - state: open, not draft ✅ - head SHA unchanged между scan и merge ✅ - no CI (workflow только build/deploy) — pytest/ruff trusted от PR описания. ## Post-merge - `deploy-tradein.yml` триггернётся на push в main (paths `tradein-mvp/**`) → build + SSH deploy. - pytest НЕ в CI — ruff/pytest защита через pre-commit (local). - Verify после Stage 2e orchestrator merge: trigger manual enrichment одного listing → `SELECT detail_enriched_at, owners_count, kitchen_area_m2 FROM listings WHERE source='avito' ORDER BY detail_enriched_at DESC NULLS LAST LIMIT 1`. ## Coordination - PR #444 (`avito_imv.py`) — оба добавляют empty `tests/__init__.py`. Merge order безразличен — git считает empty blob = одинаковый, auto-merge тривиальный.
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#443
No description provided.