From d2a740779de16cef20aa209b8ef3f00665cbfe2e Mon Sep 17 00:00:00 2001 From: TradeIn Deploy Date: Thu, 21 May 2026 13:14:33 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BE=D1=80=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D1=82=D1=8B=20Cian/N1=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=B3=D0=B5=D0=BE=D0=BA=D0=BE=D0=B4=D0=B8=D0=BD=D0=B3=20(?= =?UTF-8?q?=D0=B1=D1=8B=D0=BB=20=D0=B1=D0=B0=D0=B3=200=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cian/N1 ставили anchor+jitter координаты — фейковые, вокруг центрального якоря. Для адресов вдали от центра ST_DWithin не находил аналоги → 0. Теперь Cian/N1 оставляют lat/lon=None, geocode-missing проставляет реальные координаты по адресу (geom пересчитывается триггером). cron вызывает geocode-missing после скрейпа. Адрес чистится от суффикса · р-н. --- tradein-mvp/backend/app/api/v1/admin.py | 33 ++++++++++++------- .../backend/app/services/scrapers/cian.py | 10 +++--- .../backend/app/services/scrapers/n1.py | 12 +++---- tradein-mvp/deploy/cron-scrape.sh | 8 +++++ 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index b111832e..47184d76 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -111,23 +111,36 @@ async def scrape_around( ) +def _clean_address_for_geocode(addr: str) -> str: + """Чистим address для геокодера. + + Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» — суффикс ' · ...' + мешает Nominatim. Берём часть до ' · '. N1 отдаёт «Репина, 75/2 стр.» — ок. + """ + main = addr.split(" · ")[0].strip() + return main or addr + + @router.post("/geocode-missing") async def geocode_missing( db: Annotated[Session, Depends(get_db)], - limit: int = 50, + limit: int = 500, ) -> dict: """Геокодинг listings у которых нет lat/lon (используя address). - Запускать после scrape если парсер не достал координаты. - Nominatim rate-limited 1 req/sec — поэтому небольшой batch. + Запускать после scrape — Cian/N1 не отдают координаты, только адрес. + Nominatim rate-limited 1 req/sec → большой batch идёт ~limit секунд. + geom обновляется автоматически триггером listings_set_geom_trg. """ rows = db.execute( text( """ - SELECT id, address, raw_payload + SELECT id, address FROM listings WHERE (lat IS NULL OR lon IS NULL) AND COALESCE(address, '') != '' + AND address NOT LIKE '%(Avito)%' + AND address NOT LIKE '%(N1)%' ORDER BY scraped_at DESC LIMIT :limit """ @@ -138,18 +151,14 @@ async def geocode_missing( geocoded = 0 skipped = 0 for row in rows: - result = await geocode(row["address"], db) + clean = _clean_address_for_geocode(row["address"]) + result = await geocode(clean, db) if result is None: skipped += 1 continue + # geom пересчитается триггером из lat/lon. db.execute( - text( - """ - UPDATE listings - SET lat = :lat, lon = :lon - WHERE id = :id - """ - ), + text("UPDATE listings SET lat = :lat, lon = :lon WHERE id = :id"), {"lat": result.lat, "lon": result.lon, "id": row["id"]}, ) db.commit() diff --git a/tradein-mvp/backend/app/services/scrapers/cian.py b/tradein-mvp/backend/app/services/scrapers/cian.py index 86118522..e13a7961 100644 --- a/tradein-mvp/backend/app/services/scrapers/cian.py +++ b/tradein-mvp/backend/app/services/scrapers/cian.py @@ -346,12 +346,12 @@ class CianScraper(BaseScraper): if ppm2 > 0: area = round(price / ppm2, 2) - # Coords: anchor + jitter (Cian не отдаёт координаты в card HTML, только в API) + # Coords: НЕ ставим (Cian card HTML не отдаёт). Оставляем None — + # geocode-missing проставит реальные по address. Anchor+jitter тут + # был БАГ: Cian отдаёт ВСЕ объявления ЕКБ независимо от anchor, и + # jitter вокруг одного якоря давал фейковые координаты → ST_DWithin + # не находил аналоги для адресов вдали от центра. lat = lon = None - if hasattr(self, "_anchor_lat"): - jitter_deg = (self._anchor_radius_m / 1000) / 111 - lat = self._anchor_lat + (random.random() - 0.5) * jitter_deg - lon = self._anchor_lon + (random.random() - 0.5) * jitter_deg / 2 # Photos из img.src в card (первые 5) photo_urls: list[str] = [] diff --git a/tradein-mvp/backend/app/services/scrapers/n1.py b/tradein-mvp/backend/app/services/scrapers/n1.py index 02e59c4d..47e30ab3 100644 --- a/tradein-mvp/backend/app/services/scrapers/n1.py +++ b/tradein-mvp/backend/app/services/scrapers/n1.py @@ -12,13 +12,13 @@ SSR HTML с DOM-карточками `data-test="offers-list-item"`. - [data-test="offers-list-item-param-price"] — "15 700 000" - img.src — фото -Координаты карточки не отдаёт → anchor+jitter (как Avito). +Координаты карточки не отдаёт → оставляем lat/lon = None, +geocode-missing проставит реальные по адресу. """ from __future__ import annotations import logging -import random import re from typing import Any @@ -167,12 +167,10 @@ class N1Scraper(BaseScraper): if not price: return None - # Координаты: anchor + jitter (N1 card не отдаёт coords) + # Координаты НЕ ставим — N1 card не отдаёт. Оставляем None, + # geocode-missing проставит реальные по address (N1 отдаёт всё ЕКБ, + # jitter вокруг якоря был бы фейком). lat = lon = None - if hasattr(self, "_anchor_lat"): - jitter_deg = (self._anchor_radius_m / 1000) / 111 - lat = self._anchor_lat + (random.random() - 0.5) * jitter_deg - lon = self._anchor_lon + (random.random() - 0.5) * jitter_deg / 2 # Фото photos: list[str] = [] diff --git a/tradein-mvp/deploy/cron-scrape.sh b/tradein-mvp/deploy/cron-scrape.sh index 7ebaa949..6fae5a6b 100755 --- a/tradein-mvp/deploy/cron-scrape.sh +++ b/tradein-mvp/deploy/cron-scrape.sh @@ -63,4 +63,12 @@ for anchor in "${ANCHORS[@]}"; do sleep 5 done +# Геокодинг свежих лотов без координат (Cian/N1 отдают только адрес). +# Nominatim 1 req/sec → batch ~500 идёт до ~8 мин. +log "→ geocode-missing" +geo=$(docker exec "$CONTAINER" curl -s -X POST \ + "http://localhost:8000/api/v1/admin/geocode-missing?limit=500" \ + -m 700 2>/dev/null || echo '{"error":"geocode_timeout"}') +log "← geocode-missing $geo" + log "=== cron-scrape done ==="