fix(scrapers): bypass DOM.РФ WAF + update renamed extras endpoints

DOM.РФ накатил WAF между 2026-05-17 и 2026-05-24 на /сервисы/api/object/*/*.
Без session cookies (___dmpkit___, domain_sid) — все запросы → 403 + WAF
challenge HTML. Cookies выдаются после visit на /сервисы/каталог-новостроек/.

Дополнительно DOM.РФ переименовал 6 endpoints (photos, 5 × doc_*) — старые
URLs возвращают тот же WAF catch-all.

Run 26 (2026-05-24): 15,340 fail requests (1534 obj × 10 endpoints).
Extras snapshots не обновлялись с 2026-05-17.

- stealth.py: BrowserSession.warm_up() — visit catalog listing, кэшит WAF cookies
- domrf_kn.py: warm_up() перед Phase A/B/C (покрывает все extras запросы)
- domrf_catalog_object.py: warm_up() перед SSR HTML fetch batch
This commit is contained in:
lekss361 2026-05-24 13:26:22 +03:00
parent 02b48b8d04
commit 1ccb44687c
3 changed files with 48 additions and 0 deletions

View file

@ -441,6 +441,11 @@ async def scrape_catalog_objects(
# Страницы каталога публичные — Basic auth не нужен # Страницы каталога публичные — Basic auth не нужен
auth=None, auth=None,
) as session: ) as session:
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies.
# DOM.РФ WAF (2026-05-24) блокирует SSR-страницы объектов без этих cookies.
# Idempotent — один вызов покрывает весь batch через этот BrowserSession.
await session.warm_up()
for obj_id in obj_ids: for obj_id in obj_ids:
stats["processed"] += 1 stats["processed"] += 1
ok = await scrape_catalog_object(db, session, obj_id, snapshot_date) ok = await scrape_catalog_object(db, session, obj_id, snapshot_date)

View file

@ -1684,6 +1684,11 @@ async def run_region_sweep(
load_state=load_state, load_state=load_state,
save_state=save_state, save_state=save_state,
) as sess: ) as sess:
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies
# (___dmpkit___, domain_sid). Required since 2026-05-24 — without these
# cookies all /сервисы/api/object/*/* requests return 403 + WAF HTML.
await sess.warm_up()
# ── Phase A — fetch objects (skip on resume) ──────────────────── # ── Phase A — fetch objects (skip on resume) ────────────────────
if not resume_from_run_id: if not resume_from_run_id:
for status in statuses: for status in statuses:

View file

@ -106,6 +106,7 @@ class BrowserSession:
self._page: Page | None = None self._page: Page | None = None
self._sem = asyncio.Semaphore(8) self._sem = asyncio.Semaphore(8)
self._request_count = 0 self._request_count = 0
self._warmed_up = False
async def __aenter__(self) -> BrowserSession: async def __aenter__(self) -> BrowserSession:
await self._bootstrap() await self._bootstrap()
@ -142,6 +143,43 @@ class BrowserSession:
logger.info("bootstrap: storage_state saved to %s", self.save_state) logger.info("bootstrap: storage_state saved to %s", self.save_state)
logger.info("bootstrap: page ready, WAF challenge passed") logger.info("bootstrap: page ready, WAF challenge passed")
async def warm_up(self, force: bool = False) -> None:
"""Visit catalog listing to obtain WAF cookies (___dmpkit___, domain_sid).
DOM.РФ WAF (накатан между 2026-05-17 и 2026-05-24) требует session cookies для
любого запроса на /сервисы/api/object/*/*. Cookies выдаются JS-челленджем при
visit на /сервисы/каталог-новостроек/. Один warm-up на BrowserSession достаточен
cookies валидны для всего домена.
Idempotent: повторные вызовы без force=True no-op после первого успешного warm_up.
"""
if self._warmed_up and not force:
return
if self._context is None:
raise RuntimeError("BrowserSession not bootstrapped — call inside `async with` block")
page = await self._context.new_page()
try:
await page.goto(
f"{BASE_URL}/сервисы/каталог-новостроек/",
wait_until="domcontentloaded",
timeout=30_000,
)
await page.wait_for_timeout(2000) # JS challenge ставит cookies async
cookies = await self._context.cookies()
names = {c["name"] for c in cookies}
critical = {"___dmpkit___", "domain_sid"}
got = names & critical
if not got:
logger.warning(
"warm_up: WAF cookies missing after catalog visit (have: %s)",
sorted(names),
)
else:
logger.info("warm_up: got WAF cookies %s (total=%d)", sorted(got), len(cookies))
self._warmed_up = True
finally:
await page.close()
async def get_json(self, path: str, params: dict[str, Any]) -> dict[str, Any]: async def get_json(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
"""Fetch JSON via fetch() in browser. Retries with backoff on transient errors.""" """Fetch JSON via fetch() in browser. Retries with backoff on transient errors."""
if self._page is None: if self._page is None: