(а) enumerate: прямой bff-search offers/v1+count/v1 режет QRATOR-403 с home-IP →
переведён на навигацию по фронтовому SERP (page.goto, тот же чистый транспорт что
card-detail): id из DOM (a[href*=/card/]), total из <title>, пагинация offset=20,
price-bisection при total>2000 ("5+"→5%2B через urlencode). Layer-A
(price/area/rooms/floor/total_floors/lat/lon/address) теперь из card-SSR
(productCard.priceInfo/objectInfo + address.position + houseInfo.info), т.к.
enumerate отдаёт только id+url. Удалены мёртвые BFF-функции. Схема JSONL не изменилась.
(б) detail: дозабор с offer-card v3 (200 с home-IP, в отличие от прод-прокси):
price_prediction (AVM заполненный + repair_quality + аренда, всегда) и sold_similar
(опц., --with-sold-similar/--sold-limit) через page.evaluate из QRATOR-сессии карточки.
XHR-AVM переопределяет пустой SSR pricePrediction; sold_similar в raw_extra.
Live-tested: enum-only собрал ids без QRATOR-аборта; full --limit 2 → price_rub
6.1M/3.1M, area/floor/coords/address из card-SSR, AVM market_price 4.99M/3.01M,
sold_similar.total≈1691. ruff clean.
scripts/ingest_domclick_jsonl.py — idempotent ingest of domclick_local_runner
output: filter ok+price, upsert via save_listings (fault-tolerant house-match
hook in SAVEPOINT — non-matched listings still persist), deep-detail enrichment
via save_detail_enrichment (owners/encumbrances/kitchen/views/wall/floor/
price-history). --limit / --dry-run. psycopg v3 ANY(:ids) list-bind.
Per-record try/except in enrichment loop so one bad row never aborts the tail.
Live-tested in tradein-backend on 30 records: updated=30, house_matched=30,
detail_applied=30, price_history idempotent (75->75), enrich_failed=0.
Self-contained script (playwright chromium, NO app imports) для сбора DomClick EKB
detail с домашнего residential IP (обходит QRATOR — verified live: 2 карточки ok,
repair/areas/priceHistory/egrn/owners собраны). Enumerate via bff (Layer A) + card
__SSR_STATE__ parse (Layer B) → JSONL (resume/idempotent).
KEY: дифференциатор — РЕЖИМ HEADLESS, не только IP. С того же IP старый headless=True
→ 403, new-headless → 200 + SSR. Раннер дефолтит на new-headless.
Пейсинг: рандом 12-30с между карточками, render-jitter 6-10с, human-break каждые
25-40 (60-180с), 1 retry на 403. ~100-150 карточек/час, full ~6400 ≈ 45-65ч.
JSONL ингестится в tradein DB отдельным шагом. НЕ deploy — локальный ops-инструмент.
ORDER BY has_active_listings DESC, id во всех priority-бакетах + новый
--priority listings (focused sweep: NULL cadnum + есть active listing).
Дневной 100/day budget сначала закрывает дома, которые реально появляются
в оценках → estimator #6 cadastr_exact same-building матчинг загорается на
real estimate targets за дни, а не недели.
Correlated EXISTS бьёт по partial-индексу listings_house_id_fk_idx.
Write-path / EKB-guard / SAVEPOINT / qc_geo rules не тронуты.
code-reviewer: ✅ APPROVE no critical. Tests: 40 passed. Ruff clean.
Добавляет scripts/geocode_deals_nominatim.py для ~14% сделок всё ещё
lat IS NULL после house-centroid прохода (Step 2) — улицы без геокодированных
домов, которые centroid-join не покрывает. Переиспользует geocoder.geocode
(cache → Cadastral FDW → Yandex → Nominatim), дедупит по адресу (distinct
улицы определяют число вызовов, не 7k строк), и стампит geocode_tried_at на
КАЖДОЙ попытке (успех И чистый промах) чтобы un-geocodable строки не крутились
по 1 req/s. Resume: lat IS NULL + 30-day staleness, per-address SAVEPOINT,
--limit/--dry-run/--batch/--stale-days.
Pre-push review (code-reviewer): ✅ APPROVE, no critical — проверены SQL-safety,
no-infinite-reloop (mark-tried на miss, no-stamp на raised exc), window-cap,
geom-not-manual, dedup, async. Tests: 17 passed. Ruff clean.
Dry-run на проде показал, что DaData без явного города мапит короткие
street-адреса в Москву по умолчанию («улица Просторная» → «г Москва, ул
Просторная»). Это бы привело к catastrophic false-positive enrichment'у
если бы скрипт запустили без --dry-run.
Two-layer fix:
1. `_with_city_context(addr)` — pre-pends «Екатеринбург, » если в адресе
ещё нет упоминания города. Идемпотентен, case-insensitive.
2. `_result_in_ekb(result)` — post-filter. Проверяет `raw.city`
(структурное поле DaData) с fallback на substring search в
`canonical_address`. Защищает даже если DaData проигнорирует city-префикс
и всё равно вернёт другой город (например для homonym streets).
`_is_enriched_result()` теперь требует обе проверки: qc_geo IN (0,1)
AND coords AND city == Екатеринбург. Только так делаем UPDATE coords/cadnum.
Без этого fix'а нельзя запускать backfill — все non-EKB false-positives
перезаписали бы NULL coords мусором.
9 новых тестов: city context (prepend / idempotent / case / empty),
post-filter (raw.city / canonical / Moscow reject / None).
Combined 29 passed.