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
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:
parent
bc9549782b
commit
8c8289cdad
6 changed files with 1170 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
@ -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) == []
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
"""
|
||||
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue