feat(scraper-kit): copy domclick provider with protocol injection, strangler (#2133 domclick)
This commit is contained in:
parent
df5e9003df
commit
861467e589
4 changed files with 993 additions and 0 deletions
97
tradein-mvp/backend/tests/fixtures/domclick_bff_offers_sample.json
vendored
Normal file
97
tradein-mvp/backend/tests/fixtures/domclick_bff_offers_sample.json
vendored
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"result": {
|
||||
"snippetsCount": 6,
|
||||
"items": [
|
||||
{
|
||||
"id": 1001,
|
||||
"path": "/card/sale__flat__1001",
|
||||
"price": 8990000,
|
||||
"squarePrice": 149833,
|
||||
"offerRegionName": "Екатеринбург",
|
||||
"location": {"lat": 56.8386, "lon": 60.6054},
|
||||
"address": {"displayName": "Екатеринбург, ул. Малышева, 51"},
|
||||
"objectInfo": {"area": 60.0, "floor": 7, "rooms": 2},
|
||||
"house": {"floors": 16, "buildYear": 2015},
|
||||
"description": "Отличная квартира с евроремонтом в центре.",
|
||||
"seller": {"company": "АН Этажи", "agent": "Иванов И."},
|
||||
"isRosreestrApproved": true,
|
||||
"lastPriceHistoryState": "decreased",
|
||||
"flatComplex": {"name": "ЖК Малышева", "id": 77, "slug": "malysheva"},
|
||||
"updatedDate": "2026-06-30T10:00:00+05:00",
|
||||
"isSberCollateral": true,
|
||||
"hasDiscount": true,
|
||||
"discountValue": 100000,
|
||||
"duplicatesOfferCount": 3,
|
||||
"publishedDate": "2026-06-28T09:15:00+05:00"
|
||||
},
|
||||
{
|
||||
"id": 1002,
|
||||
"path": "/card/sale__flat__1002",
|
||||
"price": 4500000,
|
||||
"squarePrice": null,
|
||||
"offerRegionName": "Екатеринбург",
|
||||
"location": {"lat": 56.79, "lon": 60.61},
|
||||
"address": {"displayName": "Екатеринбург, ул. Луначарского, 240"},
|
||||
"objectInfo": {"area": 28.5, "floor": 3, "rooms": null},
|
||||
"house": {"floors": 25, "buildYear": 2020},
|
||||
"description": "Уютная студия без отделки, свободна.",
|
||||
"seller": {"company": {"name": "СберРиелт"}},
|
||||
"publishedDate": "2026-06-27"
|
||||
},
|
||||
{
|
||||
"id": 1003,
|
||||
"path": "/card/sale__flat__1003",
|
||||
"price": 6200000,
|
||||
"squarePrice": 0,
|
||||
"offerRegionName": "Екатеринбург",
|
||||
"location": {"lat": 56.85, "lon": 60.55},
|
||||
"address": {"displayName": "Екатеринбург, ул. Смазчиков, 3"},
|
||||
"objectInfo": {"area": 0, "floor": 0, "rooms": 0},
|
||||
"house": {"floors": 0, "buildYear": 0},
|
||||
"description": null,
|
||||
"seller": null,
|
||||
"publishedDate": null
|
||||
},
|
||||
{
|
||||
"id": 1004,
|
||||
"path": "/card/sale__flat__1004",
|
||||
"price": 12000000,
|
||||
"squarePrice": 160000,
|
||||
"offerRegionName": "Москва",
|
||||
"location": {"lat": 55.75, "lon": 37.62},
|
||||
"address": {"displayName": "Москва, ул. Тверская, 1"},
|
||||
"objectInfo": {"area": 75.0, "floor": 5, "rooms": 3},
|
||||
"house": {"floors": 20, "buildYear": 2018},
|
||||
"description": "Квартира в Москве.",
|
||||
"seller": {"agent": {"fullName": "Петров П."}},
|
||||
"publishedDate": "2026-06-20T00:00:00+03:00"
|
||||
},
|
||||
{
|
||||
"id": 1005,
|
||||
"path": "/card/sale__flat__1005",
|
||||
"price": 5000000,
|
||||
"squarePrice": 100000,
|
||||
"offerRegionName": "Екатеринбург",
|
||||
"location": {"lat": 58.5, "lon": 60.6},
|
||||
"address": {"displayName": "Севернее ЕКБ (вне bbox)"},
|
||||
"objectInfo": {"area": 50.0, "floor": 2, "rooms": 1},
|
||||
"house": {"floors": 9, "buildYear": 1990},
|
||||
"description": " ",
|
||||
"seller": {"company": " "},
|
||||
"publishedDate": "not-a-date"
|
||||
},
|
||||
{
|
||||
"id": 1006,
|
||||
"path": "/card/sale__flat__1006",
|
||||
"price": 0,
|
||||
"squarePrice": 0,
|
||||
"offerRegionName": "Екатеринбург",
|
||||
"location": {"lat": 56.8, "lon": 60.6},
|
||||
"address": {"displayName": "Битый оффер"},
|
||||
"objectInfo": {"area": 40.0, "floor": 1, "rooms": 1},
|
||||
"house": {"floors": 5, "buildYear": 1980},
|
||||
"publishedDate": "2026-06-01"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
"""Golden-parity: `scraper_kit.providers.domclick.serp` ≡ `app.services.scrapers.domclick`.
|
||||
|
||||
Strangler-инвариант (#2133): новая scraper_kit-копия domclick-провайдера должна
|
||||
давать БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе.
|
||||
Развязка (get_scraper_delay→delay_provider, base/exceptions/repair-normalizer→
|
||||
scraper_kit.*) НЕ меняет распарсенные данные.
|
||||
|
||||
Гоняем ОБА модуля на одной BFF-JSON-фикстуре и сравниваем:
|
||||
- `_extract_json` (JSON из HTML-обёртки + QRATOR-block detection);
|
||||
- `_map_item` (offer → ScrapedLot): студия rooms=0, sentinel 0→None, agency;
|
||||
- `_is_geo_ok` (bbox + offerRegionName guard);
|
||||
- url-билдеры и чистые хелперы (_build_offers_url/_build_count_url/
|
||||
_parse_publish_date/_extract_agency_name).
|
||||
|
||||
Идентичность результата = kit-парсер верно скопирован (развязка не задела логику).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
# Старый app.services.scrapers.domclick тянет app.core.config.settings=Settings(),
|
||||
# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
# НОВЫЙ (scraper_kit) — ruff сортирует scraper_kit (first-party) выше app-импортов.
|
||||
from scraper_kit.providers.domclick.serp import DomClickScraper as NewDomClickScraper
|
||||
from scraper_kit.providers.domclick.serp import _build_count_url as new_build_count_url
|
||||
from scraper_kit.providers.domclick.serp import _build_offers_url as new_build_offers_url
|
||||
from scraper_kit.providers.domclick.serp import _extract_agency_name as new_extract_agency
|
||||
from scraper_kit.providers.domclick.serp import _extract_json as new_extract_json
|
||||
from scraper_kit.providers.domclick.serp import _parse_publish_date as new_parse_publish_date
|
||||
|
||||
from app.services.scrapers.domclick import DomClickScraper as OldDomClickScraper
|
||||
from app.services.scrapers.domclick import _build_count_url as old_build_count_url
|
||||
from app.services.scrapers.domclick import _build_offers_url as old_build_offers_url
|
||||
from app.services.scrapers.domclick import _extract_agency_name as old_extract_agency
|
||||
from app.services.scrapers.domclick import _extract_json as old_extract_json
|
||||
from app.services.scrapers.domclick import _parse_publish_date as old_parse_publish_date
|
||||
|
||||
_FIXTURES = Path(__file__).parent / "fixtures"
|
||||
_BFF_JSON = (_FIXTURES / "domclick_bff_offers_sample.json").read_text(encoding="utf-8")
|
||||
_BFF_ITEMS: list[dict[str, Any]] = json.loads(_BFF_JSON)["result"]["items"]
|
||||
|
||||
|
||||
def _make_old() -> OldDomClickScraper:
|
||||
"""Старый DomClickScraper без DB: patch get_scraper_delay."""
|
||||
with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=8.0):
|
||||
return OldDomClickScraper()
|
||||
|
||||
|
||||
def _make_new() -> NewDomClickScraper:
|
||||
"""Новый DomClickScraper с инжектируемым ScraperConfig + delay_provider.
|
||||
|
||||
Парсинг-тесты не дёргают fetch_city → browser_http_endpoint не используется,
|
||||
но конструктор требует config (duck-typed namespace достаточен).
|
||||
"""
|
||||
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
|
||||
return NewDomClickScraper(config, delay_provider=lambda _name: 8.0)
|
||||
|
||||
|
||||
# ── _extract_json parity ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_extract_json_parity_bare() -> None:
|
||||
"""_extract_json на bare JSON-теле идентичен."""
|
||||
assert old_extract_json(_BFF_JSON) == new_extract_json(_BFF_JSON)
|
||||
|
||||
|
||||
def test_extract_json_parity_pre_wrapped() -> None:
|
||||
"""_extract_json на <pre>-обёрнутом JSON идентичен (реальная форма ответа)."""
|
||||
wrapped = f"<html><body><pre>{_BFF_JSON}</pre></body></html>"
|
||||
assert old_extract_json(wrapped) == new_extract_json(wrapped)
|
||||
|
||||
|
||||
def test_extract_json_parity_qrator_block() -> None:
|
||||
"""QRATOR block-страница → DomClickBlockedError в обоих (идентичное поведение)."""
|
||||
from scraper_kit.domclick_exceptions import DomClickBlockedError as NewBlocked
|
||||
|
||||
from app.services.scrapers.domclick_exceptions import DomClickBlockedError as OldBlocked
|
||||
|
||||
block_html = "<html><body>Система защиты QRATOR: captcha</body></html>"
|
||||
old_raised = new_raised = False
|
||||
try:
|
||||
old_extract_json(block_html)
|
||||
except OldBlocked:
|
||||
old_raised = True
|
||||
try:
|
||||
new_extract_json(block_html)
|
||||
except NewBlocked:
|
||||
new_raised = True
|
||||
assert old_raised and new_raised
|
||||
|
||||
|
||||
# ── _map_item parity (offer → ScrapedLot) ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_map_item_parity_all_items() -> None:
|
||||
"""Каждый offer-item → ScrapedLot идентичен (студия rooms=0, sentinel 0→None)."""
|
||||
old = _make_old()
|
||||
new = _make_new()
|
||||
for item in _BFF_ITEMS:
|
||||
# force_rooms=0 для студий (st-бакет) — проверяем обе ветки маппинга.
|
||||
for force_rooms in (None, 0):
|
||||
old_lot = old._map_item(item, force_rooms=force_rooms)
|
||||
new_lot = new._map_item(item, force_rooms=force_rooms)
|
||||
if old_lot is None or new_lot is None:
|
||||
assert old_lot is None and new_lot is None
|
||||
continue
|
||||
assert old_lot.model_dump() == new_lot.model_dump()
|
||||
# Счётчик parse-ошибок эволюционирует одинаково.
|
||||
assert old.parse_failures == new.parse_failures
|
||||
|
||||
|
||||
# ── _is_geo_ok parity (bbox + region guard) ───────────────────────────────────
|
||||
|
||||
|
||||
def test_is_geo_ok_parity_all_items() -> None:
|
||||
"""Гео-гард (offerRegionName + bbox) идентичен на всех items."""
|
||||
old = _make_old()
|
||||
new = _make_new()
|
||||
for item in _BFF_ITEMS:
|
||||
assert old._is_geo_ok(item) == new._is_geo_ok(item)
|
||||
|
||||
|
||||
# ── url-билдеры + чистые хелперы ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_url_builders_parity() -> None:
|
||||
"""_build_offers_url / _build_count_url идентичны (вкл. '5+' → '5%2B')."""
|
||||
cases: list[tuple[str, int | None, int | None]] = [
|
||||
("st", None, None),
|
||||
("2", 3_000_000, 8_000_000),
|
||||
("5+", 15_000_000, None),
|
||||
("1", None, 5_000_000),
|
||||
]
|
||||
for rooms, gte, lte in cases:
|
||||
assert old_build_count_url(rooms, gte, lte) == new_build_count_url(rooms, gte, lte)
|
||||
for offset in (0, 20, 1980):
|
||||
assert old_build_offers_url(rooms, gte, lte, offset) == new_build_offers_url(
|
||||
rooms, gte, lte, offset
|
||||
)
|
||||
|
||||
|
||||
def test_parse_publish_date_parity() -> None:
|
||||
"""_parse_publish_date идентичен на валидных/битых ISO-строках."""
|
||||
for iso in [None, "", "2026-06-28T09:15:00+05:00", "2026-06-27", "not-a-date", "2026-13-40"]:
|
||||
assert old_parse_publish_date(iso) == new_parse_publish_date(iso)
|
||||
|
||||
|
||||
def test_extract_agency_name_parity() -> None:
|
||||
"""_extract_agency_name идентичен на строковых/dict/пустых seller-блоках."""
|
||||
sellers: list[dict[str, Any]] = [
|
||||
{},
|
||||
{"company": "АН Этажи", "agent": "Иванов"},
|
||||
{"company": " ", "agent": {"name": "Резерв"}},
|
||||
{"agent": {"fullName": "Петров П."}},
|
||||
{"company": {"title": "СберРиелт"}},
|
||||
{"agent": 12345},
|
||||
]
|
||||
for seller in sellers:
|
||||
assert old_extract_agency(seller) == new_extract_agency(seller)
|
||||
|
||||
|
||||
# ── strangler-guard: kit-провайдер не импортирует app.* ───────────────────────
|
||||
|
||||
|
||||
def test_kit_domclick_has_no_app_imports() -> None:
|
||||
"""Ни один модуль scraper_kit.providers.domclick.* не импортирует app.* (развязка)."""
|
||||
import inspect
|
||||
|
||||
from scraper_kit.providers.domclick import serp
|
||||
|
||||
source = inspect.getsource(serp)
|
||||
for line in source.splitlines():
|
||||
stripped = line.strip()
|
||||
assert not stripped.startswith("from app"), f"serp: {line!r}"
|
||||
assert not stripped.startswith("import app"), f"serp: {line!r}"
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
"""DomClick-провайдер scraper_kit — strangler-копия `app.services.scrapers.domclick`.
|
||||
|
||||
Модули:
|
||||
- serp — BFF JSON API sweep (DomClickScraper, room×price bisection,
|
||||
offer-item → ScrapedLot маппинг)
|
||||
|
||||
Развязка от `app.*`:
|
||||
- `app.core.config.settings.browser_http_endpoint` → инжектируемый `ScraperConfig`
|
||||
- `app.services.scraper_settings.get_scraper_delay` → инжектируемый `delay_provider`
|
||||
- `app.services.scrapers.base` → `scraper_kit.base`
|
||||
- `app.services.scrapers.domclick_exceptions` → `scraper_kit.domclick_exceptions`
|
||||
- `app.services.scrapers.repair_state_normalizer` → `scraper_kit.repair_state_normalizer`
|
||||
- `app.services.scrapers.browser_fetcher` → `scraper_kit.browser_fetcher`
|
||||
|
||||
DomClick читает из `ScraperConfig` только `browser_http_endpoint` (для BrowserFetcher);
|
||||
остальные поля настроек ему не нужны (прямой BFF JSON через shared mobile proxy).
|
||||
"""
|
||||
|
|
@ -0,0 +1,696 @@
|
|||
"""DomClick.ru scraper — вторичка через JSON BFF API (#1968).
|
||||
|
||||
Strangler-копия (#2133) боевого `app.services.scrapers.domclick`, развязанная от
|
||||
`app.*`: продуктовые зависимости инжектируются снаружи (`delay_provider`), общие
|
||||
хелперы берутся из `scraper_kit.*`. Логика парсинга идентична оригиналу
|
||||
(golden-parity — tests/test_scraper_kit_domclick_golden_parity.py).
|
||||
|
||||
Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
|
||||
BrowserFetcher(source="domclick") (generic provider → shared mobile proxy).
|
||||
QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy.
|
||||
|
||||
Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
|
||||
Парсинг: _extract_json() вытаскивает первый {...} из ответа.
|
||||
|
||||
Стратегия нумерации комнат (ROOM_BUCKETS):
|
||||
"st" — студии (force rooms=0 в ScrapedLot)
|
||||
"1"–"4" — соответственно
|
||||
"5+" — 5 и более комнат
|
||||
|
||||
Пагинация: offset 0, 20, 40, …, cap=2000. Если snippetsCount > 2000
|
||||
для бакета, рекурсивно делим по цене (binary split) до тех пор пока
|
||||
каждая ветка укладывается в cap.
|
||||
|
||||
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
|
||||
через detail-эндпоинт после первоначального сбора.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from scraper_kit.base import BaseScraper, ScrapedLot
|
||||
from scraper_kit.domclick_exceptions import DomClickBlockedError
|
||||
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scraper_kit.contracts import ScraperConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── API constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
_BFF_BASE = "https://bff-search-web.domclick.ru"
|
||||
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
|
||||
_EKB_AREA_ID = "20561"
|
||||
|
||||
# Buckets to sweep — порядок влияет на логи.
|
||||
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
|
||||
|
||||
OFFSET_CAP: int = 2000 # max offset принятый BFF API
|
||||
PAGE_SIZE: int = 20 # items per page (жёстко задан API)
|
||||
# Потолок цены для binary split. 1 млрд ₽ — безопасно выше любой ЕКБ-квартиры;
|
||||
# выбран так чтобы НЕ отбрасывать листинги дороже потолка при сплите unbounded-бакета
|
||||
# (родительский count не ограничен сверху → clamped _lte должен покрывать весь хвост).
|
||||
LTE_MAX: int = 1_000_000_000
|
||||
MIN_PRICE_SPAN: int = 100_000 # ниже этого span прекращаем делить
|
||||
|
||||
# ── QRATOR block detection ────────────────────────────────────────────────────
|
||||
|
||||
_QRATOR_MARKERS: tuple[str, ...] = (
|
||||
"qrator",
|
||||
"bot_mitigation",
|
||||
"система защиты",
|
||||
"403 | домклик",
|
||||
"captcha",
|
||||
"access denied",
|
||||
)
|
||||
|
||||
# ── EKB geo guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
_EKB_LAT_MIN: float = 56.6
|
||||
_EKB_LAT_MAX: float = 57.0
|
||||
_EKB_LON_MIN: float = 60.2
|
||||
_EKB_LON_MAX: float = 60.9
|
||||
_EKB_REGION_NAME: str = "Екатеринбург"
|
||||
|
||||
|
||||
# ── JSON extraction ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_json(html: str) -> dict[str, Any]:
|
||||
"""Извлекает JSON-объект из ответа BrowserFetcher.
|
||||
|
||||
BrowserFetcher оборачивает JSON в HTML-страницу двумя способами:
|
||||
1. Bare body: ``{"result": ...}`` напрямую в <body>.
|
||||
2. <pre>-wrapped: ``<html><body><pre>{"result": ...}</pre></body></html>``.
|
||||
|
||||
Стратегия: найти первый '{' и последний '}' — работает для обоих вариантов.
|
||||
|
||||
Raises:
|
||||
DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры.
|
||||
ValueError: если JSON не найден или не является dict.
|
||||
"""
|
||||
# Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может
|
||||
# стоять за пределами head в крупных challenge-страницах.
|
||||
html_lower = html.lower()
|
||||
if any(m in html_lower for m in _QRATOR_MARKERS):
|
||||
raise DomClickBlockedError(
|
||||
f"DomClick BFF: QRATOR block page detected (markers checked: {_QRATOR_MARKERS[:2]})"
|
||||
)
|
||||
|
||||
start = html.find("{")
|
||||
end = html.rfind("}")
|
||||
if start == -1 or end == -1 or end <= start:
|
||||
raise ValueError(f"No JSON object found in BFF response (len={len(html)})")
|
||||
|
||||
data = json.loads(html[start : end + 1])
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"BFF response JSON is not a dict: {type(data)}")
|
||||
return data # type: ignore[return-value]
|
||||
|
||||
|
||||
# ── URL builders ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_offers_url(
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
offset: int,
|
||||
) -> str:
|
||||
"""Строит URL для GET /api/offers/v1 с пагинацией.
|
||||
|
||||
urlencode кодирует "5+" → "5%2B" (literal '+' в query string = space → reject).
|
||||
"""
|
||||
params: list[tuple[str, str]] = [
|
||||
("address", _EKB_ADDRESS_GUID),
|
||||
("aids", _EKB_AREA_ID),
|
||||
("deal_type", "sale"),
|
||||
("category", "living"),
|
||||
("offer_type", "flat"),
|
||||
("rooms", rooms),
|
||||
("sort", "qi"),
|
||||
("sort_dir", "desc"),
|
||||
("offset", str(offset)),
|
||||
("limit", str(PAGE_SIZE)),
|
||||
]
|
||||
if price_gte is not None:
|
||||
params.append(("sale_price__gte", str(price_gte)))
|
||||
if price_lte is not None:
|
||||
params.append(("sale_price__lte", str(price_lte)))
|
||||
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
|
||||
|
||||
|
||||
def _build_count_url(
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
) -> str:
|
||||
"""Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
|
||||
params: list[tuple[str, str]] = [
|
||||
("address", _EKB_ADDRESS_GUID),
|
||||
("aids", _EKB_AREA_ID),
|
||||
("deal_type", "sale"),
|
||||
("category", "living"),
|
||||
("offer_type", "flat"),
|
||||
("rooms", rooms),
|
||||
("sort", "qi"),
|
||||
("sort_dir", "desc"),
|
||||
]
|
||||
if price_gte is not None:
|
||||
params.append(("sale_price__gte", str(price_gte)))
|
||||
if price_lte is not None:
|
||||
params.append(("sale_price__lte", str(price_lte)))
|
||||
return f"{_BFF_BASE}/api/offers/count/v1?{urlencode(params)}"
|
||||
|
||||
|
||||
def _parse_publish_date(iso: str | None) -> date | None:
|
||||
"""Парсит ISO8601 дату публикации → date. None при ошибке."""
|
||||
if not iso:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(iso[:10])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_agency_name(seller: dict[str, Any]) -> str | None:
|
||||
"""Извлечь читаемое имя агентства/агента из seller-блока BFF offer.
|
||||
|
||||
seller.company / seller.agent могут быть строкой ИЛИ dict (defensive —
|
||||
реальная форма поля варьируется). Приоритет: company → agent. Для dict
|
||||
пробуем типичные name-поля. Возвращает None если ничего читаемого нет.
|
||||
"""
|
||||
for key in ("company", "agent"):
|
||||
val = seller.get(key)
|
||||
if isinstance(val, str):
|
||||
name = val.strip()
|
||||
if name:
|
||||
return name
|
||||
elif isinstance(val, dict):
|
||||
for name_key in ("name", "title", "fullName"):
|
||||
nm = val.get(name_key)
|
||||
if isinstance(nm, str) and nm.strip():
|
||||
return nm.strip()
|
||||
return None
|
||||
|
||||
|
||||
# ── DomClickScraper ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DomClickScraper(BaseScraper):
|
||||
"""DomClick вторичка через JSON BFF API. Источник = 'domklik'.
|
||||
|
||||
Использует BrowserFetcher(source="domclick") (generic provider → shared mobile
|
||||
proxy), который обходит QRATOR. Прямые curl/httpx-запросы с datacenter-IP
|
||||
блокируются QRATOR.
|
||||
|
||||
Основной метод: fetch_city(city_id, rooms, pages).
|
||||
fetch_around() не реализован: DomClick не поддерживает geo-radius в URL.
|
||||
|
||||
Strangler-инжекция (#2133): продуктовые зависимости приходят снаружи вместо
|
||||
прямого импорта `app.*`:
|
||||
- `config: ScraperConfig` — источник `browser_http_endpoint` для
|
||||
BrowserFetcher (kit не читает `app.core.config.settings`);
|
||||
- `delay_provider` — замена прямого импорта get_scraper_delay.
|
||||
|
||||
Counters (публичные после fetch_city):
|
||||
parse_failures — офферы с ошибкой маппинга
|
||||
geo_filtered — офферы вне ЕКБ bbox или с неверным offerRegionName
|
||||
blocked — True если sweep был прерван QRATOR-блоком
|
||||
fetch_errors — не-block ошибки извлечения JSON (truncated/garbled/bad shape)
|
||||
"""
|
||||
|
||||
name = "domklik"
|
||||
source = "domklik"
|
||||
base_url = "https://domclick.ru"
|
||||
# Консервативная задержка между страницами (fallback до init)
|
||||
request_delay_sec = 8.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ScraperConfig,
|
||||
*,
|
||||
delay_provider: Callable[[str], float] | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
|
||||
# BrowserFetcher, delay_provider заменяет прямой импорт
|
||||
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
|
||||
self._config = config
|
||||
if delay_provider is not None:
|
||||
self.request_delay_sec = delay_provider(self.name)
|
||||
self.parse_failures: int = 0
|
||||
self.geo_filtered: int = 0
|
||||
self.blocked: bool = False
|
||||
# Не-block ошибки извлечения JSON (truncated/garbled response, неверная
|
||||
# структура). В отличие от parse_failures (per-item), это per-fetch ошибки,
|
||||
# которые ограничивают сбор бакета. Учитываются в honest-status pipeline.
|
||||
self.fetch_errors: int = 0
|
||||
|
||||
async def __aenter__(self) -> DomClickScraper:
|
||||
await super().__aenter__()
|
||||
return self
|
||||
|
||||
# ── fetch_around — stub ───────────────────────────────────────────────────
|
||||
|
||||
async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list[ScrapedLot]:
|
||||
"""DomClick не поддерживает geo-radius. Используй fetch_city()."""
|
||||
raise NotImplementedError(
|
||||
"DomClick не поддерживает geo-radius; используй fetch_city(city_id=...)"
|
||||
)
|
||||
|
||||
# ── Основной метод ────────────────────────────────────────────────────────
|
||||
|
||||
async def fetch_city(
|
||||
self,
|
||||
city_id: int,
|
||||
rooms: list[int] | None = None,
|
||||
pages: int = 100,
|
||||
) -> list[ScrapedLot]:
|
||||
"""Citywide sweep через BFF JSON API.
|
||||
|
||||
Аргументы city_id и rooms принимаются для совместимости сигнатуры с
|
||||
вызывающим кодом (run_domclick_city_sweep), но:
|
||||
- city_id — vestigial (EКБ захардкожен через GUID).
|
||||
- rooms — игнорируется; всегда обходятся все ROOM_BUCKETS внутри.
|
||||
|
||||
Args:
|
||||
city_id: игнорируется (EKB захардкожен).
|
||||
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
|
||||
pages: максимальное число страниц на бакет (safety cap).
|
||||
|
||||
Returns:
|
||||
Дедуплицированный по source_id список ScrapedLot.
|
||||
"""
|
||||
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
|
||||
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||
|
||||
out_lots: list[ScrapedLot] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
async with BrowserFetcher(
|
||||
source="domclick", endpoint=self._config.browser_http_endpoint
|
||||
) as fetcher:
|
||||
for bucket in ROOM_BUCKETS:
|
||||
logger.info(
|
||||
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
|
||||
bucket,
|
||||
city_id,
|
||||
pages,
|
||||
)
|
||||
try:
|
||||
await self._sweep_bucket(
|
||||
fetcher=fetcher,
|
||||
rooms=bucket,
|
||||
price_gte=None,
|
||||
price_lte=None,
|
||||
seen_ids=seen_ids,
|
||||
out_lots=out_lots,
|
||||
pages=pages,
|
||||
)
|
||||
except DomClickBlockedError:
|
||||
self.blocked = True
|
||||
logger.error(
|
||||
"domklik: QRATOR block during rooms=%r — aborting all buckets",
|
||||
bucket,
|
||||
)
|
||||
break
|
||||
except (ValueError, TypeError) as exc:
|
||||
# Defensive: bucket-level ошибка не должна убивать весь sweep.
|
||||
# _count/_paginate уже глотают эти ошибки per-fetch (fetch_errors++),
|
||||
# но если что-то всё же всплыло — переходим к следующему бакету.
|
||||
self.fetch_errors += 1
|
||||
logger.warning(
|
||||
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
||||
bucket,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"domklik: fetch_city done city_id=%d total=%d "
|
||||
"parse_failures=%d geo_filtered=%d fetch_errors=%d blocked=%s",
|
||||
city_id,
|
||||
len(out_lots),
|
||||
self.parse_failures,
|
||||
self.geo_filtered,
|
||||
self.fetch_errors,
|
||||
self.blocked,
|
||||
)
|
||||
return out_lots
|
||||
|
||||
# ── Internal sweep helpers ────────────────────────────────────────────────
|
||||
|
||||
async def _count(
|
||||
self,
|
||||
fetcher: Any,
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
) -> int:
|
||||
"""Запрашивает snippetsCount для данного бакета (rooms + price range).
|
||||
|
||||
DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
|
||||
извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и return 0
|
||||
(бакет пропускается, sweep продолжается).
|
||||
"""
|
||||
url = _build_count_url(rooms, price_gte, price_lte)
|
||||
logger.debug("domklik: count url=%s", url)
|
||||
html = await fetcher.fetch(url)
|
||||
try:
|
||||
data = _extract_json(html)
|
||||
except DomClickBlockedError:
|
||||
raise
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
self.fetch_errors += 1
|
||||
logger.warning(
|
||||
"domklik: _count JSON extract failed rooms=%r price=[%s,%s] — skip bucket",
|
||||
rooms,
|
||||
price_gte,
|
||||
price_lte,
|
||||
exc_info=True,
|
||||
)
|
||||
return 0
|
||||
# {"result": null} → None, {"snippetsCount": null} → None: harden обе.
|
||||
res = data.get("result") or {}
|
||||
raw = res.get("snippetsCount")
|
||||
return int(raw) if raw else 0
|
||||
|
||||
async def _sweep_bucket(
|
||||
self,
|
||||
fetcher: Any,
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
seen_ids: set[str],
|
||||
out_lots: list[ScrapedLot],
|
||||
pages: int,
|
||||
) -> None:
|
||||
"""Рекурсивный sweep бакета (rooms, price_gte, price_lte).
|
||||
|
||||
Если snippetsCount > OFFSET_CAP — делим диапазон цен пополам и
|
||||
рекурсируем в каждую половину. Остановка рекурсии:
|
||||
- span <= MIN_PRICE_SPAN → пагинируем как есть (с предупреждением о truncation)
|
||||
- snippetsCount == 0 → пропускаем
|
||||
- snippetsCount <= OFFSET_CAP → пагинируем
|
||||
|
||||
Raises:
|
||||
DomClickBlockedError: если QRATOR-блок — propagate наверх.
|
||||
"""
|
||||
count = await self._count(fetcher, rooms, price_gte, price_lte)
|
||||
if count == 0:
|
||||
return
|
||||
|
||||
if count > OFFSET_CAP:
|
||||
_gte = price_gte if price_gte is not None else 0
|
||||
_lte = price_lte if price_lte is not None else LTE_MAX
|
||||
span = _lte - _gte
|
||||
if span <= MIN_PRICE_SPAN:
|
||||
logger.warning(
|
||||
"domklik: rooms=%r price=[%s,%s] count=%d > cap=%d "
|
||||
"but span=%d <= min=%d — paginating as-is (bucket truncated at %d)",
|
||||
rooms,
|
||||
price_gte,
|
||||
price_lte,
|
||||
count,
|
||||
OFFSET_CAP,
|
||||
span,
|
||||
MIN_PRICE_SPAN,
|
||||
OFFSET_CAP,
|
||||
)
|
||||
await self._paginate(
|
||||
fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages
|
||||
)
|
||||
return
|
||||
mid = (_gte + _lte) // 2
|
||||
logger.debug(
|
||||
"domklik: rooms=%r count=%d > cap=%d — price-split [%d,%d] → [%d,%d]+[%d,%d]",
|
||||
rooms,
|
||||
count,
|
||||
OFFSET_CAP,
|
||||
_gte,
|
||||
_lte,
|
||||
_gte,
|
||||
mid,
|
||||
mid + 1,
|
||||
_lte,
|
||||
)
|
||||
await self._sweep_bucket(fetcher, rooms, _gte, mid, seen_ids, out_lots, pages)
|
||||
await self._sweep_bucket(fetcher, rooms, mid + 1, _lte, seen_ids, out_lots, pages)
|
||||
else:
|
||||
await self._paginate(fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages)
|
||||
|
||||
async def _paginate(
|
||||
self,
|
||||
fetcher: Any,
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
seen_ids: set[str],
|
||||
out_lots: list[ScrapedLot],
|
||||
pages: int,
|
||||
) -> None:
|
||||
"""Пагинирует один бакет (rooms, price range) до исчерпания или cap.
|
||||
|
||||
DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
|
||||
извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и break
|
||||
(трактуем как конец бакета — уже собранные lots сохраняются).
|
||||
|
||||
Raises:
|
||||
DomClickBlockedError: propagate из _extract_json при QRATOR-блоке.
|
||||
"""
|
||||
force_rooms = 0 if rooms == "st" else None
|
||||
page_idx = 0
|
||||
while page_idx < pages:
|
||||
offset = page_idx * PAGE_SIZE
|
||||
if offset >= OFFSET_CAP:
|
||||
break
|
||||
url = _build_offers_url(rooms, price_gte, price_lte, offset)
|
||||
logger.debug("domklik: offers url=%s", url)
|
||||
html = await fetcher.fetch(url)
|
||||
try:
|
||||
data = _extract_json(html)
|
||||
except DomClickBlockedError:
|
||||
raise
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
self.fetch_errors += 1
|
||||
logger.warning(
|
||||
"domklik: _paginate JSON extract failed rooms=%r offset=%d "
|
||||
"— ending bucket (lots so far preserved)",
|
||||
rooms,
|
||||
offset,
|
||||
exc_info=True,
|
||||
)
|
||||
break
|
||||
|
||||
# {"result": null} → None; harden до .get("items").
|
||||
items: list[dict[str, Any]] = (data.get("result") or {}).get("items") or []
|
||||
if not items:
|
||||
break
|
||||
|
||||
for item in items:
|
||||
lot = self._map_item(item, force_rooms=force_rooms)
|
||||
if lot is None:
|
||||
continue
|
||||
if not self._is_geo_ok(item):
|
||||
self.geo_filtered += 1
|
||||
continue
|
||||
key = lot.source_id or lot.source_url
|
||||
if key in seen_ids:
|
||||
continue
|
||||
seen_ids.add(key)
|
||||
out_lots.append(lot)
|
||||
|
||||
page_idx += 1
|
||||
if page_idx < pages and len(items) == PAGE_SIZE:
|
||||
await self.sleep_between_requests()
|
||||
elif not items or len(items) < PAGE_SIZE:
|
||||
break
|
||||
|
||||
def _is_geo_ok(self, item: dict[str, Any]) -> bool:
|
||||
"""Гео-гард: пропускает только листинги ЕКБ в bbox.
|
||||
|
||||
aids=20561 даёт чистый ЕКБ, но гард оставляем как defensive проверку.
|
||||
"""
|
||||
region = item.get("offerRegionName", "")
|
||||
if region != _EKB_REGION_NAME:
|
||||
return False
|
||||
loc = item.get("location") or {}
|
||||
lat = loc.get("lat")
|
||||
lon = loc.get("lon")
|
||||
if lat is None or lon is None:
|
||||
return False
|
||||
return (
|
||||
_EKB_LAT_MIN <= float(lat) <= _EKB_LAT_MAX
|
||||
and _EKB_LON_MIN <= float(lon) <= _EKB_LON_MAX
|
||||
)
|
||||
|
||||
def _map_item(
|
||||
self, item: dict[str, Any], *, force_rooms: int | None = None
|
||||
) -> ScrapedLot | None:
|
||||
"""Маппинг offer-item из BFF API → ScrapedLot.
|
||||
|
||||
Возвращает None если price <= 0 или при ошибке парсинга.
|
||||
"""
|
||||
try:
|
||||
price = item.get("price", 0) or 0
|
||||
if price <= 0:
|
||||
return None
|
||||
|
||||
item_id = item.get("id")
|
||||
source_id = str(item_id) if item_id is not None else None
|
||||
source_url: str = item.get("path", "") or ""
|
||||
|
||||
loc = item.get("location") or {}
|
||||
lat: float | None = loc.get("lat")
|
||||
lon: float | None = loc.get("lon")
|
||||
|
||||
address_obj = item.get("address") or {}
|
||||
address: str | None = address_obj.get("displayName")
|
||||
|
||||
# 0 — sentinel «неизвестно» для area/floor/total_floors/buildYear
|
||||
# (DomClick эмитит 0 для under-construction / unknown) → None.
|
||||
obj_info = item.get("objectInfo") or {}
|
||||
area_raw = obj_info.get("area")
|
||||
area_m2: float | None = float(area_raw) if area_raw else None
|
||||
floor_raw = obj_info.get("floor")
|
||||
floor: int | None = int(floor_raw) if floor_raw else None
|
||||
|
||||
house = item.get("house") or {}
|
||||
floors_raw = house.get("floors")
|
||||
total_floors: int | None = int(floors_raw) if floors_raw else None
|
||||
year_raw = house.get("buildYear")
|
||||
year_built: int | None = int(year_raw) if year_raw else None
|
||||
|
||||
# rooms: 0 — ВАЛИДНОЕ значение (студия), поэтому проверка is not None,
|
||||
# а не truthy. force_rooms=0 (st-бакет) перебивает objectInfo.
|
||||
if force_rooms is not None:
|
||||
rooms: int | None = force_rooms
|
||||
else:
|
||||
rooms_raw = obj_info.get("rooms")
|
||||
rooms = int(rooms_raw) if rooms_raw is not None else None
|
||||
|
||||
square_price_raw = item.get("squarePrice")
|
||||
price_per_m2: int | None = int(square_price_raw) if square_price_raw else None
|
||||
if price_per_m2 is None and area_m2 and area_m2 > 0:
|
||||
price_per_m2 = int(price / area_m2)
|
||||
|
||||
# ── Описание + repair-инференс + minhash (Layer A enrichment) ─────
|
||||
# DomClick search не отдаёт renovation-enum: repair_state выводим из
|
||||
# текста описания (как cian #622 fallback — «евроремонт»/«без отделки»).
|
||||
# description_minhash — sha1 от описания (mirror cian) для cross-source
|
||||
# дедупа.
|
||||
description_raw = item.get("description")
|
||||
description: str | None = None
|
||||
if isinstance(description_raw, str):
|
||||
description = description_raw.strip() or None
|
||||
|
||||
repair_state: str | None = None
|
||||
description_minhash: str | None = None
|
||||
if description:
|
||||
repair_state = infer_repair_state_from_text(description)
|
||||
description_minhash = hashlib.sha1(
|
||||
description.lower().encode("utf-8", errors="replace")
|
||||
).hexdigest()[:32]
|
||||
|
||||
# ── Продавец / агентство ──────────────────────────────────────────
|
||||
seller = item.get("seller")
|
||||
if not isinstance(seller, dict):
|
||||
seller = {}
|
||||
agency_name = _extract_agency_name(seller)
|
||||
|
||||
flat_complex = item.get("flatComplex") or {}
|
||||
raw_payload: dict[str, Any] = {
|
||||
"isRosreestrApproved": item.get("isRosreestrApproved"),
|
||||
"squarePrice": square_price_raw,
|
||||
"lastPriceHistoryState": item.get("lastPriceHistoryState"),
|
||||
"flatComplex": {
|
||||
"name": flat_complex.get("name"),
|
||||
"id": flat_complex.get("id"),
|
||||
"slug": flat_complex.get("slug"),
|
||||
}
|
||||
if flat_complex
|
||||
else None,
|
||||
"updatedDate": item.get("updatedDate"),
|
||||
}
|
||||
|
||||
# ── Доп. SERP-поля (Layer A enrichment) — мержим в raw_payload, ────
|
||||
# сбрасывая None-значения чтобы payload оставался компактным; ничего
|
||||
# из существующих ключей не теряем.
|
||||
seller_compact = {
|
||||
k: seller.get(k) for k in ("agent", "company") if seller.get(k) is not None
|
||||
}
|
||||
_enrich: dict[str, Any] = {
|
||||
"is_sber_collateral": item.get("isSberCollateral"),
|
||||
"has_discount": item.get("hasDiscount"),
|
||||
"discount_value": item.get("discountValue"),
|
||||
"duplicates_offer_count": item.get("duplicatesOfferCount"),
|
||||
"seller": seller_compact or None,
|
||||
}
|
||||
raw_payload.update({k: v for k, v in _enrich.items() if v is not None})
|
||||
|
||||
publish_date = _parse_publish_date(item.get("publishedDate"))
|
||||
|
||||
return ScrapedLot(
|
||||
source="domklik",
|
||||
source_url=source_url,
|
||||
source_id=source_id,
|
||||
address=address,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
rooms=rooms,
|
||||
area_m2=area_m2,
|
||||
floor=floor,
|
||||
total_floors=total_floors,
|
||||
year_built=year_built,
|
||||
price_rub=int(price),
|
||||
price_per_m2=price_per_m2,
|
||||
listing_segment="vtorichka",
|
||||
publish_date=publish_date,
|
||||
description=description,
|
||||
repair_state=repair_state,
|
||||
description_minhash=description_minhash,
|
||||
agency_name=agency_name,
|
||||
raw_payload=raw_payload,
|
||||
)
|
||||
except Exception:
|
||||
self.parse_failures += 1
|
||||
logger.warning(
|
||||
"domklik: _map_item failed for item id=%r (parse_failures=%d)",
|
||||
item.get("id"),
|
||||
self.parse_failures,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# ── Convenience runner (для Celery tasks) ────────────────────────────────────
|
||||
|
||||
|
||||
async def scrape_domclick_city(
|
||||
config: ScraperConfig,
|
||||
city_id: int,
|
||||
rooms: list[int] | None = None,
|
||||
pages: int = 100,
|
||||
*,
|
||||
delay_provider: Callable[[str], float] | None = None,
|
||||
) -> list[ScrapedLot]:
|
||||
"""Удобная точка входа для вызова из Celery tasks.
|
||||
|
||||
Пример::
|
||||
|
||||
import asyncio
|
||||
lots = asyncio.run(scrape_domclick_city(config, city_id=4, rooms=None))
|
||||
"""
|
||||
async with DomClickScraper(config, delay_provider=delay_provider) as scraper:
|
||||
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
||||
Loading…
Add table
Reference in a new issue