feat(tradein/scraper-kit): port domclick_detail + ekb_geoportal_client to kit (#2307)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m44s

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).
This commit is contained in:
bot-backend 2026-07-04 00:04:18 +03:00
parent bc9549782b
commit 8c8289cdad
6 changed files with 1170 additions and 1 deletions

View file

@ -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 = (
"<html><head></head><body><script>"
f"window.__SSR_STATE__ = {_SSR_LITERAL};"
"</script></body></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 = "<html><body>Access denied by DataDome captcha</body></html>"
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 = "<html><body>no ssr state here</body></html>"
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)

View file

@ -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) == []

View file

@ -1,8 +1,10 @@
"""DomClick-провайдер scraper_kit — strangler-копия `app.services.scrapers.domclick`. """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 маппинг) offer-item ScrapedLot маппинг)
- detail Layer B detail-card enrichment (parse_detail_html SSR-стейт,
fetch_detail, save_detail_enrichment) strangler-порт #2307
Развязка от `app.*`: Развязка от `app.*`:
- `app.core.config.settings.browser_http_endpoint` инжектируемый `ScraperConfig` - `app.core.config.settings.browser_http_endpoint` инжектируемый `ScraperConfig`

View file

@ -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__<id>``) и обогащает
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__<digits> из 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 <cols> (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

View file

@ -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).
"""

View file

@ -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)