feat(scraper-kit): copy domclick provider with protocol injection, strangler (#2133 domclick)
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m28s
Deploy Trade-In / build-backend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Has been cancelled
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m28s
Deploy Trade-In / build-backend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Has been cancelled
This commit is contained in:
parent
df5e9003df
commit
b7abaeb569
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