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)