From 8c8289cdad36ffdd8e60683230434c88eed538ef Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 00:04:18 +0300 Subject: [PATCH] feat(tradein/scraper-kit): port domclick_detail + ekb_geoportal_client to kit (#2307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group D of the scraper_kit migration epic (#2277). Both legacy modules had no kit-side equivalent yet (per audit Scraper_Kit_Legacy_Dependency_Audit_0703), unlike the other 24/26 files which were already strangler-duplicated. - scraper_kit/providers/domclick/detail.py — Layer B detail-card enrichment (parse_detail_html/fetch_detail/save_detail_enrichment), no ScraperConfig injection needed (fetch_detail takes an already-open browser_fetcher). - scraper_kit/providers/ekb_geoportal/{__init__,client}.py — new subpackage, first non-listing-site provider in scraper_kit. Legacy module had zero app.* coupling (only httpx), so the port is near-verbatim. Parity proven via tests/support/parity.py (assert_parity) against the exact legacy functions on realistic fixtures (live-card SSR JSON, GeoJSON FeatureCollection). Callers of both legacy modules are unchanged (out of scope — ekb_geoportal_ingest switch is #2310; the only other domclick_detail caller is scripts/ingest_domclick_jsonl.py, also untouched). --- .../test_domclick_detail_kit_parity.py | 189 ++++++ .../test_ekb_geoportal_client_kit_parity.py | 117 ++++ .../providers/domclick/__init__.py | 4 +- .../scraper_kit/providers/domclick/detail.py | 600 ++++++++++++++++++ .../providers/ekb_geoportal/__init__.py | 17 + .../providers/ekb_geoportal/client.py | 244 +++++++ 6 files changed, 1170 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/scrapers/test_domclick_detail_kit_parity.py create mode 100644 tradein-mvp/backend/tests/scrapers/test_ekb_geoportal_client_kit_parity.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/ekb_geoportal/__init__.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/ekb_geoportal/client.py diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail_kit_parity.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail_kit_parity.py new file mode 100644 index 00000000..fae5c7aa --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail_kit_parity.py @@ -0,0 +1,189 @@ +"""Parity-proof: `app.services.scrapers.domclick_detail` vs +`scraper_kit.providers.domclick.detail` (issue #2307, Group D). + +`parse_detail_html` — parsing-ядро без сети/БД (SSR-стейт → dataclass), поэтому +сравнивается offline на фиксированной HTML-фикстуре через +`tests.support.parity.assert_parity` (harness #2304). Legacy и kit тела функции +на момент написания теста ИДЕНТИЧНЫ (см. audit +`Scraper_Kit_Legacy_Dependency_Audit_0703` в vault) — различаются только +import-пути зависимостей (`app.services.scrapers.*` vs `scraper_kit.*`). + +HTML-фикстура — независимая копия литерала из +`tests/scrapers/test_domclick_detail.py::_HTML` (тот же live-card ground truth +2075729321, 2026-06-27), чтобы этот тест не зависел от другого test-модуля. +""" + +# SSR-JSON фикстура содержит длинные строки + +from __future__ import annotations + +import dataclasses +import os + +import pytest + +# app.services.scrapers.domclick_detail не читает settings напрямую, но соседние +# модули пакета app.services.scrapers делают это при импорте app.core.config — +# фиктивный DSN мостит офлайн-запуск без CI-уровневого DATABASE_URL (mirror +# tests/scrapers/test_avito_detail_kit_parity.py). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.domclick_exceptions import ( + DomClickBlockedError as KitDomClickBlockedError, +) +from scraper_kit.domclick_exceptions import ( + DomClickParseError as KitDomClickParseError, +) +from scraper_kit.providers.domclick.detail import ( + parse_detail_html as kit_parse_detail_html, +) + +from app.services.scrapers.domclick_detail import ( + parse_detail_html as legacy_parse_detail_html, +) +from app.services.scrapers.domclick_exceptions import ( + DomClickBlockedError as LegacyDomClickBlockedError, +) +from app.services.scrapers.domclick_exceptions import ( + DomClickParseError as LegacyDomClickParseError, +) +from tests.support.parity import ParityMismatchError, assert_parity + +_CARD_URL = "https://ekaterinburg.domclick.ru/card/sale__flat__2075729321" + +# Real __SSR_STATE__ shape (ground truth, live card 2075729321, 2026-06-27). +# bare `undefined` (value-position), in-string "undefined", '{' внутри строки и +# эскейп-кавычки — написан вручную (json.dumps использовать нельзя, нужен +# именно НЕвалидный-для-json undefined). +_SSR_LITERAL = """{ + "productCard": { + "objectInfo": { + "area": 38.0, + "rooms": 1, + "floor": 5, + "isApartment": false, + "renovation": "евроремонт", + "livingArea": 22.37, + "kitchenArea": 10.13, + "description": "Состояние пока undefined.", + "note": "угол {da}", + "quote": "сказал \\"привет\\" сосед" + }, + "legalOptions": {"saleType": "Свободная продажа"}, + "egrnData": { + "area": 38.2, + "floor": 5, + "owners_count": 1, + "collateral": true, + "collateral_sber": false + }, + "priceInfo": { + "priceHistory": [ +{"date": "2026-04-15T09:02:26.548728+03:00", "price": 13400000, "diff": -400000, "state": "less"}, + {"date": "not-a-date", "price": 4800000, "diff": -1.0, "state": "less"}, + {"date": "2026-05-01T10:00:00Z", "price": null, "diff": 0, "state": "more"} + ] + }, + "viewsCount": 338, + "callsCount": 5, + "favoriteOfferUsersCount": 12, + "duplicatesOfferCount": 0, + "address": {"guid": "abc-guid-123"}, + "unknownField": undefined + }, + "houseInfo": { + "info": { + "buildYear": 2015, + "wallType": "Кирпично-монолитный", + "floorType": "Железобетонный", + "entranceCount": 3, + "quartersCount": 120, + "energyEfficiency": "B", + "buildingSeries": "индивидуальный" + } + }, + "pricePrediction": { + "market_price": 13000000, + "min_market_price": 12000000, + "max_market_price": 14000000, + "rent_short": 50000, + "rent_long": 35000, + "repair_quality": "good" + } +}""" + +_HTML = ( + "" +) + + +def test_legacy_and_kit_parse_detail_html_are_class_distinct() -> None: + """Sanity-check ДО harness'а: legacy/kit DomClickDetailEnrichment — РАЗНЫЕ + классы (разные модули) → обычный `==` даёт False, хотя данные идентичны. + Именно это harness обходит через структурное сравнение (dataclasses.fields). + """ + legacy_result = legacy_parse_detail_html(_HTML, _CARD_URL) + kit_result = kit_parse_detail_html(_HTML, _CARD_URL) + + assert type(legacy_result) is not type(kit_result) + assert legacy_result != kit_result # dataclass __eq__ проверяет class identity первым + assert dataclasses.asdict(legacy_result) == dataclasses.asdict(kit_result) + + +def test_parity_harness_confirms_domclick_detail_parse_equivalence() -> None: + """Реальное использование harness'а для DomClick Layer B (issue #2307).""" + assert_parity( + legacy_fn=legacy_parse_detail_html, + kit_fn=kit_parse_detail_html, + fixtures=[(_HTML, _CARD_URL)], + ) + + +def test_parity_harness_catches_real_field_divergence() -> None: + """Harness реально ЛОВИТ расхождение на реальной форме — мутируем один + field (``living_area_m2``) в kit-результате и убеждаемся, что harness + падает с ``ParityMismatchError``, называющим именно этот field. + """ + legacy_result = legacy_parse_detail_html(_HTML, _CARD_URL) + kit_result = kit_parse_detail_html(_HTML, _CARD_URL) + assert kit_result.living_area_m2 is not None # sanity: поле реально распарсилось + + mutated_kit_result = dataclasses.replace( + kit_result, living_area_m2=kit_result.living_area_m2 + 1 + ) + + with pytest.raises(ParityMismatchError) as exc_info: + assert_parity( + legacy_fn=lambda: legacy_result, + kit_fn=lambda: mutated_kit_result, + fixtures=[()], + ) + + assert "living_area_m2" in str(exc_info.value) + + +def test_legacy_and_kit_exceptions_behave_identically_on_challenge_page() -> None: + """Anti-bot challenge page (no __SSR_STATE__, block-маркеры) → оба пути + поднимают семантически эквивалентный DomClickBlockedError (разные классы, + те же условия детекции — зеркало `test_scraper_kit_domclick_golden_parity.py` + для Layer A exceptions). + """ + challenge_html = "Access denied by DataDome captcha" + + with pytest.raises(LegacyDomClickBlockedError): + legacy_parse_detail_html(challenge_html, _CARD_URL) + with pytest.raises(KitDomClickBlockedError): + kit_parse_detail_html(challenge_html, _CARD_URL) + + +def test_legacy_and_kit_exceptions_behave_identically_on_missing_state() -> None: + """HTML без __SSR_STATE__ и без block-маркеров → оба пути поднимают + DomClickParseError (дрейф схемы / повреждённый ответ, не блок).""" + no_state_html = "no ssr state here" + + with pytest.raises(LegacyDomClickParseError): + legacy_parse_detail_html(no_state_html, _CARD_URL) + with pytest.raises(KitDomClickParseError): + kit_parse_detail_html(no_state_html, _CARD_URL) diff --git a/tradein-mvp/backend/tests/scrapers/test_ekb_geoportal_client_kit_parity.py b/tradein-mvp/backend/tests/scrapers/test_ekb_geoportal_client_kit_parity.py new file mode 100644 index 00000000..02d0a5c6 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_ekb_geoportal_client_kit_parity.py @@ -0,0 +1,117 @@ +"""Parity-proof: `app.services.scrapers.ekb_geoportal_client` vs +`scraper_kit.providers.ekb_geoportal.client` (issue #2307, Group D). + +`parse_feature_collection` (GeoJSON FeatureCollection → list[TopoBuilding], +area-weighted centroid) — чистая функция без сети/БД, поэтому сравнивается +offline через `tests.support.parity.assert_parity` (harness #2304). Legacy и +kit тела функции на момент написания теста ИДЕНТИЧНЫ (см. audit +`Scraper_Kit_Legacy_Dependency_Audit_0703` в vault) — единственное отличие — +import-путь модуля. + +GeoJSON-фикстуры — независимая копия из +`tests/test_ekb_geoportal_ingest.py::test_parse_feature_collection_basic`, +чтобы этот тест не зависел от другого test-модуля. +""" + +from __future__ import annotations + +import dataclasses + +import pytest +from scraper_kit.providers.ekb_geoportal.client import ( + TopoBuilding as KitTopoBuilding, +) +from scraper_kit.providers.ekb_geoportal.client import ( + parse_feature_collection as kit_parse_feature_collection, +) + +from app.services.scrapers.ekb_geoportal_client import ( + TopoBuilding as LegacyTopoBuilding, +) +from app.services.scrapers.ekb_geoportal_client import ( + parse_feature_collection as legacy_parse_feature_collection, +) +from tests.support.parity import ParityMismatchError, assert_parity + +# A unit square [0,0]-[2,2] → centroid (1, 1) in (lon, lat). +_SQUARE = [[[[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0], [0.0, 0.0]]]] + + +def _multipolygon(coords: list) -> dict: + return {"type": "MultiPolygon", "coordinates": coords} + + +_FEATURE_COLLECTION = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": _multipolygon(_SQUARE), + "properties": { + "topo_street": "Космонавтов", + "topo_num_house": "7б", + "topo_floor": 9, + "topo_purpose": "жилое", + "topo_material": "3", + "key": 12345, + }, + }, + { + # без street/house — должен быть пропущен обоими путями + "type": "Feature", + "geometry": _multipolygon(_SQUARE), + "properties": {"topo_street": "", "topo_num_house": "5"}, + }, + ], +} + + +def test_legacy_and_kit_topo_building_are_class_distinct() -> None: + """Sanity-check ДО harness'а: legacy/kit TopoBuilding — РАЗНЫЕ классы + (разные модули) → обычный `==` даёт False, хотя данные идентичны.""" + legacy_result = legacy_parse_feature_collection(_FEATURE_COLLECTION) + kit_result = kit_parse_feature_collection(_FEATURE_COLLECTION) + + assert len(legacy_result) == len(kit_result) == 1 + assert type(legacy_result[0]) is not type(kit_result[0]) + assert legacy_result[0] != kit_result[0] # dataclass __eq__ проверяет class identity первым + assert dataclasses.asdict(legacy_result[0]) == dataclasses.asdict(kit_result[0]) + + +def test_parity_harness_confirms_ekb_geoportal_parse_equivalence() -> None: + """Реальное использование harness'а для EKB Geoportal client (issue #2307).""" + assert_parity( + legacy_fn=legacy_parse_feature_collection, + kit_fn=kit_parse_feature_collection, + fixtures=[(_FEATURE_COLLECTION,)], + ) + + +def test_parity_harness_catches_real_field_divergence() -> None: + """Harness реально ЛОВИТ расхождение — мутируем ``floors`` в kit-результате + и убеждаемся, что harness падает с ``ParityMismatchError``, называющим + именно этот field.""" + legacy_result = legacy_parse_feature_collection(_FEATURE_COLLECTION) + kit_result = kit_parse_feature_collection(_FEATURE_COLLECTION) + assert kit_result[0].floors == 9 # sanity: поле реально распарсилось + + mutated_kit_building = dataclasses.replace(kit_result[0], floors=1) + assert isinstance(mutated_kit_building, KitTopoBuilding) + assert isinstance(legacy_result[0], LegacyTopoBuilding) + + with pytest.raises(ParityMismatchError) as exc_info: + assert_parity( + legacy_fn=lambda: legacy_result, + kit_fn=lambda: [mutated_kit_building], + fixtures=[()], + ) + + assert "floors" in str(exc_info.value) + + +def test_legacy_and_kit_empty_and_malformed_inputs_match() -> None: + """Graceful-degradation ветки (пустой/битый payload) дают одинаковый [] + на обоих путях.""" + for payload in ({}, {"features": "nope"}, {"features": [None, 42]}): + assert legacy_parse_feature_collection(payload) == [] + assert kit_parse_feature_collection(payload) == [] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/__init__.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/__init__.py index a95ad960..6f380b14 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/__init__.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/__init__.py @@ -1,8 +1,10 @@ """DomClick-провайдер scraper_kit — strangler-копия `app.services.scrapers.domclick`. Модули: - - serp — BFF JSON API sweep (DomClickScraper, room×price bisection, + - serp — BFF JSON API sweep (DomClickScraper, room×price bisection, offer-item → ScrapedLot маппинг) + - detail — Layer B detail-card enrichment (parse_detail_html → SSR-стейт, + fetch_detail, save_detail_enrichment) — strangler-порт #2307 Развязка от `app.*`: - `app.core.config.settings.browser_http_endpoint` → инжектируемый `ScraperConfig` diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py new file mode 100644 index 00000000..aabe9c72 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py @@ -0,0 +1,600 @@ +"""DomClick.ru Layer B — detail-card enrichment. + +Strangler-порт (#2307) боевого `app.services.scrapers.domclick_detail`, +развязанный от `app.*` (parity доказана +tests/scrapers/test_domclick_detail_kit_parity.py). + +Layer A (`scraper_kit.providers.domclick.serp`) собирает базовые поля через BFF +JSON API. Layer B дочитывает страницу карточки +(``https://ekaterinburg.domclick.ru/card/sale__flat__``) и обогащает +listings: renovation/repair_state, living/kitchen area, balconies, sale_type, +owners_count, encumbrances, year_built, views и priceHistory. + +Транспорт — BrowserFetcher через QRATOR-clean прокси (source="cian"): card-хосты +DomClick заблокированы QRATOR через domclick/avito-прокси, но чисты через cian +(verified live 2026-06-27). curl_cffi на card-хосте отдаёт 401/стаб. fetch_detail +принимает уже открытый browser_fetcher; жизненным циклом сессии и выбором source +владеет оркестратор — поэтому, в отличие от соседних provider-модулей, здесь НЕ +нужна инжекция `ScraperConfig` (нет собственного HTTP-клиента/прокси-конфига). + +SSR-quirk +--------- +HTML карточки встраивает ``window.__SSR_STATE__ = {...}`` — это JS object literal, +а НЕ чистый JSON: в value-позиции встречается bare ``undefined`` (json.loads падает). +_extract_ssr_state делает balanced-brace scan (с пропуском скобок внутри строк) и +заменяет bare ``undefined`` → ``null`` строго в value-позиции, не трогая строки. + +Форма SSR-стейта подтверждена на живой карточке 2075729321 (2026-06-27): +productCard.objectInfo.{renovation,livingArea,kitchenArea}, productCard.priceInfo. +priceHistory ({date ISO8601+tz, price, diff, state}), productCard.egrnData +(snake_case owners_count/collateral/collateral_sber), productCard.legalOptions. +saleType, productCard.viewsCount/callsCount, houseInfo.info.{wallType,floorType}, +и ТОП-УРОВНЕМ pricePrediction (DomClick AVM → raw_extra.avm). + +Развязка от `app.*`: + - `app.services.scrapers.domclick_exceptions` → `scraper_kit.domclick_exceptions` + (уже идентичная копия, см. audit `Scraper_Kit_Legacy_Dependency_Audit_0703`) + - `app.services.scrapers.repair_state_normalizer` → `scraper_kit.repair_state_normalizer` + - `app.services.scrapers.browser_fetcher.BrowserFetcher` → + `scraper_kit.browser_fetcher.BrowserFetcher` (TYPE_CHECKING-only, зеркало + `scraper_kit.providers.avito.detail`) + - `save_detail_enrichment` пишет через SQLAlchemy напрямую (как соседние + provider-модули `avito/detail.py` / `cian/detail.py`) — DB-доступ не product-specific, + инжекция не нужна. +""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError +from scraper_kit.repair_state_normalizer import ( + infer_repair_state_from_text, + normalize_repair_state, +) + +if TYPE_CHECKING: + from scraper_kit.browser_fetcher import BrowserFetcher + +logger = logging.getLogger(__name__) + +# ── Маркеры anti-bot challenge (DataDome / QRATOR) ─────────────────────────── +# Зеркало идеи Layer A: если __SSR_STATE__ отсутствует И страница похожа на +# challenge — это блок, а не parse-failure. +_BLOCK_MARKERS: tuple[str, ...] = ("qrator", "captcha", "datadome", "access denied") + +# ── item_id из URL карточки: .../card/sale__flat__2075729321 ───────────────── +_ITEM_ID_RE = re.compile(r"sale__flat__(\d+)") + +# ── SSR assignment marker ──────────────────────────────────────────────────── +_SSR_ASSIGN_RE = re.compile(r"window\.__SSR_STATE__\s*=\s*") + +# ── bare `undefined` (value-позиция) → null, не трогая строки ───────────────── +_UNDEFINED_RE = re.compile(r"(?<=[:\[,\s])undefined(?=[\s,\]\}])") + +# ── renovation (RU) → каноничный repair_state enum ─────────────────────────── +# ЛОКАЛЬНАЯ карта (не трогаем shared _RAW_TO_ENUM в repair_state_normalizer). +# Значения уже каноничны; прогоняются через normalize_repair_state для валидации. +_RENOVATION_MAP: dict[str, str] = { + "евроремонт": "good", + "косметический": "standard", + "дизайнерский": "excellent", + "черновая": "needs_repair", + "без отделки": "needs_repair", + "требует ремонта": "needs_repair", + "предчистовая": "needs_repair", +} + + +# ── DomClickDetailEnrichment ────────────────────────────────────────────────── +@dataclass +class DomClickDetailEnrichment: + """Результат парсинга detail-карточки DomClick (Layer B). + + save_detail_enrichment() пишет подмножество в listings (+ offer_price_history). + Все поля None-safe: отсутствующее → None (не крэш). + """ + + item_id: str | None = None + source_url: str | None = None + + repair_state: str | None = None # enum: needs_repair/standard/good/excellent + repair_type: str | None = None # сырое RU-значение renovation + living_area_m2: float | None = None + kitchen_area_m2: float | None = None + balconies_count: int | None = None + has_balcony: bool | None = None + sale_type: str | None = None + owners_count: int | None = None + encumbrances_clean: bool | None = None # True=чисто, False=есть обременение + year_built: int | None = None + views_total: int | None = None + + price_changes: list[dict[str, Any]] = field(default_factory=list) + raw_extra: dict[str, Any] = field(default_factory=dict) + + +# ── SSR state extraction ────────────────────────────────────────────────────── + + +def _scan_balanced_object(s: str, start: int) -> str: + """Сканирует от '{' по индексу ``start`` до парной '}'. + + Отслеживает глубину по {/}, ИГНОРИРУЯ скобки внутри JS-строк (одинарные и + двойные кавычки с ``\\``-эскейпами). Возвращает литерал ``{...}`` включительно. + + Raises: + DomClickParseError: если парная скобка не найдена (truncated HTML). + """ + depth = 0 + in_string = False + quote_char = "" + escaped = False + for i in range(start, len(s)): + ch = s[i] + if in_string: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == quote_char: + in_string = False + continue + if ch in ('"', "'"): + in_string = True + quote_char = ch + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return s[start : i + 1] + raise DomClickParseError("__SSR_STATE__ object not balanced (no matching brace)") + + +def _extract_ssr_state(html: str) -> dict[str, Any]: + """Извлекает ``window.__SSR_STATE__`` object-литерал из HTML карточки. + + Шаги: + 1. Находим присваивание ``window.__SSR_STATE__ =``. Если нет — различаем + challenge-страницу (→ DomClickBlockedError) и просто отсутствие + стейта (→ DomClickParseError). + 2. Balanced-brace scan от первого '{' → литерал. + 3. Санитайз bare ``undefined`` → ``null`` (только value-позиция). + 4. json.loads. + + Raises: + DomClickBlockedError: страница без стейта + маркеры anti-bot. + DomClickParseError: стейт не найден / не сбалансирован / json.loads упал. + """ + match = _SSR_ASSIGN_RE.search(html) + if match is None: + html_lower = html.lower() + if any(marker in html_lower for marker in _BLOCK_MARKERS): + raise DomClickBlockedError( + "DomClick detail: challenge page detected (no __SSR_STATE__, " + f"markers checked: {_BLOCK_MARKERS})" + ) + raise DomClickParseError("__SSR_STATE__ not found") + + brace_start = html.find("{", match.end()) + if brace_start == -1: + raise DomClickParseError("__SSR_STATE__ assignment has no opening '{'") + + literal = _scan_balanced_object(html, brace_start) + sanitized = _UNDEFINED_RE.sub("null", literal) + try: + data = json.loads(sanitized) + except json.JSONDecodeError as exc: + raise DomClickParseError( + f"__SSR_STATE__ json.loads failed: {exc} (head={sanitized[:200]!r})" + ) from exc + if not isinstance(data, dict): + raise DomClickParseError(f"__SSR_STATE__ is not an object: {type(data)}") + return data + + +# ── Value coercion helpers ──────────────────────────────────────────────────── + + +def _to_float(value: Any) -> float | None: + """None-safe float. bool/неконвертируемое → None.""" + if value is None or isinstance(value, bool): + return None + try: + return float(value) + except (ValueError, TypeError): + return None + + +def _to_int(value: Any) -> int | None: + """None-safe int. bool/неконвертируемое → None.""" + if value is None or isinstance(value, bool): + return None + try: + return int(value) + except (ValueError, TypeError): + return None + + +def _parse_change_time(value: Any) -> datetime | None: + """Нормализует дату изменения цены → timezone-aware datetime. + + Адаптация Layer A ``_parse_publish_date`` (которая возвращала date): здесь + поддерживаем epoch (сек/мс), числовую строку и ISO8601. Real priceHistory.date + приходит как ``2026-04-15T09:02:26.548728+03:00`` — fromisoformat СОХРАНЯЕТ + исходный offset (+03:00), НЕ конвертирует в UTC; naive-значения получают UTC. + epoch трактуем как UTC. Невалидное → None (caller пропускает запись). + """ + if value is None or isinstance(value, bool): + return None + # epoch (число): >1e12 трактуем как миллисекунды + if isinstance(value, int | float): + ts = float(value) + if ts > 1e12: + ts /= 1000.0 + try: + return datetime.fromtimestamp(ts, tz=UTC) + except (ValueError, OSError, OverflowError): + return None + s = str(value).strip() + if not s: + return None + if s.isdigit(): + ts = float(s) + if ts > 1e12: + ts /= 1000.0 + try: + return datetime.fromtimestamp(ts, tz=UTC) + except (ValueError, OSError, OverflowError): + return None + # ISO8601 (поддержим trailing 'Z') + try: + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + except (ValueError, TypeError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt + + +def _map_repair_state(renovation: str | None) -> str | None: + """RU renovation → каноничный enum. Нет совпадения в карте → инференс из текста.""" + if not renovation: + return None + mapped = _RENOVATION_MAP.get(renovation.strip().lower()) + if mapped is not None: + return normalize_repair_state(mapped) + return infer_repair_state_from_text(renovation) + + +def _compact(d: dict[str, Any]) -> dict[str, Any]: + """Выкидывает None-значения, чтобы raw_extra оставался компактным.""" + return {k: v for k, v in d.items() if v is not None} + + +def _pick(d: dict[str, Any], *keys: str) -> Any: + """Первое не-None значение среди ключей (defensive snake/camel spellings).""" + for k in keys: + v = d.get(k) + if v is not None: + return v + return None + + +# ── item_id ─────────────────────────────────────────────────────────────────── + + +def _extract_item_id(url: str) -> str | None: + """sale__flat__ из URL карточки.""" + m = _ITEM_ID_RE.search(url or "") + return m.group(1) if m else None + + +# ── parse_detail_html ────────────────────────────────────────────────────────── + + +def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment: + """Pure parser detail-карточки DomClick (без сети/БД). + + _extract_ssr_state может бросить DomClickBlockedError/DomClickParseError — + они propagate (оркестратор различает блок и parse-failure). Дальнейшая + навигация по полям обёрнута: per-field сюрприз формы → минимальный + enrichment (item_id+url), а не крэш. + """ + state = _extract_ssr_state(html) + item_id = _extract_item_id(source_url) + try: + pc = state.get("productCard") or {} + oi = pc.get("objectInfo") or {} + hi = (state.get("houseInfo") or {}).get("info") or {} + egrn = pc.get("egrnData") or {} + + # repair: enum + сырое значение + renovation = oi.get("renovation") + repair_type = renovation if isinstance(renovation, str) else None + repair_state = _map_repair_state(repair_type) + + # площади + living_area_m2 = _to_float(oi.get("livingArea")) + kitchen_area_m2 = _to_float(oi.get("kitchenArea")) + + # балконы: int → count+флаг; иное (строка/др.) → raw_extra, counts None + balconies = oi.get("balconies") + balconies_count: int | None = None + has_balcony: bool | None = None + balconies_raw: Any = None + if isinstance(balconies, bool): + balconies_raw = balconies + elif isinstance(balconies, int): + balconies_count = balconies + has_balcony = balconies > 0 + elif balconies is not None: + balconies_raw = balconies + + sale_type = (pc.get("legalOptions") or {}).get("saleType") + + # owners_count — защищаемся от двух написаний ключа + owners_count = _to_int(egrn.get("owners_count")) + if owners_count is None: + owners_count = _to_int(egrn.get("ownersCount")) + + # encumbrances_clean (ИНВЕРСИЯ): collateral/collateral_sber обозначают + # НАЛИЧИЕ обременения. Любой truthy → clean=False; оба отсутствуют/false + # при наличии egrnData → True; egrnData нет → None. + collateral = egrn.get("collateral") + collateral_sber = egrn.get("collateral_sber") + if egrn: + encumbrances_clean: bool | None = not bool(collateral or collateral_sber) + else: + encumbrances_clean = None + + year_built = _to_int(hi.get("buildYear")) + views_total = _to_int(pc.get("viewsCount")) + + # priceHistory → нормализованные записи (skip при битой дате / без цены) + raw_history = (pc.get("priceInfo") or {}).get("priceHistory") or [] + price_changes: list[dict[str, Any]] = [] + if isinstance(raw_history, list): + for entry in raw_history: + if not isinstance(entry, dict): + continue + change_time = _parse_change_time(entry.get("date")) + price_rub = _to_int(entry.get("price")) + # Skip при нераспарсенной дате ИЛИ отсутствующей/невалидной цене. + if change_time is None or price_rub is None: + continue + diff = entry.get("diff") + diff_percent = ( + diff if isinstance(diff, int | float) and not isinstance(diff, bool) else None + ) + price_changes.append( + { + "change_time": change_time, + "price_rub": price_rub, + "diff_percent": diff_percent, + } + ) + + # AVM (Layer C, тот же fetch) — DomClick price prediction живёт ТОП-УРОВНЕМ + # в SSR-стейте. Может быть плоским или обёрнут в .result/.data — defensively + # разворачиваем. В raw_extra пишем только присутствующие поля. + avm_root = state.get("pricePrediction") or {} + avm_src = avm_root + if isinstance(avm_root, dict): + avm_src = avm_root.get("result") or avm_root.get("data") or avm_root + if not isinstance(avm_src, dict): + avm_src = {} + avm = _compact( + { + "market_price": _pick(avm_src, "market_price", "marketPrice"), + "min": _pick(avm_src, "min_market_price", "minMarketPrice"), + "max": _pick(avm_src, "max_market_price", "maxMarketPrice"), + "repair_quality": _pick(avm_src, "repair_quality", "repairQuality"), + } + ) + + # raw_extra → merge в listings.raw_payload. wall/floor type ОСТАЮТСЯ тут, + # НЕ пишем в listings.house_type (кросс-источниковый словарь грязный). + # egrn area key — точное написание не подтверждено; пробуем несколько. + egrn_area = egrn.get("area") or egrn.get("rosreestrArea") or egrn.get("object_area") + demand = _compact( + { + "calls": pc.get("callsCount"), + "favorites": pc.get("favoriteOfferUsersCount"), + "duplicates": pc.get("duplicatesOfferCount"), + } + ) + raw_extra = _compact( + { + "wall_type": hi.get("wallType") or (pc.get("house") or {}).get("wallType"), + "floor_type": hi.get("floorType"), + "entrance_count": hi.get("entranceCount"), + "quarters_count": hi.get("quartersCount"), + "energy_efficiency": hi.get("energyEfficiency"), + "building_series": hi.get("buildingSeries"), + "domclick_building_guid": (pc.get("address") or {}).get("guid"), + "egrn_area": egrn_area, + "demand": demand or None, + "collateral": collateral, + "collateral_sber": collateral_sber, + "balconies_raw": balconies_raw, + "avm": avm or None, + } + ) + + return DomClickDetailEnrichment( + item_id=item_id, + source_url=source_url, + repair_state=repair_state, + repair_type=repair_type, + living_area_m2=living_area_m2, + kitchen_area_m2=kitchen_area_m2, + balconies_count=balconies_count, + has_balcony=has_balcony, + sale_type=sale_type if isinstance(sale_type, str) else None, + owners_count=owners_count, + encumbrances_clean=encumbrances_clean, + year_built=year_built, + views_total=views_total, + price_changes=price_changes, + raw_extra=raw_extra, + ) + except Exception: + logger.warning( + "domclick_detail: field navigation failed for %s — minimal enrichment", + source_url, + exc_info=True, + ) + return DomClickDetailEnrichment(item_id=item_id, source_url=source_url) + + +# ── fetch_detail ──────────────────────────────────────────────────────────────── + + +async def fetch_detail( + card_url: str, + *, + browser_fetcher: BrowserFetcher, +) -> DomClickDetailEnrichment: + """Fetch абсолютного URL карточки через BrowserFetcher → parse_detail_html. + + card_url уже абсолютный (listings.source_url из Layer A), URL не строим. + Сессией владеет оркестратор (передаёт уже открытый browser_fetcher). + + Raises: + DomClickBlockedError: anti-bot challenge ИЛИ ошибка браузерного fetch + (нет curl-fallback — DataDome даёт 401 на curl). Оркестратор считает + это блоком и помечает listing failed. + DomClickParseError: HTTP 200, но SSR-стейт не парсится (дрейф схемы). + """ + try: + html = await browser_fetcher.fetch(card_url) + except DomClickBlockedError: + raise + except Exception as exc: + raise DomClickBlockedError( + f"DomClick detail browser fetch failed for {card_url}: {exc}" + ) from exc + return parse_detail_html(html, card_url) + + +# ── save_detail_enrichment ────────────────────────────────────────────────────── + + +def save_detail_enrichment( + db: Session, + listing_id: int, + e: DomClickDetailEnrichment, +) -> bool: + """Persist enrichment в одной транзакции (commit в конце; rollback — за caller). + + 1. UPDATE listings SET (COALESCE — None не затирает существующее), + raw_payload || raw_extra, detail_enriched_at=NOW() WHERE id=listing_id. + 2. Для каждой записи priceHistory — INSERT INTO offer_price_history + (ON CONFLICT DO NOTHING → идемпотентно). source='domklik'. + + Snapshot (upsert_listing_snapshot) НЕ пишем: enrichment не несёт текущую цену, + а detail-backfill цену не меняет — snapshot остаётся за SERP-путём (note). + + Returns: + True если UPDATE задел строку (listing найден), иначе False. + """ + raw_extra_json = json.dumps(e.raw_extra or {}, ensure_ascii=False, default=str) + + result = db.execute( + text(""" + UPDATE listings SET + repair_state = COALESCE(:repair_state, repair_state), + repair_type = COALESCE(:repair_type, repair_type), + living_area_m2 = COALESCE(:living_area_m2, living_area_m2), + kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2), + balconies_count = COALESCE(:balconies_count, balconies_count), + has_balcony = COALESCE(:has_balcony, has_balcony), + sale_type = COALESCE(:sale_type, sale_type), + owners_count = COALESCE(:owners_count, owners_count), + encumbrances_clean = COALESCE(:encumbrances_clean, encumbrances_clean), + year_built = COALESCE(:year_built, year_built), + views_total = COALESCE(:views_total, views_total), + raw_payload = COALESCE(raw_payload, '{}'::jsonb) || CAST(:raw_extra AS jsonb), + detail_enriched_at = NOW() + WHERE id = CAST(:lid AS bigint) + """), + { + "lid": listing_id, + "repair_state": e.repair_state, + "repair_type": e.repair_type, + "living_area_m2": e.living_area_m2, + "kitchen_area_m2": e.kitchen_area_m2, + "balconies_count": e.balconies_count, + "has_balcony": e.has_balcony, + "sale_type": e.sale_type, + "owners_count": e.owners_count, + "encumbrances_clean": e.encumbrances_clean, + "year_built": e.year_built, + "views_total": e.views_total, + "raw_extra": raw_extra_json, + }, + ) + + # priceHistory — INSERT, де-дуп по (listing_id, change_time). SAVEPOINT per-row + # (backend.md): битый cast одной записи не валит весь UPDATE+commit. + for change in e.price_changes: + ct = change.get("change_time") + price = change.get("price_rub") + if not ct or not price: + continue + try: + with db.begin_nested(): + db.execute( + text(""" + INSERT INTO offer_price_history + (listing_id, change_time, price_rub, diff_percent, source) + VALUES ( + CAST(:lid AS bigint), + CAST(:ct AS timestamptz), + CAST(:price AS numeric), + CAST(:diff AS numeric), + 'domklik' + ) + ON CONFLICT ON CONSTRAINT offer_price_history_listing_change_uq + DO NOTHING + """), + { + "lid": listing_id, + "ct": ct, + "price": price, + "diff": change.get("diff_percent"), + }, + ) + except Exception as exc: + logger.warning( + "domclick_detail: price-history insert failed listing_id=%s ct=%s: %s", + listing_id, + ct, + exc, + ) + + db.commit() + found = result.rowcount > 0 + if not found: + logger.warning( + "domclick_detail save: listing not found id=%s (item_id=%s)", + listing_id, + e.item_id, + ) + else: + logger.info( + "domclick_detail save: listing id=%s enriched (price_changes=%d)", + listing_id, + len(e.price_changes), + ) + return found diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/ekb_geoportal/__init__.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/ekb_geoportal/__init__.py new file mode 100644 index 00000000..b748954a --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/ekb_geoportal/__init__.py @@ -0,0 +1,17 @@ +"""EKB Geoportal провайдер scraper_kit — strangler-копия +`app.services.scrapers.ekb_geoportal_client` (#2307). + +Первый non-listing-site провайдер в `scraper_kit.providers`: не скрапит +объявления с площадок (avito/cian/domclick/yandex), а тянет открытый WFS +городского геопортала ЕКБ (реестр футпринтов зданий) для geo-матчинга. Модуль +уже был "kit-clean" в legacy-виде — никаких `app.*`-зависимостей (только +`httpx`), поэтому порт почти дословный, без Protocol-инжекции. + +Модули: + - client — EkbGeoportalClient (WFS GetFeature), TopoBuilding, + parse_feature_collection (GeoJSON → dataclass), area-weighted centroid + (чистый Python, без shapely). + +Легаси-вызывающий (`app.tasks.ekb_geoportal_ingest`) на момент порта ЕЩЁ НЕ +переключён на этот модуль — переключение см. issue #2310 (Group C). +""" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/ekb_geoportal/client.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/ekb_geoportal/client.py new file mode 100644 index 00000000..bbe88e6d --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/ekb_geoportal/client.py @@ -0,0 +1,244 @@ +"""Клиент городского геопортала Екатеринбурга (геопортал.екатеринбург.рф). + +Strangler-порт (#2307) боевого `app.services.scrapers.ekb_geoportal_client`, +дословный (модуль изначально не зависел от `app.*` — только `httpx`, поэтому +Protocol-инжекция контрактов не требуется). Parity доказана +tests/scrapers/test_ekb_geoportal_client_kit_parity.py. + +Открытый GeoServer WFS, без авторизации. Слой `portal:portal_geo_topo_build` — +футпринты зданий ЕКБ (намного полнее чем NSPD cad_buildings, который пропускает +~70% зданий города). + +ВАЖНО: сервер отдаёт 403 на дефолтный User-Agent curl/httpx. Нужны браузерные +заголовки (UA + Referer + Accept) — тогда 200. + +Геометрия `geoloc` в EPSG:4326 (MultiPolygon footprint). BBOX axis order = +lon_min,lat_min,lon_max,lat_max. Сервер режет каждый ответ на 20000 features — +лоадер тайлит мелко, чтобы не упереться. + +Центроид считается чистым Python'ом (area-weighted, без shapely — shapely не входит +в зависимости tradein-mvp backend). + +Легаси-версия используется лоадером `app.tasks.ekb_geoportal_ingest` (переключение +на этот kit-модуль — issue #2310). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import httpx + +logger = logging.getLogger(__name__) + +# Punycode-хост геопортал.екатеринбург.рф +GEOPORTAL_HOST = "https://xn--80afgznagjs.xn--80acgfbsl1azdqr.xn--p1ai" +WFS_PATH = "/api/gis/ows/3/portal/wfs" +BUILD_LAYER = "portal:portal_geo_topo_build" + +# Браузерные заголовки — без них хост отдаёт 403. +_BROWSER_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/148.0.0.0 Safari/537.36" + ), + "Referer": f"{GEOPORTAL_HOST}/", + "Accept": "application/json", +} + + +@dataclass(frozen=True, slots=True) +class TopoBuilding: + """Одна часть-фича здания из слоя portal_geo_topo_build. + + Здание может быть разбито на несколько part-features (одинаковые street/house, + разные key) — лоадер группирует их по (street_norm, house_norm). + """ + + street: str + house: str + floors: int | None + purpose: str | None + material: str | None + key: int | None + lat: float + lon: float + + +def _parse_int(value: object) -> int | None: + if value is None: + return None + try: + return int(value) + except (ValueError, TypeError): + return None + + +def _parse_str(value: object) -> str | None: + if value is None: + return None + s = str(value).strip() + return s or None + + +def _ring_centroid(ring: list) -> tuple[float, float, float] | None: + """Area-weighted centroid of one linear ring → (cx, cy, signed_area2). + + signed_area2 = 2× signed area (используется как вес при усреднении колец/полигонов). + Возвращает None если кольцо вырождено (нулевая площадь). + """ + n = len(ring) + if n < 3: + return None + a2 = 0.0 # 2× signed area + cx = 0.0 + cy = 0.0 + for i in range(n): + x0, y0 = ring[i][0], ring[i][1] + x1, y1 = ring[(i + 1) % n][0], ring[(i + 1) % n][1] + cross = x0 * y1 - x1 * y0 + a2 += cross + cx += (x0 + x1) * cross + cy += (y0 + y1) * cross + if a2 == 0.0: + return None + cx /= 3.0 * a2 + cy /= 3.0 * a2 + return (cx, cy, a2) + + +def _multipolygon_centroid(coords: list) -> tuple[float, float] | None: + """Центроид GeoJSON MultiPolygon/Polygon-частей: усреднение по |площади| внешних колец. + + coords — список полигонов, каждый = [outer_ring, hole1, ...]. Берём только + внешнее кольцо каждого полигона (дыр у footprint'ов зданий практически нет, + их вклад в положение центра пренебрежим). Возвращает (lon, lat) или None. + """ + sum_w = 0.0 + sum_x = 0.0 + sum_y = 0.0 + for polygon in coords: + if not polygon: + continue + outer = polygon[0] + c = _ring_centroid(outer) + if c is None: + continue + cx, cy, a2 = c + w = abs(a2) + sum_w += w + sum_x += cx * w + sum_y += cy * w + if sum_w == 0.0: + return None + return (sum_x / sum_w, sum_y / sum_w) + + +def _geometry_centroid(geom: dict) -> tuple[float, float] | None: + """Центроид GeoJSON geometry (MultiPolygon или Polygon) → (lon, lat) или None.""" + gtype = geom.get("type") + coords = geom.get("coordinates") + if not coords: + return None + if gtype == "MultiPolygon": + return _multipolygon_centroid(coords) + if gtype == "Polygon": + # Polygon coordinates = [outer_ring, hole, ...] → обернём в один полигон. + return _multipolygon_centroid([coords]) + return None + + +def _feature_to_building(feature: dict) -> TopoBuilding | None: + """GeoJSON Feature → TopoBuilding. None если нет улицы/дома или геометрии.""" + props = feature.get("properties") or {} + street = _parse_str(props.get("topo_street")) + house = _parse_str(props.get("topo_num_house")) + if not street or not house: + return None + + geom = feature.get("geometry") + if not isinstance(geom, dict): + return None + try: + centroid = _geometry_centroid(geom) + except Exception: + logger.warning("geoportal: bad geometry for street=%r house=%r", street, house) + return None + if centroid is None: + return None + lon, lat = centroid + + return TopoBuilding( + street=street, + house=house, + floors=_parse_int(props.get("topo_floor")), + purpose=_parse_str(props.get("topo_purpose")), + material=_parse_str(props.get("topo_material")), + key=_parse_int(props.get("key")), + lat=lat, + lon=lon, + ) + + +def parse_feature_collection(payload: dict) -> list[TopoBuilding]: + """GeoJSON FeatureCollection → list[TopoBuilding]. Пропускает невалидные.""" + features = payload.get("features") + if not isinstance(features, list): + return [] + out: list[TopoBuilding] = [] + for feature in features: + if not isinstance(feature, dict): + continue + building = _feature_to_building(feature) + if building is not None: + out.append(building) + return out + + +class EkbGeoportalClient: + """Async WFS-клиент геопортала ЕКБ. Один клиент переиспользуется лоадером.""" + + def __init__(self, *, timeout: float = 30.0) -> None: + self._timeout = timeout + + async def fetch_topo_buildings( + self, + bbox: tuple[float, float, float, float], + *, + count: int = 20000, + ) -> list[TopoBuilding]: + """Запрашивает здания в bbox (lon_min, lat_min, lon_max, lat_max). + + Graceful: на non-200/parse-error логирует warning и возвращает []. + """ + lon_min, lat_min, lon_max, lat_max = bbox + cql = f"BBOX(geoloc,{lon_min},{lat_min},{lon_max},{lat_max})" + params = { + "service": "WFS", + "version": "2.0.0", + "request": "GetFeature", + "typeNames": BUILD_LAYER, + "outputFormat": "application/json", + "CQL_FILTER": cql, + "count": str(count), + } + url = f"{GEOPORTAL_HOST}{WFS_PATH}" + try: + # Геопортал ЕКБ отдаёт сертификат российского CA (Минцифры), которого нет в + # стандартном trust store httpx → TLS verify падает. Данные публичные, + # read-only → verify=False (тот же подход, что curl -k при разведке). + async with httpx.AsyncClient( + timeout=self._timeout, headers=_BROWSER_HEADERS, verify=False + ) as client: + resp = await client.get(url, params=params) + if resp.status_code != 200: + logger.warning("geoportal WFS → HTTP %d for bbox=%s", resp.status_code, bbox) + return [] + payload = resp.json() + except Exception as exc: + logger.warning("geoportal WFS fetch/parse failed for bbox=%s: %s", bbox, exc) + return [] + + return parse_feature_collection(payload)