refactor(tradein): avito search — remove jitter (C-5) + mobile API dead code + parse houseLinks #441

Merged
lekss361 merged 1 commit from feat/tradein-avito-search-refactor into main 2026-05-23 11:55:19 +00:00
Owner

Summary

Stage 2a of Avito Scraper v2. Closes C-5 silent corruption (anchor+jitter в search-карточках → точечная маркировка), удаляет ~200 LOC мёртвого кода (mobile API Strategy A, __initialState__ extraction), добавляет parse /catalog/houses/... для связи listing → house (Stage 2c/3).

LOC delta: -232 / +57 (net −175 lines).

Changes

tradein-mvp/backend/app/services/scrapers/avito.py

  • DELETE _fetch_via_mobile_api + _mobile_item_to_lot (~95 LOC, H-6 dead — mobile API endpoint 404 since long)
  • DELETE _RE_AVITO_STATE + _extract_avito_state + _items_from_state (state empty since 2025)
  • DELETE _state_item_to_lot (unused after state path removal)
  • FIX C-5 в _dom_card_to_lot: lat=lon=None вместо jitter. Точные coords придут из avito_detail.py (Stage 2b) или geocode-missing cron подтянет из address
  • ADD parse <a href="/catalog/houses/...">house_source/house_ext_id/house_url
  • ADD listing_segment detection (vtorichka / novostroyki) из item URL pattern
  • CLEANUP: убран import json, import random (больше не используются)

tradein-mvp/backend/app/services/scrapers/base.py

  • EXTEND ScrapedLot (Pydantic): house_source, house_ext_id, house_url, listing_segment
  • EXTEND save_listings INSERT: 4 новых колонки + params (ON CONFLICT не меняем — house linking устаканивается на первом INSERT)

Smoke

  • ruff check app/services/scrapers/avito.py clean
  • Import test: from app.services.scrapers.avito import AvitoScraper OK
  • ⚠️ Network smoke deferred to deploy (Avito требует TLS impersonate, local env без VPN не reproducible)
  • Pre-existing 4 E501 в _USER_AGENTS строках base.py — НЕ тронуты (out of scope)

Behavioral changes

Before After
100% search lots с jittered coords (±1km error) 100% search lots c lat=lon=NULL
address fallback "Екатеринбург (Avito)" address=NULL если DOM не отдал
0% house linking ~82% lots с house_ext_id (по плану)
__initialState__ JSON parsing (мёртвый код) DOM-only scrape

После deploy geocode-missing cron подтянет coords из address (или deferred до Stage 2b detail enrichment даст точные).

Test plan

  • ruff clean
  • import test pass
  • Deploy → запустить scrape per anchor → verify lat=lon=NULL для всех новых listings (psql: SELECT count(*) FROM listings WHERE source='avito' AND scraped_at > NOW() - INTERVAL '1 hour' AND lat IS NULL)
  • Verify house_ext_id filled для большинства (psql: SELECT count(*) FILTER (WHERE house_ext_id IS NOT NULL)::float / count(*) FROM listings WHERE source='avito' AND scraped_at > NOW() - INTERVAL '1 hour')
  • Stage 2b/2c/2d unblocked после merge

Next

После merge → spawn 3 parallel workers (или sequentially per user preference) для Stage 2b (avito_detail.py), 2c (avito_houses.py), 2d (avito_imv.py).

