9022b15b42
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>
145 lines
7.4 KiB
JavaScript
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();
|
|
}
|
|
}
|