// Рисовалка: постоянный браузер + маскировка + контекст-на-страницу + museum double-tap + прокси. // Отдаёт для 2ГИС встроенный window.initialState, для Яндекса/сайтов — HTML. Рецепт маскировки и // double-tap проверены спайком 05.07 (docs/superpowers/findings/2026-07-05-2gis-initialstate-spike.md). import { chromium } from 'playwright'; const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; // Маскировка под живой Chrome (скрыть автоматизацию). Ставится в каждый контекст до загрузки. const STEALTH = () => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'languages', { get: () => ['ru-RU', 'ru'] }); Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); window.chrome = { runtime: {} }; }; let browserPromise = null; // Один браузер на процесс, переиспользуем. Контекст (куки+прокси) — на каждую страницу отдельно. function browser() { if (!browserPromise) { browserPromise = chromium.launch({ headless: true, args: ['--disable-blink-features=AutomationControlled', '--no-sandbox'], }); } return browserPromise; } export async function closeBrowser() { if (browserPromise) { const b = await browserPromise; browserPromise = null; await b.close(); } } /** * Отрисовать страницу. * @param {string} url * @param {'initialState'|'html'|'yandex-list'} type initialState → window.initialState (2ГИС); * html → HTML; yandex-list → лента Яндекс.Карт со скроллом → JSON {orgs:[...]} * @param {object|null} proxy playwright-объект прокси {server,username?,password?} или null * @param {number} scrolls сколько раз прокрутить ленту (только для yandex-list) * @returns {Promise} данные (пустая строка при неудаче) */ export async function render(url, type, proxy = null, scrolls = 6) { const b = await browser(); const ctx = await b.newContext({ locale: 'ru-RU', userAgent: UA, viewport: { width: 1366, height: 900 }, ...(proxy ? { proxy } : {}), }); try { await ctx.addInitScript(STEALTH); // Отсечка картинок/шрифтов/медиа — экономит трафик прокси и время (данные не в них). §5.4 await ctx.route('**/*', (route) => { const t = route.request().resourceType(); if (t === 'image' || t === 'font' || t === 'media') return route.abort(); return route.continue(); }); const page = await ctx.newPage(); const isGis = url.includes('2gis'); const isGisSearch = isGis && url.includes('/search/'); // Яндекс.Карты: лента ленивая — скроллим контейнер и собираем организации (как render-yandex-list.cjs). if (type === 'yandex-list') { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }); await page.waitForTimeout(4000); for (let i = 0; i < Math.max(1, scrolls); i++) { await page.evaluate(() => { const el = document.querySelector('.scroll__container, .search-list-view__list, [class*="search-list"]'); if (el) el.scrollBy(0, 2000); }); await page.waitForTimeout(800); } const orgs = await page.$$eval('a[href*="/maps/org/"]', (els) => { const seen = new Set(); const out = []; for (const e of els) { const href = e.getAttribute('href') || ''; const m = href.match(/\/maps\/org\/[a-z0-9_-]+\/(\d+)/i); if (!m || seen.has(m[1])) continue; seen.add(m[1]); const name = (e.getAttribute('aria-label') || e.textContent || '').trim(); if (!name) continue; let box = e; for (let up = 0; up < 6; up++) { if (box.parentElement) box = box.parentElement; if (box.className && /snippet|search-business|card/i.test(box.className)) break; } const catEl = box.querySelector('[class*="category"], [class*="rubric"]'); const category = catEl ? catEl.textContent.trim().slice(0, 80) : ''; out.push({ name, id: m[1], href: href.split('?')[0], category }); } return out; }); return JSON.stringify({ orgs }); } await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }); if (isGis) { // Пройти заглушку «обновите браузер» (ставит cookie-обход) — кликаем, как только видна, без фикс-паузы. try { const skip = page.getByText(/перейти в 2ГИС|Пропустить/i).first(); if (await skip.isVisible({ timeout: 1500 })) { await skip.click({ timeout: 3000 }); await page.waitForTimeout(400); } } catch { /* заглушки нет — ок */ } // museum double-tap: для поиска return_url заглушки дважды закодирован (%20→«20») → повторный // чистый заход по тому же URL (cookie-обход уже стоит) даёт корректный поиск. Для /firm/ не нужно. if (isGisSearch) { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }); } // Умное ожидание вместо фикс-пауз: ждём появления НАСТОЯЩИХ данных в initialState // (пагинация для поиска, профиль фирмы для карточки), а не заведомо-длинную паузу. §5.4. await page.waitForFunction((search) => { const s = window.initialState; if (!s || !s.data) return false; return search ? !!(s.data.search && s.data.search.pagination && Object.keys(s.data.search.pagination).length) : !!(s.data.entity && s.data.entity.profile && Object.keys(s.data.entity.profile).length); }, isGisSearch, { timeout: 20000, polling: 200 }).catch(() => { /* фолбэк: отдадим что есть */ }); } else { // Яндекс-карточка (html): ждём появления блоков карточки (телефоны/сайт), потом короткий добор — // вместо глухих 5с. Если карточки нет (сайт/иное) — фолбэк по таймауту, не хуже прежнего. await page.waitForFunction( () => !!document.querySelector('[class*="orgpage-phones-view"], [class*="business-urls-view"], [class*="orgpage-header"]'), null, { timeout: 5000, polling: 200 }, ).catch(() => { /* нет карточки — отдадим что есть */ }); await page.waitForTimeout(1200); } if (type === 'initialState') { const state = await page.evaluate(() => { try { return window.initialState ? JSON.stringify(window.initialState) : ''; } catch { return ''; } }); return state || ''; } return await page.content(); } finally { await ctx.close(); } }