All checks were successful
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
M1: добавлен guard перед вызовом fetch_jk — если ext_id пустой/None при наличии
jk_slug, логируем warning, incrementим failed_fetch и пропускаем (continue).
Пустой jk_id формирует битый URL /{slug}-/ и коллизию на UNIQUE ext_id=''.
M3: в admin.py /scrape/yandex-newbuilding заменён `async with YandexNewbuildingScraper()`
на прямое `scraper = YandexNewbuildingScraper()` — fetch_jk использует внутренний
BrowserFetcher, httpx-клиент BaseScraper открывался вхолостую.
Тесты: добавлен test_sweep_slug_present_but_ext_id_null_skips_fetch.
423 lines
16 KiB
Python
423 lines
16 KiB
Python
"""Yandex Newbuilding sweep-task (#974): enrichment ЖК в market.yandex_jk_enrichment.
|
||
|
||
Context
|
||
-------
|
||
309 house_sources строк с ext_source='yandex_realty_nb' имеют ext_id (= yandex_jk_id),
|
||
но у большинства NULL yandex_jk_slug и не заполнены данные в market.yandex_jk_enrichment.
|
||
|
||
Для каждого дома цепочка:
|
||
1. resolve_yandex_jk_slug(jk_id) → slug (через BrowserFetcher SERP)
|
||
Результат сохраняется в houses.yandex_jk_slug (SAVEPOINT) — resumable.
|
||
2. YandexNewbuildingScraper.fetch_jk(slug, jk_id) → YandexNewbuildingInfo
|
||
Через BrowserFetcher (tradein-browser camoufox) — единственный рабочий путь.
|
||
3. UPSERT в market.yandex_jk_enrichment (ON CONFLICT (ext_id) DO UPDATE).
|
||
4. UPDATE houses.yandex_jk_id WHERE yandex_jk_slug = slug.
|
||
|
||
Idempotency
|
||
-----------
|
||
- force=False: пропускает дома у которых уже есть строка в market.yandex_jk_enrichment.
|
||
- UPSERT через ON CONFLICT (ext_id) DO UPDATE — безопасен при повторном запуске.
|
||
- SAVEPOINT per house — один сбойный fetch не прерывает батч.
|
||
- dry_run: подсчёт популяции без fetch.
|
||
|
||
Anti-bot / resilience
|
||
---------------------
|
||
- request_delay_sec (default из get_scraper_delay('yandex_realty_nb')) с ±20% jitter.
|
||
- SAVEPOINT per house: resolve-фаза и enrich-фаза — отдельные savepoint'ы.
|
||
|
||
Execution
|
||
---------
|
||
- Чистый async callable — NO Celery. Триггер: admin endpoint или scheduler.
|
||
- psycopg v3 conventions: CAST(:x AS type) в SQL (никогда ::`); logger (никогда print).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import random
|
||
import time
|
||
from dataclasses import dataclass, field, fields
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.services.scraper_settings import get_scraper_delay
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
__all__ = [
|
||
"YandexNewbuildingSweepResult",
|
||
"count_yandex_newbuilding_houses",
|
||
"enrich_yandex_newbuilding_sweep",
|
||
]
|
||
|
||
_ENRICHMENT_SOURCE = "yandex_realty_nb"
|
||
|
||
|
||
@dataclass
|
||
class YandexNewbuildingSweepResult:
|
||
"""Per-run счётчики yandex newbuilding sweep."""
|
||
|
||
# Sizing (независимо от limit).
|
||
total: int = 0 # дома с ext_source='yandex_realty_nb'
|
||
fetchable: int = 0 # из них: с yandex_jk_slug ИЛИ с ext_id
|
||
pending: int = 0 # fetchable И ещё не обогащены (force=False)
|
||
|
||
# Processing (ограничен limit).
|
||
processed: int = 0
|
||
skipped_already_enriched: int = 0
|
||
succeeded: int = 0
|
||
resolved_slug: int = 0 # ext_id → slug разрезолвлен + сохранён
|
||
failed_resolve: int = 0 # slug не удалось разрезолвить
|
||
failed_fetch: int = 0 # fetch_jk вернул None / упал
|
||
rows_inserted: int = 0 # строк в market.yandex_jk_enrichment (новых/обновлённых)
|
||
|
||
duration_sec: float = field(default=0.0)
|
||
|
||
def to_dict(self) -> dict[str, int | float]:
|
||
return {f.name: getattr(self, f.name) for f in fields(self)}
|
||
|
||
|
||
# ── SQL ────────────────────────────────────────────────────────────────────────
|
||
|
||
_COUNT_TOTAL = """
|
||
SELECT COUNT(DISTINCT h.id)
|
||
FROM houses h
|
||
JOIN house_sources hs ON hs.house_id = h.id
|
||
WHERE hs.ext_source = 'yandex_realty_nb'
|
||
"""
|
||
|
||
_COUNT_FETCHABLE = """
|
||
SELECT COUNT(DISTINCT h.id)
|
||
FROM houses h
|
||
JOIN house_sources hs ON hs.house_id = h.id
|
||
WHERE hs.ext_source = 'yandex_realty_nb'
|
||
AND (h.yandex_jk_slug IS NOT NULL OR hs.ext_id IS NOT NULL)
|
||
"""
|
||
|
||
_COUNT_PENDING = """
|
||
SELECT COUNT(DISTINCT h.id)
|
||
FROM houses h
|
||
JOIN house_sources hs ON hs.house_id = h.id
|
||
WHERE hs.ext_source = 'yandex_realty_nb'
|
||
AND (h.yandex_jk_slug IS NOT NULL OR hs.ext_id IS NOT NULL)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM market.yandex_jk_enrichment e WHERE e.ext_id = hs.ext_id
|
||
)
|
||
"""
|
||
|
||
# DISTINCT ON (h.id) — у дома может быть >1 house_sources строки; берём любую ext_id.
|
||
_SELECT_PENDING_HOUSES = """
|
||
SELECT DISTINCT ON (h.id)
|
||
h.id AS house_id,
|
||
h.yandex_jk_slug,
|
||
h.yandex_jk_id,
|
||
hs.ext_id
|
||
FROM houses h
|
||
JOIN house_sources hs ON hs.house_id = h.id
|
||
WHERE hs.ext_source = 'yandex_realty_nb'
|
||
AND (h.yandex_jk_slug IS NOT NULL OR hs.ext_id IS NOT NULL)
|
||
AND (
|
||
CAST(:force AS boolean) = TRUE
|
||
OR NOT EXISTS (
|
||
SELECT 1 FROM market.yandex_jk_enrichment e WHERE e.ext_id = hs.ext_id
|
||
)
|
||
)
|
||
ORDER BY h.id, hs.ext_id NULLS LAST
|
||
LIMIT :lim
|
||
"""
|
||
|
||
_UPDATE_SLUG = """
|
||
UPDATE houses
|
||
SET yandex_jk_slug = CAST(:slug AS text)
|
||
WHERE id = CAST(:hid AS bigint)
|
||
"""
|
||
|
||
_UPSERT_ENRICHMENT = """
|
||
INSERT INTO market.yandex_jk_enrichment (
|
||
ext_id, name, developer_name, address,
|
||
lat, lon, rating, ratings_count, text_reviews_count,
|
||
raw_payload, updated_at
|
||
) VALUES (
|
||
CAST(:ext_id AS text),
|
||
CAST(:name AS text),
|
||
CAST(:developer_name AS text),
|
||
CAST(:address AS text),
|
||
CAST(:lat AS float8),
|
||
CAST(:lon AS float8),
|
||
CAST(:rating AS float4),
|
||
CAST(:ratings_count AS int),
|
||
CAST(:text_reviews_count AS int),
|
||
CAST(:raw_payload AS jsonb),
|
||
NOW()
|
||
)
|
||
ON CONFLICT (ext_id) DO UPDATE SET
|
||
name = EXCLUDED.name,
|
||
developer_name = EXCLUDED.developer_name,
|
||
address = EXCLUDED.address,
|
||
lat = EXCLUDED.lat,
|
||
lon = EXCLUDED.lon,
|
||
rating = EXCLUDED.rating,
|
||
ratings_count = EXCLUDED.ratings_count,
|
||
text_reviews_count = EXCLUDED.text_reviews_count,
|
||
raw_payload = EXCLUDED.raw_payload,
|
||
updated_at = NOW()
|
||
"""
|
||
|
||
|
||
def count_yandex_newbuilding_houses(db: Session) -> dict[str, int]:
|
||
"""Sizing: сколько yandex_realty_nb домов существует / fetchable / pending.
|
||
|
||
Дешёвый (3 COUNT) — безопасен для dry_run.
|
||
"""
|
||
total = int(db.execute(text(_COUNT_TOTAL)).scalar_one())
|
||
fetchable = int(db.execute(text(_COUNT_FETCHABLE)).scalar_one())
|
||
pending = int(db.execute(text(_COUNT_PENDING)).scalar_one())
|
||
return {"total": total, "fetchable": fetchable, "pending": pending}
|
||
|
||
|
||
async def enrich_yandex_newbuilding_sweep(
|
||
db: Session,
|
||
*,
|
||
limit: int = 5,
|
||
force: bool = False,
|
||
request_delay_sec: float | None = None,
|
||
dry_run: bool = False,
|
||
city: str = "ekaterinburg",
|
||
) -> YandexNewbuildingSweepResult:
|
||
"""Enrichment sweep для yandex_realty_nb домов → market.yandex_jk_enrichment.
|
||
|
||
Per house:
|
||
- resolve slug если NULL (persist в houses.yandex_jk_slug, SAVEPOINT)
|
||
- fetch_jk через BrowserFetcher
|
||
- UPSERT market.yandex_jk_enrichment (ON CONFLICT ext_id)
|
||
- UPDATE houses.yandex_jk_id (если изменился)
|
||
|
||
Args:
|
||
db: tradein-mvp SQLAlchemy session (НЕ gendesign DB).
|
||
limit: max домов за один прогон. Default 5 — bounded start.
|
||
force: переобработать уже обогащённые (UPSERT-safe). Default False.
|
||
request_delay_sec: пауза между домами. None → get_scraper_delay('yandex_realty_nb').
|
||
dry_run: считать популяцию без fetch/write.
|
||
city: город для Yandex Realty URL (ekaterinburg).
|
||
|
||
Returns:
|
||
YandexNewbuildingSweepResult со счётчиками.
|
||
"""
|
||
from app.services.scrapers.yandex_newbuilding import (
|
||
YandexNewbuildingScraper,
|
||
resolve_yandex_jk_slug,
|
||
)
|
||
|
||
result = YandexNewbuildingSweepResult()
|
||
t0 = time.time()
|
||
delay = (
|
||
request_delay_sec
|
||
if request_delay_sec is not None
|
||
else get_scraper_delay(_ENRICHMENT_SOURCE)
|
||
)
|
||
|
||
# ── Sizing ──────────────────────────────────────────────────────────────
|
||
sizing = count_yandex_newbuilding_houses(db)
|
||
result.total = sizing["total"]
|
||
result.fetchable = sizing["fetchable"]
|
||
result.pending = sizing["pending"]
|
||
|
||
rows = db.execute(text(_SELECT_PENDING_HOUSES), {"force": force, "lim": limit}).mappings().all()
|
||
|
||
logger.info(
|
||
"yandex-nb-sweep: total=%d fetchable=%d pending=%d; "
|
||
"selected=%d (limit=%d force=%s dry_run=%s delay=%.1fs city=%s)",
|
||
result.total,
|
||
result.fetchable,
|
||
result.pending,
|
||
len(rows),
|
||
limit,
|
||
force,
|
||
dry_run,
|
||
delay,
|
||
city,
|
||
)
|
||
|
||
if dry_run:
|
||
logger.info(
|
||
"dry_run: would process %d yandex_realty_nb houses (ids=%s)",
|
||
len(rows),
|
||
[r["house_id"] for r in rows],
|
||
)
|
||
result.duration_sec = time.time() - t0
|
||
return result
|
||
|
||
for idx, row in enumerate(rows):
|
||
house_id: int = int(row["house_id"])
|
||
jk_slug: str | None = row["yandex_jk_slug"]
|
||
ext_id: str | None = row["ext_id"]
|
||
result.processed += 1
|
||
|
||
# Idempotency fast-path (belt-and-braces под concurrent writer)
|
||
if not force and jk_slug:
|
||
already = db.execute(
|
||
text(
|
||
"SELECT 1 FROM market.yandex_jk_enrichment WHERE ext_id = CAST(:ext_id AS text)"
|
||
),
|
||
{"ext_id": ext_id},
|
||
).fetchone()
|
||
if already is not None:
|
||
result.skipped_already_enriched += 1
|
||
logger.debug("skip house_id=%s — already enriched ext_id=%s", house_id, ext_id)
|
||
continue
|
||
|
||
# ── Resolve slug ─────────────────────────────────────────────────
|
||
if not jk_slug:
|
||
if not ext_id:
|
||
logger.warning("house_id=%s: нет yandex_jk_slug и нет ext_id — skip", house_id)
|
||
result.failed_resolve += 1
|
||
continue
|
||
|
||
try:
|
||
resolved = await resolve_yandex_jk_slug(ext_id, city=city)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"resolve_yandex_jk_slug house_id=%s ext_id=%s raised: %s",
|
||
house_id,
|
||
ext_id,
|
||
exc,
|
||
)
|
||
resolved = None
|
||
|
||
# Anti-bot sleep после resolve-fetch
|
||
await _sleep_with_jitter(delay, idx, len(rows), force=True)
|
||
|
||
if not resolved:
|
||
logger.warning("slug unresolved house_id=%s ext_id=%s — skip", house_id, ext_id)
|
||
result.failed_resolve += 1
|
||
continue
|
||
|
||
# Persist slug под SAVEPOINT
|
||
sp = db.begin_nested()
|
||
try:
|
||
db.execute(text(_UPDATE_SLUG), {"slug": resolved, "hid": house_id})
|
||
sp.commit()
|
||
db.commit()
|
||
except Exception as exc:
|
||
sp.rollback()
|
||
logger.warning(
|
||
"persist slug failed house_id=%s ext_id=%s: %s", house_id, ext_id, exc
|
||
)
|
||
result.failed_resolve += 1
|
||
continue
|
||
|
||
jk_slug = resolved
|
||
result.resolved_slug += 1
|
||
|
||
# ── Guard: ext_id обязателен для fetch_jk (формирует URL /{slug}-{id}/) ──
|
||
if not ext_id:
|
||
logger.warning(
|
||
"house_id=%s: jk_slug=%s известен, но ext_id пустой — "
|
||
"fetch_jk пропущен (пустой jk_id даёт битый URL)",
|
||
house_id,
|
||
jk_slug,
|
||
)
|
||
result.failed_fetch += 1
|
||
continue
|
||
|
||
# ── Fetch через BrowserFetcher ────────────────────────────────────
|
||
info = None
|
||
try:
|
||
scraper = YandexNewbuildingScraper()
|
||
info = await scraper.fetch_jk(jk_slug=jk_slug, jk_id=ext_id, city=city)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"fetch_jk failed house_id=%s jk_slug=%s ext_id=%s: %s",
|
||
house_id,
|
||
jk_slug,
|
||
ext_id,
|
||
exc,
|
||
)
|
||
result.failed_fetch += 1
|
||
await _sleep_with_jitter(delay, idx, len(rows))
|
||
continue
|
||
|
||
if info is None:
|
||
logger.warning(
|
||
"fetch_jk returned None house_id=%s jk_slug=%s (anti-bot / parse miss?)",
|
||
house_id,
|
||
jk_slug,
|
||
)
|
||
result.failed_fetch += 1
|
||
await _sleep_with_jitter(delay, idx, len(rows))
|
||
continue
|
||
|
||
# ── UPSERT market.yandex_jk_enrichment под SAVEPOINT ─────────────
|
||
sp = db.begin_nested()
|
||
try:
|
||
db.execute(
|
||
text(_UPSERT_ENRICHMENT),
|
||
{
|
||
"ext_id": info.ext_id,
|
||
"name": info.name,
|
||
"developer_name": info.developer_name,
|
||
"address": info.address,
|
||
"lat": info.lat,
|
||
"lon": info.lon,
|
||
"rating": info.rating,
|
||
"ratings_count": info.ratings_count,
|
||
"text_reviews_count": info.text_reviews_count,
|
||
"raw_payload": json.dumps(info.raw_payload or {}, ensure_ascii=False),
|
||
},
|
||
)
|
||
sp.commit()
|
||
db.commit()
|
||
result.rows_inserted += 1
|
||
result.succeeded += 1
|
||
logger.info(
|
||
"enriched house_id=%s ext_id=%s slug=%s name=%r",
|
||
house_id,
|
||
info.ext_id,
|
||
jk_slug,
|
||
info.name,
|
||
)
|
||
except Exception as exc:
|
||
sp.rollback()
|
||
logger.warning(
|
||
"UPSERT yandex_jk_enrichment failed house_id=%s ext_id=%s: %s",
|
||
house_id,
|
||
ext_id,
|
||
exc,
|
||
)
|
||
try:
|
||
db.rollback()
|
||
except Exception as rb_exc:
|
||
logger.warning("rollback failed house_id=%s: %s", house_id, rb_exc)
|
||
|
||
await _sleep_with_jitter(delay, idx, len(rows))
|
||
|
||
result.duration_sec = time.time() - t0
|
||
logger.info(
|
||
"yandex-nb-sweep done: processed=%d ok=%d skip=%d resolved=%d "
|
||
"resolve_fail=%d fetch_fail=%d rows_inserted=%d | %.1fs",
|
||
result.processed,
|
||
result.succeeded,
|
||
result.skipped_already_enriched,
|
||
result.resolved_slug,
|
||
result.failed_resolve,
|
||
result.failed_fetch,
|
||
result.rows_inserted,
|
||
result.duration_sec,
|
||
)
|
||
return result
|
||
|
||
|
||
async def _sleep_with_jitter(delay: float, idx: int, total: int, *, force: bool = False) -> None:
|
||
"""Polite anti-bot sleep с ±20% jitter.
|
||
|
||
Пропускается после последнего элемента (нет следующего fetch) — ЕСЛИ force=False.
|
||
force=True используется для resolve→enrich gap внутри одного дома.
|
||
"""
|
||
if delay <= 0:
|
||
return
|
||
if not force and idx >= total - 1:
|
||
return
|
||
await asyncio.sleep(delay * random.uniform(0.8, 1.2))
|