Files
portal/render-service/browser.mjs
T
Дмитрий 9022b15b42 perf: ускорение автоподбора — рендер-канал и шаг 2
render-service: умное ожидание вместо фикс-пауз. 2ГИС карточка 7.2 -> 1.6с, поиск 17 -> 5с, Яндекс-карточка 6.5 -> 2.8с. Качество проверено вживую.
шаг 2: таймаут открытия сайта 45 -> 15с, убран лишний повтор, карточки 2ГИС пачкой htmlBatch. Полнота сохранена, скорость выросла.
доки: ранбук выката рендер-виртуалки, фаза 8.

Ветка worktree-avtopodbor, не прод. Тесты 459/459, Pint чист.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 04:58:46 +03:00

145 lines
7.4 KiB
JavaScript

// Рисовалка: постоянный браузер + маскировка + контекст-на-страницу + 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<string>} данные (пустая строка при неудаче)
*/
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();
}
}