PR I (#595) hookнул matching service в save_listings для new scrapes. PR J — repeatable script для existing 18,428 listings (avito 9302, cian 5158, yandex 3704, n1 264) — все они unlinked к houses. Script: - backfill_listing_sources.py: stream batches of 500 rows, per-row SAVEPOINT, idempotent via NOT EXISTS pre-check + ON CONFLICT в upsert_listing_source. - Use lot.house_source/house_ext_id if scraper extract'нул (direct link path), otherwise match_or_create_house() (Tier 0-3 via PostGIS geo-proximity + address fingerprint). - upsert_listing_source с method='backfill', confidence=0.9 (lower чем real-time source_link=1.0 — для forensic distinction). - Args: --batch-size N (default 500), --limit N (canary), --dry-run. - No network calls (in-DB matching через PostGIS + address aliases) — работает даже когда Yandex Geocoder заблокирован. Tests: 19 pass в test_backfill_listing_sources.py: - happy path, idempotency (re-run skip), house_source direct linkage, match failure → still upserts с NULL house_id, dry-run, --limit cap, cursor advance, CLI entry point. README.md обновлён с canonical docker exec instruction. Run post-merge через: ssh gendesign 'docker exec tradein-backend python -m \\ scripts.backfill_listing_sources --batch-size 500' Expected ~5-15 min wall-time для 18k rows (per-row Tier 0-3 matching через advisory lock + 3 PostGIS queries). Final coverage ~80-90% (~10% remaining без resolvable address/coords). Closes #582 (matching gap).
9 KiB
tradein-mvp/backend/scripts/
Ops scripts that touch the production database directly. Run via python -m scripts.<name> from the backend/ working directory after uv sync.
All scripts are idempotent / resumable where they write — re-running the same
--batch label skips already-processed rows (UNIQUE constraints in target
tables). Failures inside a per-row loop never roll back the outer transaction;
each row is wrapped in a SAVEPOINT (db.begin_nested()) per .claude/rules/backend.md.
Production usage (canonical)
Scripts ship inside the tradein-backend image (PR F — COPY scripts ./scripts
в backend/Dockerfile). На VPS они уже в /app/scripts/ — никаких manual
docker cp не нужно.
YANDEX_GEOCODER_API_KEY подтягивается из /opt/gendesign/tradein-mvp/backend/ .env.runtime через env_file: в docker-compose.prod.yml — никакого -e в
docker exec не нужно.
# Backfill (forward geocode 4170 houses без coords)
ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --batch 2026-05-27_backfill'
# Audit-only (reverse geocode проверка для уже geocoded houses)
ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --audit-only --batch 2026-05-27_audit'
# Canary first
ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --limit 100 --batch canary_$(date +%F)'
После изменения backend/.env.runtime нужен --force-recreate контейнера
(см. .claude/rules/deploy.md):
ssh gendesign 'cd /opt/gendesign/tradein-mvp && docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --force-recreate --no-deps backend'
Address audit + backfill (issue #582)
End-to-end address quality pipeline. Three scripts, two helpers, two SQL files.
Локальные примеры ниже — для dev-машины с
uv runи переменными в shell. На prod используй canonicaldocker execкоманды из секции выше — тамYANDEX_GEOCODER_API_KEYуже подгружен изbackend/.env.runtime.
audit_address_mismatch.py — Phase 1 baseline (PR #583)
Stratified-sample audit (200 EKB houses) comparing houses.address vs
Yandex Geocoder reverse lookup. Writes one row per house into
address_mismatch_audit with the snapped point + canonical address + distance.
DATABASE_URL=postgresql+psycopg://... \
YANDEX_GEOCODER_API_KEY=... \
uv run python -m scripts.audit_address_mismatch \
--batch 2026-05-25_run1 \
--limit-per-district 25
Mode auto picks API if the key is set, otherwise Playwright (CAPTCHA-aware,
4-7s sleep between calls). API tier free is 25k req/day → 200-row sample
takes ~10s with no quota concern.
Report:
psql "$DATABASE_URL" -v batch='2026-05-25_run1' \
-f scripts/address_audit_report.sql
backfill_house_coords.py — Phase 2-3 (PR for #582)
Two modes (--audit-only flag switches between them):
Backfill (default) — forward-geocode houses.address for the ~4141 rows
WHERE lat IS NULL OR lon IS NULL. Only writes back if Yandex returns
precision='exact' or 'number' (skips street-only / locality matches).
Each processed row gets an address_mismatch_audit entry with status
backfill / imprecise / no_match / error.
DATABASE_URL=postgresql+psycopg://... \
YANDEX_GEOCODER_API_KEY=... \
uv run python -m scripts.backfill_house_coords \
--batch 2026-05-27_backfill
Expected duration (~4141 rows, 50ms between calls, ~250ms RTT per request): 20-25 min. Expected output split (rough baseline from Phase 1 numbers):
| Status | Approx rows | What it means |
|---|---|---|
backfill |
~3.3k–3.7k | UPDATE landed, lat/lon now populated |
imprecise |
~300–500 | Match returned but precision too low — needs review |
no_match |
~100–300 | Yandex couldn't resolve; address probably mangled |
error |
<50 | HTTP errors / timeouts — re-run picks them up |
Audit-only — reverse-geocode the ~4452 houses WITH coords, write
audit rows with status ok (≤50m) / mismatch (>50m) / no_match / error.
Does NOT modify the houses table.
uv run python -m scripts.backfill_house_coords \
--batch 2026-05-27_audit --audit-only
Combined budget for both phases (~8.6k requests) is well under the 25k/day Geocoder free tier.
Common ops
Canary first — run with --limit 100 and inspect the audit table before
letting the full job loose:
uv run python -m scripts.backfill_house_coords \
--batch canary_$(date +%F) --limit 100
psql "$DATABASE_URL" -c "
SELECT audit_status, COUNT(*)
FROM address_mismatch_audit
WHERE audit_batch = 'canary_$(date +%F)'
GROUP BY audit_status;
"
Resume after crash / quota hit — same --batch label, the UNIQUE
(house_id, audit_batch) index skips finished rows:
uv run python -m scripts.backfill_house_coords --batch 2026-05-27_backfill
# ... interruption ...
uv run python -m scripts.backfill_house_coords --batch 2026-05-27_backfill
# logs: "resuming batch 2026-05-27_backfill: N rows already processed"
Helpers (not entry points)
_yandex_reverse.py—forward_via_api(),reverse_via_api(),reverse_via_playwright(),YandexReverseResultdataclass. Both API paths share_parse_api_payloadbecause Yandex's forward/reverse envelopes have the same shape.audit_address_sample.sql— random sample for the Phase 1 audit (used byaudit_address_mismatch.py).address_audit_report.sql— psql-driven post-run summary (p50/p75/p95 distance, top-20 outliers, per-district breakdown).
Matching backfill (PR J)
backfill_listing_sources.py — retroactive matching for ~18k listings
PR I (commit 7e24ccb) hooked the matching service into save_listings() so
every new scrape now writes a listing_sources row + resolves a canonical
houses row. This script does the same work retroactively for all
existing listings — listing_sources only had rows from new scrapes
post-PR I.
What it does per row:
match_or_create_house()(Tier 0-3) — useslistings.house_source/house_ext_idwhen present (Avito Houses Catalog, Cian newbuilding), else falls back to address/lat/lon/cadastrals.upsert_listing_source()withmethod='backfill',confidence=0.9(vs real-timesource_link1.0 — distinguishes the two in audits).UPDATE listings.house_id_fkwhen the row didn't already have one.
# Canary
DATABASE_URL=postgresql+psycopg://... \
uv run python -m scripts.backfill_listing_sources \
--limit 100 --dry-run
# Real run, one source at a time (staged rollout)
uv run python -m scripts.backfill_listing_sources --source avito
# Full run
uv run python -m scripts.backfill_listing_sources --batch-size 500
Idempotent / resumable — the source query is
WHERE NOT EXISTS (SELECT 1 FROM listing_sources ls WHERE ls.ext_source = listings.source AND ls.ext_id = COALESCE(listings.source_id, listings.dedup_hash)). Re-runs only pick up rows still missing from
listing_sources. upsert_listing_source adds a second layer of safety via
ON CONFLICT (ext_source, ext_id) DO UPDATE.
No network calls — pure in-DB matching (Yandex Geocoder is blocked on
prod, and match_or_create_house does not call it anyway).
Per-row SAVEPOINT (db.begin_nested()) per .claude/rules/backend.md —
one bad row never aborts the surrounding batch.
Expected output (PR J initial run):
| Source | Rows | Expected matched | Notes |
|---|---|---|---|
| avito | 9302 | 9000+ | Many carry house_source/house_ext_id |
| cian | 5158 | 5000+ | Most carry house_source/house_ext_id |
| yandex | 3704 | 3700+ | No source_id → uses dedup_hash as ext_id |
| n1 | 264 | 264 | All have address/coords |
Expected duration: rough estimate ~5-15 minutes on prod for ~18k rows
(advisory-lock + 1-3 DB roundtrips per listing for Tier 0-3, ~500 commit
checkpoints at default batch size). Run with --limit 100 first to
calibrate, then let the full job loose.
Final summary in the log includes per-source coverage % so you can verify the run landed:
backfill done (dry_run=False): processed=18428 matched=18428
house_resolved=18200 house_failed=228 skipped=0 errors=0
avito processed=9302 matched=9302 house_resolved=9290 ...
cian processed=5158 matched=5158 house_resolved=5100 ...
yandex processed=3704 matched=3704 house_resolved=3540 ...
n1 processed=264 matched=264 house_resolved=270 ...
final listing_sources coverage:
avito 9302 / 9302 (100.0%)
cian 5158 / 5158 (100.0%)
...