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