## Summary Stage 2a of **Avito Scraper v2**. Closes **C-5 silent corruption** (anchor+jitter в search-карточках → точечная маркировка), удаляет ~200 LOC мёртвого кода (mobile API Strategy A, `__initialState__` extraction), добавляет parse `/catalog/houses/...` для связи listing → house (Stage 2c/3). LOC delta: `-232 / +57` (net **−175 lines**). ## Changes ### `tradein-mvp/backend/app/services/scrapers/avito.py` - **DELETE** `_fetch_via_mobile_api` + `_mobile_item_to_lot` (~95 LOC, H-6 dead — mobile API endpoint 404 since long) - **DELETE** `_RE_AVITO_STATE` + `_extract_avito_state` + `_items_from_state` (state empty since 2025) - **DELETE** `_state_item_to_lot` (unused after state path removal) - **FIX C-5** в `_dom_card_to_lot`: `lat=lon=None` вместо jitter. Точные coords придут из `avito_detail.py` (Stage 2b) или `geocode-missing` cron подтянет из `address` - **ADD** parse `<a href="/catalog/houses/...">` → `house_source`/`house_ext_id`/`house_url` - **ADD** `listing_segment` detection (`vtorichka` / `novostroyki`) из item URL pattern - **CLEANUP**: убран `import json`, `import random` (больше не используются) ### `tradein-mvp/backend/app/services/scrapers/base.py` - **EXTEND** `ScrapedLot` (Pydantic): `house_source`, `house_ext_id`, `house_url`, `listing_segment` - **EXTEND** `save_listings` INSERT: 4 новых колонки + params (ON CONFLICT не меняем — house linking устаканивается на первом INSERT) ## Smoke - ✅ `ruff check app/services/scrapers/avito.py` clean - ✅ Import test: `from app.services.scrapers.avito import AvitoScraper` OK - ⚠️ Network smoke deferred to deploy (Avito требует TLS impersonate, local env без VPN не reproducible) - Pre-existing 4 E501 в `_USER_AGENTS` строках `base.py` — НЕ тронуты (out of scope) ## Behavioral changes | Before | After | |---|---| | 100% search lots с jittered coords (±1km error) | 100% search lots c `lat=lon=NULL` | | address fallback "Екатеринбург (Avito)" | address=NULL если DOM не отдал | | 0% house linking | ~82% lots с `house_ext_id` (по плану) | | `__initialState__` JSON parsing (мёртвый код) | DOM-only scrape | После deploy `geocode-missing` cron подтянет coords из `address` (или deferred до Stage 2b detail enrichment даст точные). ## Test plan - [x] ruff clean - [x] import test pass - [ ] Deploy → запустить scrape per anchor → verify `lat=lon=NULL` для всех новых listings (psql: `SELECT count(*) FROM listings WHERE source='avito' AND scraped_at > NOW() - INTERVAL '1 hour' AND lat IS NULL`) - [ ] Verify `house_ext_id` filled для большинства (psql: `SELECT count(*) FILTER (WHERE house_ext_id IS NOT NULL)::float / count(*) FROM listings WHERE source='avito' AND scraped_at > NOW() - INTERVAL '1 hour'`) - [ ] Stage 2b/2c/2d unblocked после merge ## Next После merge → spawn 3 parallel workers (или sequentially per user preference) для Stage 2b (`avito_detail.py`), 2c (`avito_houses.py`), 2d (`avito_imv.py`).
lekss361 added 1 commit 2026-05-23 11:49:55 +00:00
- DELETE _fetch_via_mobile_api + _mobile_item_to_lot (200 LOC dead code, H-6 audit)
- DELETE _RE_AVITO_STATE + _extract_avito_state + _items_from_state (state empty since 2025)
- DELETE _state_item_to_lot (unused after state path removal)
- FIX C-5: _dom_card_to_lot now sets lat=lon=None (no anchor+jitter silent corruption)
  Точные coords придут из avito_detail.py (Stage 2b) или geocode-missing cron
- ADD parse <a href="/catalog/houses/..."> -> house_source/house_ext_id/house_url
- ADD listing_segment detection (vtorichka/novostroyki) from item URL pattern
- EXTEND ScrapedLot (base.py): 4 новых поля
- EXTEND save_listings INSERT: 4 новых колонок

Refs: Stage 2a of AvitoScraper_v2 plan.
Author
Owner

Deep Code Review — verdict APPROVE

Status: APPROVE (merge)
Files reviewed: 2 (P1: tradein-mvp/backend/app/services/scrapers/avito.py, P1: .../base.py)
Lines: +57 / -232 (net −175)

Critical (BLOCK) — none

High — none

Medium (follow-up, не блокирующее)

  1. save_listings ON CONFLICT не обновляет house linking (base.py:224-229)

    ON CONFLICT (dedup_hash) DO UPDATE
        SET last_seen_at = NOW(), is_active = true,
            price_rub = EXCLUDED.price_rub,
            price_per_m2 = EXCLUDED.price_per_m2
    

    Existing listings (insert'нутые до этого PR) с house_ext_id=NULL так и останутся NULL при re-scrape — Avito показывает же объявление, dedup_hash совпадает, обновляем только price.
    Fix follow-up PR: добавить house_source = COALESCE(listings.house_source, EXCLUDED.house_source) и аналогично для house_ext_id/house_url/listing_segment. Это backfill через первый same-listing rescrape.

  2. House regex pattern strict enough? (avito.py:178)
    parts[-1].isdigit() — корректно отсеивает /catalog/houses/about/, но любой numeric последний segment пройдёт. Avito может ссылаться на /catalog/houses/info/2026 (год) если такая страница появится. Низкий риск, но мониторь SELECT count(*) FROM listings WHERE house_ext_id ~ '^(19|20)\d{2}$' после deploy — если >0%, regex нужно ужесточить (например min 5 digits).

  3. Pre-existing 4 E501 в base.py _USER_AGENTS — out of scope, ок, но в follow-up cleanup PR.

Low / nits — none

Cross-file impact analysis

  • ScrapedLot Pydantic extension: 4 новых поля все Optional[str] = None (base.py:81-84) → backward compat OK. Verified all callers used named kwargs:
    • avito.py:190 — заполняет новые поля
    • cian.py:232, 363 — не использует, defaults None корректно
    • yandex_realty.py:211 — не использует, defaults None
    • n1.py:182 — не использует, defaults None
  • save_listings SQL: psycopg v3 named params (:house_ext_id etc), placeholders count matches column count, типы text — корректно соответствуют schema 011_listings_alter.sql:22-26.
  • Deleted symbols (_fetch_via_mobile_api, _mobile_item_to_lot, _extract_avito_state, _items_from_state, _state_item_to_lot, _RE_AVITO_STATE): grep по tradein-mvp/backend/0 lingering references. _items_from_state существует отдельно в yandex_realty.py:261 (другая функция, не conflict). import json/import random — больше не используются в avito.py, удалены корректно.

Blast radius (NULL coords) — APPROVED

Critical discovery: estimator.py:379 УЖЕ исключает Avito из radius search (AND source <> 'avito') с комментарием:

Avito исключён из радиусного поиска: его «координаты» — это якорь cron-скрейпа ± jitter (DOM Avito реальных coords не отдаёт). Фейковые точки кучкуются на 5 якорях и вытесняют реальные cian/yandex/n1.

Значит:

  • До PR: Avito JITTER → отсекался WHERE source <> 'avito', oценка работала без Avito.
  • После PR: Avito NULL → physically не попадает в ST_DWithin (NULL geom), всё равно не учитывается.
  • Чистый эффект для оценки = 0 регрессии, а после geocode-backfill (cron) Avito можно вернуть в estimator (follow-up PR, удалить AND source <> 'avito').
  • MapPicker frontend — used для user input clicks (не для display lots), null coords не крашат UI.

geocode-missing cron verification — PASS

  • Endpoint: app/api/v1/admin.py:139 POST /api/v1/admin/geocode-missing?limit=100&target=listings — exists, рабочий.
  • Cron schedule: deploy/cron-scrape.sh:86-96 — после каждого scrape запускает 15 чанков по 100 = до 1500 адресов с budget 240s/chunk.
  • Critical sub-finding: admin.py:158 extra_filter address NOT LIKE '%(Avito)%' — раньше СКИПАЛ jittered Avito строки с fallback address "Екатеринбург (Avito)". После PR address либо реальный (улица), либо NULL → cron WHERE COALESCE(address, '') != '' отфильтрует NULL, реальные адреса геокодируются. Filter NOT LIKE '%(Avito)%' теперь dead-code, но не вредит. OK.

Tradein DB consistency

  • Schema 011_listings_alter.sql:22-26 уже добавил все 4 column'а как nullable text + house_id_fk bigint REFERENCES houses(id) (FK). PR пишет house_ext_id (text), resolution text → FK house_id_fk через JOIN — Stage 2c. Pattern correct.
  • Partial index listings_house_ext_id_idx ON listings (house_source, house_ext_id) WHERE house_ext_id IS NOT NULL (011:65-66) — оптимально для Stage 2c lookup.

Performance findings

  • Нет N+1, нет new SELECT'ов на больших таблицах. save_listings per-row INSERT (как и до PR) — приемлемо, batch size небольшой (50 max per scrape).

Positive observations

  • Корректно удалён ~200 LOC dead code (mobile API + state parsing).
  • ScrapedLot extension с Optional defaults сохраняет backward compat.
  • Address fallback "Екатеринбург (Avito)" удалён — допускает geocode-missing cron подхватить реальные адреса.
  • House linking через data-marker="item" ancestor scope корректен (не глобальный селектор).
  • import json, import random правильно убраны.
  • _state_item_to_lot deleted вместе с парс-логикой — без orphan функции.

Post-merge smoke checklist

  1. Deploy.yml triggered (paths matches tradein-mvp/backend/** ✓ verified .forgejo/workflows/deploy-tradein.yml:36)
  2. После next scrape per anchor:
    SELECT count(*) FROM listings
    WHERE source='avito' AND scraped_at > NOW() - INTERVAL '1 hour' AND lat IS NULL;
    -- expect: ~50 per anchor
    
    SELECT count(*) FILTER (WHERE house_ext_id IS NOT NULL)::float / NULLIF(count(*),0)
    FROM listings
    WHERE source='avito' AND scraped_at > NOW() - INTERVAL '1 hour';
    -- expect: ~0.7-0.85 (по плану 82%)
    
  3. После geocode-missing cron run (через ~3ч):
    SELECT count(*) FROM listings
    WHERE source='avito' AND scraped_at > NOW() - INTERVAL '4 hours'
      AND lat IS NOT NULL;
    -- expect: significant subset (адресы которые Yandex/Nominatim смог найти)
    

Risk score

  • Risk: Low
  • Reversibility: Easy revert (no schema changes, только code)
  • Recommended merge window: anytime

Merging.

## Deep Code Review — verdict APPROVE **Status**: APPROVE (merge) **Files reviewed**: 2 (P1: `tradein-mvp/backend/app/services/scrapers/avito.py`, P1: `.../base.py`) **Lines**: +57 / -232 (net −175) ### Critical (BLOCK) — none ### High — none ### Medium (follow-up, не блокирующее) 1. **`save_listings` ON CONFLICT не обновляет house linking** (`base.py:224-229`) ``` ON CONFLICT (dedup_hash) DO UPDATE SET last_seen_at = NOW(), is_active = true, price_rub = EXCLUDED.price_rub, price_per_m2 = EXCLUDED.price_per_m2 ``` Existing listings (insert'нутые до этого PR) с `house_ext_id=NULL` так и останутся NULL при re-scrape — Avito показывает же объявление, dedup_hash совпадает, обновляем только price. **Fix follow-up PR**: добавить `house_source = COALESCE(listings.house_source, EXCLUDED.house_source)` и аналогично для `house_ext_id`/`house_url`/`listing_segment`. Это backfill через первый same-listing rescrape. 2. **House regex pattern strict enough?** (`avito.py:178`) `parts[-1].isdigit()` — корректно отсеивает `/catalog/houses/about/`, но любой numeric последний segment пройдёт. Avito может ссылаться на `/catalog/houses/info/2026` (год) если такая страница появится. Низкий риск, но мониторь `SELECT count(*) FROM listings WHERE house_ext_id ~ '^(19|20)\d{2}$'` после deploy — если >0%, regex нужно ужесточить (например min 5 digits). 3. **Pre-existing 4 E501 в `base.py` `_USER_AGENTS`** — out of scope, ок, но в follow-up cleanup PR. ### Low / nits — none ### Cross-file impact analysis - **`ScrapedLot` Pydantic extension**: 4 новых поля **все `Optional[str] = None`** (`base.py:81-84`) → **backward compat OK**. Verified all callers used named kwargs: - `avito.py:190` — заполняет новые поля - `cian.py:232, 363` — не использует, defaults `None` корректно - `yandex_realty.py:211` — не использует, defaults `None` - `n1.py:182` — не использует, defaults `None` - **`save_listings` SQL**: psycopg v3 named params (`:house_ext_id` etc), placeholders count matches column count, типы text — корректно соответствуют schema `011_listings_alter.sql:22-26`. - **Deleted symbols** (`_fetch_via_mobile_api`, `_mobile_item_to_lot`, `_extract_avito_state`, `_items_from_state`, `_state_item_to_lot`, `_RE_AVITO_STATE`): grep по `tradein-mvp/backend/` — **0 lingering references**. `_items_from_state` существует отдельно в `yandex_realty.py:261` (другая функция, не conflict). `import json`/`import random` — больше не используются в `avito.py`, удалены корректно. ### Blast radius (NULL coords) — APPROVED **Critical discovery: `estimator.py:379` УЖЕ исключает Avito из radius search** (`AND source <> 'avito'`) с комментарием: > Avito исключён из радиусного поиска: его «координаты» — это якорь cron-скрейпа ± jitter (DOM Avito реальных coords не отдаёт). Фейковые точки кучкуются на 5 якорях и вытесняют реальные cian/yandex/n1. Значит: - До PR: Avito JITTER → отсекался `WHERE source <> 'avito'`, oценка работала **без** Avito. - После PR: Avito NULL → physically не попадает в `ST_DWithin` (NULL geom), всё равно не учитывается. - **Чистый эффект для оценки = 0 регрессии**, а после geocode-backfill (cron) Avito можно вернуть в estimator (follow-up PR, удалить `AND source <> 'avito'`). - **MapPicker frontend** — used для user input clicks (не для display lots), null coords не крашат UI. ### `geocode-missing` cron verification — PASS - Endpoint: `app/api/v1/admin.py:139` `POST /api/v1/admin/geocode-missing?limit=100&target=listings` — exists, рабочий. - Cron schedule: `deploy/cron-scrape.sh:86-96` — после каждого scrape запускает 15 чанков по 100 = до 1500 адресов с budget 240s/chunk. - **Critical sub-finding**: `admin.py:158` extra_filter `address NOT LIKE '%(Avito)%'` — раньше **СКИПАЛ** jittered Avito строки с fallback address "Екатеринбург (Avito)". После PR address либо реальный (улица), либо NULL → cron `WHERE COALESCE(address, '') != ''` отфильтрует NULL, реальные адреса геокодируются. Filter `NOT LIKE '%(Avito)%'` теперь dead-code, но не вредит. **OK**. ### Tradein DB consistency - Schema `011_listings_alter.sql:22-26` уже добавил все 4 column'а как nullable text + `house_id_fk bigint REFERENCES houses(id)` (FK). PR пишет `house_ext_id` (text), resolution `text → FK house_id_fk` через JOIN — Stage 2c. Pattern correct. - Partial index `listings_house_ext_id_idx ON listings (house_source, house_ext_id) WHERE house_ext_id IS NOT NULL` (`011:65-66`) — оптимально для Stage 2c lookup. ### Performance findings - Нет N+1, нет new SELECT'ов на больших таблицах. `save_listings` per-row INSERT (как и до PR) — приемлемо, batch size небольшой (50 max per scrape). ### Positive observations - Корректно удалён ~200 LOC dead code (mobile API + state parsing). - `ScrapedLot` extension с Optional defaults сохраняет backward compat. - Address fallback "Екатеринбург (Avito)" удалён — допускает geocode-missing cron подхватить реальные адреса. - House linking через `data-marker="item"` ancestor scope корректен (не глобальный селектор). - `import json`, `import random` правильно убраны. - `_state_item_to_lot` deleted вместе с парс-логикой — без orphan функции. ### Post-merge smoke checklist 1. Deploy.yml triggered (paths matches `tradein-mvp/backend/**` ✓ verified `.forgejo/workflows/deploy-tradein.yml:36`) 2. После next scrape per anchor: ```sql SELECT count(*) FROM listings WHERE source='avito' AND scraped_at > NOW() - INTERVAL '1 hour' AND lat IS NULL; -- expect: ~50 per anchor SELECT count(*) FILTER (WHERE house_ext_id IS NOT NULL)::float / NULLIF(count(*),0) FROM listings WHERE source='avito' AND scraped_at > NOW() - INTERVAL '1 hour'; -- expect: ~0.7-0.85 (по плану 82%) ``` 3. После geocode-missing cron run (через ~3ч): ```sql SELECT count(*) FROM listings WHERE source='avito' AND scraped_at > NOW() - INTERVAL '4 hours' AND lat IS NOT NULL; -- expect: significant subset (адресы которые Yandex/Nominatim смог найти) ``` ### Risk score - **Risk**: Low - **Reversibility**: Easy revert (no schema changes, только code) - **Recommended merge window**: anytime Merging.
lekss361 merged commit 47f4cc7d58 into main 2026-05-23 11:55:19 +00:00
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#441
No description provided.