"""DOM.RF analytics scraper using Playwright (headless Chromium). Bypasses: - ServicePipe WAF (Chromium executes JS challenge automatically) - Lazy-loading (wait for `networkidle` + page-specific triggers) - Cookie session (Chromium maintains it across navigations) Captures: every JSON response from /api/* endpoints on the same domain. Saves: data/raw/domrf_full/
/.json (one JSON per endpoint). Usage: PYTHONIOENCODING=utf-8 python data/sql/30_scrape_domrf.py [--headed] [--page ] Run --headed first to verify WAF passes; switch to default headless after. """ import asyncio, json, os, re, sys from urllib.parse import urlsplit, parse_qs, urlencode, urlunsplit from playwright.async_api import async_playwright ROOT = os.path.join(os.path.dirname(__file__), '..', 'raw', 'domrf_full') BASE = 'https://xn--80az8a.xn--d1aqf.xn--p1ai' # 12 analytics pages — name (used as folder) → URL path PAGES = [ ('housing', '/аналитика/показатели_жилищного_строительства'), ('launch', '/аналитика/запуски-и-вводы?repYear=2026&calculationType=SQUARE&period=YEAR'), ('share_construction','/аналитика/долевое_строительство'), ('housing_dev', '/аналитика/жилищное_строительство'), ('escrow', '/аналитика/эскроу'), ('realization', '/аналитика/реализация_строящихся_квартир'), ('sold_ready', '/аналитика/распроданность-стройготовность?repYear=2026&repMonth=3'), ('quartirografia', '/аналитика/квартирография'), ('commissioning', '/аналитика/ввод_жилья'), ('mortgage_rates', '/аналитика/ставки_предложений_по_ипотеке'), ('mortgage_stats', '/аналитика/ипотечное_кредитование'), ('stat_series', '/аналитика/статистические_ряды'), ] def safe_filename(url: str) -> str: """Turn API URL into safe filename: path + query → snake_case.""" parts = urlsplit(url) base = parts.path.split('/api/')[-1].strip('/') base = re.sub(r'[^A-Za-z0-9_\-]', '_', base)[:100] if parts.query: q = re.sub(r'[^A-Za-z0-9_\-]', '_', parts.query)[:80] base = f'{base}__{q}' return base + '.json' def upgrade_size(url: str) -> str: """If URL has size=15 (or any small value), upgrade to size=1000 to get full data.""" parts = urlsplit(url) if not parts.query: return url qs = parse_qs(parts.query, keep_blank_values=True) if 'size' in qs: qs['size'] = ['1000'] new_q = urlencode({k: v[0] for k, v in qs.items()}) return urlunsplit((parts.scheme, parts.netloc, parts.path, new_q, parts.fragment)) # --------------------------------------------------------------------------- # Page-specific lazy-load triggers # --------------------------------------------------------------------------- # Most DOM.RF analytics pages render Material-UI tabs/selects; data fetches # fire on tab/option switch. The "kitchen sink" trigger walks every tab and # every Select option, with networkidle waits in between. Pages with custom # UI (tree pickers, multi-step filters) get dedicated triggers below. async def _wait_quiet(page, ms=2500): try: await page.wait_for_load_state('networkidle', timeout=8000) except Exception: pass await page.wait_for_timeout(ms) async def _click_all_tabs(page): """Click every Material-UI tab one-by-one, waiting between to capture XHR.""" tab_count = await page.evaluate('''() => document.querySelectorAll( '[role="tab"], .MuiTab-root, button[data-tab]' ).length''') print(f' tabs found: {tab_count}') for i in range(tab_count): try: await page.evaluate( '''(i) => { const tabs = document.querySelectorAll( '[role="tab"], .MuiTab-root, button[data-tab]' ); if (tabs[i]) tabs[i].click(); }''', i, ) await _wait_quiet(page, 2000) except Exception as e: print(f' tab[{i}] err: {e}') async def _iterate_selects(page, max_options=20): """Open every MUI Select / native