gendesign/data/sql/30_scrape_domrf.py
2026-04-27 13:05:36 +03:00

501 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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/<section>/<endpoint>.json (one JSON per endpoint).
Usage:
PYTHONIOENCODING=utf-8 python data/sql/30_scrape_domrf.py [--headed] [--page <name>]
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 <select>, pick each option, wait."""
select_count = await page.evaluate('''() => document.querySelectorAll(
'[role="combobox"], [role="button"][aria-haspopup="listbox"], select'
).length''')
print(f' selects found: {select_count}')
for i in range(select_count):
try:
opt_count = await page.evaluate(
'''async (i) => {
const sels = document.querySelectorAll(
'[role="combobox"], [role="button"][aria-haspopup="listbox"], select'
);
const sel = sels[i];
if (!sel) return 0;
if (sel.tagName === 'SELECT') return sel.options.length;
sel.click();
await new Promise(r => setTimeout(r, 800));
return document.querySelectorAll('[role="option"], li[data-value]').length;
}''',
i,
)
opt_count = min(opt_count, max_options)
for j in range(opt_count):
try:
await page.evaluate(
'''async ([i, j]) => {
const sels = document.querySelectorAll(
'[role="combobox"], [role="button"][aria-haspopup="listbox"], select'
);
const sel = sels[i];
if (!sel) return;
if (sel.tagName === 'SELECT') {
sel.selectedIndex = j;
sel.dispatchEvent(new Event('change', {bubbles: true}));
} else {
sel.click();
await new Promise(r => setTimeout(r, 600));
const opts = document.querySelectorAll('[role="option"], li[data-value]');
if (opts[j]) opts[j].click();
}
}''',
[i, j],
)
await _wait_quiet(page, 1500)
except Exception as e:
print(f' select[{i}].opt[{j}] err: {e}')
break
except Exception as e:
print(f' select[{i}] err: {e}')
async def _click_all_safe_buttons(page, deny=('Войти', 'Регистр', 'Выйти', 'Меню')):
"""Click every non-disabled button whose text doesn't match deny list."""
deny_js = json.dumps(list(deny))
btn_count = await page.evaluate(
'''(deny) => Array.from(document.querySelectorAll('button:not([disabled])'))
.filter(b => !deny.some(d => (b.textContent || '').includes(d))).length''',
deny,
)
print(f' safe buttons: {btn_count}')
for i in range(min(btn_count, 30)):
try:
await page.evaluate(
'''([i, deny]) => {
const bs = Array.from(document.querySelectorAll('button:not([disabled])'))
.filter(b => !deny.some(d => (b.textContent || '').includes(d)));
if (bs[i]) bs[i].click();
}''',
[i, list(deny)],
)
await _wait_quiet(page, 1500)
except Exception:
pass
async def trigger_kitchen_sink(page):
"""Generic: scroll, click all tabs, iterate all selects."""
for _ in range(2):
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
await page.wait_for_timeout(1200)
await page.evaluate('window.scrollTo(0, 0)')
await page.wait_for_timeout(800)
await _click_all_tabs(page)
await _iterate_selects(page, max_options=10)
await _click_all_safe_buttons(page)
# ---------------------------------------------------------------------------
# Direct-API helpers — fetch from inside browser ctx so cookies/WAF survive
# ---------------------------------------------------------------------------
# These five pages don't fire XHR on user interaction — they're SSR-baked or
# expose a documented REST API. Instead of clicking we just call the APIs
# directly via page.evaluate(fetch()) and dump the JSON via the response hook.
# Page spec source: knowledge graph entities DomRF_Tab*_*_Apr26.
REGIONS_FULL_RU = list(range(1, 100)) # try region codes 1..99 (Rosstat-style)
async def _fetch_and_save(page, url, out_dir, seen_urls, saved):
"""Fetch URL inside browser context (so cookies/WAF stay valid), save body."""
if url in seen_urls:
return
try:
resp = await page.evaluate(
'''async (u) => {
const r = await fetch(u, {credentials: 'include'});
const t = await r.text();
return {status: r.status, ct: r.headers.get('content-type') || '', text: t};
}''',
url,
)
except Exception as e:
print(f' direct err {url}: {e}')
return
if not resp or resp.get('status', 0) >= 400:
return
text = resp.get('text') or ''
if not text or len(text) < 20:
return
seen_urls.add(url)
fname = safe_filename(url)
with open(os.path.join(out_dir, fname), 'w', encoding='utf-8') as f:
f.write(text)
saved.append((fname, len(text), url))
print(f' direct {fname} ({len(text)}b)')
async def _dump_next_data(page, out_dir, saved):
"""Pull __NEXT_DATA__ JSON blob from the rendered HTML and save it."""
blob = await page.evaluate('''() => {
const el = document.getElementById('__NEXT_DATA__');
return el ? el.textContent : null;
}''')
if not blob:
return
fname = '__NEXT_DATA__.json'
with open(os.path.join(out_dir, fname), 'w', encoding='utf-8') as f:
f.write(blob)
saved.append((fname, len(blob), 'inline:__NEXT_DATA__'))
print(f' ssr {fname} ({len(blob)}b)')
async def trigger_housing(page, out_dir, seen_urls, saved):
"""Tab 1: показатели жилищного строительства — SSR + portal-analytics REST."""
await _dump_next_data(page, out_dir, saved)
base = '/portal-analytics/api/dashboard'
# Aggregate (no filter) variants first
for ep in ('by-region', 'by-developer', 'by-room-count', 'by-flat-area'):
await _fetch_and_save(page, f'{base}/{ep}', out_dir, seen_urls, saved)
# Per-region drilldown
for rid in REGIONS_FULL_RU:
await _fetch_and_save(page, f'{base}/by-region?regionId={rid}', out_dir, seen_urls, saved)
await _fetch_and_save(page, f'{base}/by-room-count?regionId={rid}', out_dir, seen_urls, saved)
await _fetch_and_save(page, f'{base}/by-flat-area?regionId={rid}', out_dir, seen_urls, saved)
async def trigger_housing_dev(page, out_dir, seen_urls, saved):
"""Tab 2: жилищное строительство — different Redux slice, same portal-analytics base."""
await _dump_next_data(page, out_dir, saved)
# Probe common analytics endpoints
bases = [
'/portal-analytics/api/housing',
'/portal-analytics/api/dashboard',
'/аналитика/api/housing',
]
for b in bases:
for ep in ('summary', 'by-region', 'by-developer', 'by-period', 'overview', 'main'):
await _fetch_and_save(page, f'{b}/{ep}', out_dir, seen_urls, saved)
await trigger_kitchen_sink(page)
async def trigger_realization(page, out_dir, seen_urls, saved):
"""Tab 6: реализация — /аналитика/api/rpp/* documented in graph."""
await _dump_next_data(page, out_dir, saved)
base = '/аналитика/api/rpp'
rep_year, rep_month = 2026, 3
# RF-level (no regionCode)
for ep in ('total', 'housing', 'readyYear'):
await _fetch_and_save(
page, f'{base}/{ep}?repMonth={rep_month}&repYear={rep_year}',
out_dir, seen_urls, saved,
)
await _fetch_and_save(
page,
f'{base}/developer?typeSquare=total&repMonth={rep_month}&repYear={rep_year}'
f'&developerOrder=totalSquare:desc',
out_dir, seen_urls, saved,
)
# Per-region (89 регионов РФ — codes mostly in 1..99)
for rcode in REGIONS_FULL_RU:
for ep in ('total', 'housing', 'readyYear'):
await _fetch_and_save(
page,
f'{base}/{ep}?regionCode={rcode}&repMonth={rep_month}&repYear={rep_year}',
out_dir, seen_urls, saved,
)
for ts in ('total', 'living'):
await _fetch_and_save(
page,
f'{base}/developer?typeSquare={ts}&regionCode={rcode}'
f'&repMonth={rep_month}&repYear={rep_year}'
f'&developerOrder=totalSquare:desc',
out_dir, seen_urls, saved,
)
async def trigger_mortgage_rates(page, out_dir, seen_urls, saved):
"""Tab 10: ставки предложений по ипотеке — likely SSR; probe known APIs."""
await _dump_next_data(page, out_dir, saved)
bases = [
'/portal-analytics/api/mortgage-rates',
'/portal-analytics/api/mortgage',
'/аналитика/api/mortgage-rates',
'/аналитика/api/mortgage',
]
for b in bases:
for ep in ('', '/list', '/banks', '/offers', '/dashboard', '/summary'):
await _fetch_and_save(page, f'{b}{ep}', out_dir, seen_urls, saved)
await trigger_kitchen_sink(page)
async def trigger_stat_series(page, out_dir, seen_urls, saved):
"""Tab 12: статистические ряды — index of XLSX files baked into __NEXT_DATA__.
Per graph: data lives in props.initialState.housingData.list (CMS hierarchy).
XLSX URLs: /site/binaries/content/assets/domrf/xlsdashboard/{filename}.xlsx
Strategy: dump __NEXT_DATA__ + extract every .xlsx href and download.
"""
await _dump_next_data(page, out_dir, saved)
# Extract all .xlsx links from the rendered DOM and the __NEXT_DATA__ blob
links = await page.evaluate('''() => {
const set = new Set();
document.querySelectorAll('a[href]').forEach(a => {
const h = a.getAttribute('href');
if (h && h.toLowerCase().includes('.xlsx')) set.add(h);
});
const blob = document.getElementById('__NEXT_DATA__');
if (blob) {
const re = /["'](\\/site\\/binaries[^"']+\\.xlsx)["']/g;
let m;
while ((m = re.exec(blob.textContent)) !== null) set.add(m[1]);
}
return Array.from(set);
}''')
print(f' xlsx links found: {len(links)}')
xlsx_dir = os.path.join(out_dir, 'xlsx')
os.makedirs(xlsx_dir, exist_ok=True)
for href in links:
url = href if href.startswith('http') else BASE + href
# Download via fetch in browser context, then write bytes locally
try:
b64 = await page.evaluate(
'''async (u) => {
const r = await fetch(u, {credentials: 'include'});
if (!r.ok) return null;
const buf = await r.arrayBuffer();
let s = '';
const arr = new Uint8Array(buf);
for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]);
return btoa(s);
}''',
url,
)
if not b64:
continue
import base64
data = base64.b64decode(b64)
fname = re.sub(r'[^A-Za-z0-9_.\-]', '_', os.path.basename(href.split('?')[0]))
with open(os.path.join(xlsx_dir, fname), 'wb') as f:
f.write(data)
saved.append((f'xlsx/{fname}', len(data), url))
print(f' xlsx {fname} ({len(data)}b)')
except Exception as e:
print(f' xlsx err {href}: {e}')
PAGE_TRIGGERS = {
'housing': trigger_housing,
'housing_dev': trigger_housing_dev,
'realization': trigger_realization,
'mortgage_rates': trigger_mortgage_rates,
'stat_series': trigger_stat_series,
}
async def scrape_page(browser, name, path):
out_dir = os.path.join(ROOT, name)
os.makedirs(out_dir, exist_ok=True)
print(f'\n=== {name} ({path}) ===')
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
locale='ru-RU',
viewport={'width': 1920, 'height': 1080},
)
page = await context.new_page()
seen_urls = set()
saved = []
async def on_response(resp):
url = resp.url
# Match same-origin /api/ endpoints, skip yandex/sendsay trackers
if 'xn--80az8a.xn--d1aqf.xn--p1ai' not in url or '/api/' not in url:
return
ct = (resp.headers.get('content-type') or '').lower()
if 'json' not in ct:
return
if url in seen_urls:
return
seen_urls.add(url)
try:
text = await resp.text()
fname = safe_filename(url)
with open(os.path.join(out_dir, fname), 'w', encoding='utf-8') as f:
f.write(text)
saved.append((fname, len(text), url))
print(f' saved {fname} ({len(text)}b)')
except Exception as e:
print(f' ERR {url}: {e}')
page.on('response', lambda r: asyncio.create_task(on_response(r)))
full_url = BASE + path
try:
await page.goto(full_url, wait_until='domcontentloaded', timeout=60_000)
try:
await page.wait_for_load_state('networkidle', timeout=30_000)
except Exception:
pass
await page.wait_for_timeout(5000)
# Per-page triggers (falls back to kitchen sink for everything new)
trigger = PAGE_TRIGGERS.get(name)
try:
if trigger is None:
await trigger_kitchen_sink(page)
else:
# New triggers take (page, out_dir, seen_urls, saved) so they
# can perform direct API fetches and dump SSR blobs themselves.
await trigger(page, out_dir, seen_urls, saved)
except Exception as e:
print(f' trigger err: {e}')
# Final settle
await _wait_quiet(page, 3000)
# Re-fetch with size=1000 for endpoints that have small page-size
for url in list(seen_urls):
up = upgrade_size(url)
if up != url and up not in seen_urls:
try:
resp = await page.evaluate(
'''(u) => fetch(u, {credentials:'include'})
.then(r => r.text()).then(t => ({status: 200, text: t}))''',
up,
)
if resp and resp.get('text'):
seen_urls.add(up)
fname = safe_filename(up)
with open(os.path.join(out_dir, fname), 'w', encoding='utf-8') as f:
f.write(resp['text'])
saved.append((fname, len(resp['text']), up))
print(f' upgraded {fname} ({len(resp["text"])}b)')
except Exception as e:
print(f' upgrade err {up}: {e}')
finally:
await context.close()
return saved
async def main():
headed = '--headed' in sys.argv
target = None
if '--page' in sys.argv:
target = sys.argv[sys.argv.index('--page') + 1]
pages = [(n, p) for n, p in PAGES if target is None or n == target]
print(f'Scraping {len(pages)} pages, headed={headed}')
os.makedirs(ROOT, exist_ok=True)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=not headed)
all_saved = {}
for name, path in pages:
try:
all_saved[name] = await scrape_page(browser, name, path)
except Exception as e:
print(f'PAGE {name} FAILED: {e}')
all_saved[name] = []
await browser.close()
print('\n=== SUMMARY ===')
for name, saved in all_saved.items():
total = sum(s[1] for s in saved)
print(f' {name:22s} files={len(saved):3d} total={total:>10}b')
if __name__ == '__main__':
asyncio.run(main())