Files
portal/render-service/browser.mjs
T
Дмитрий de544c25b0 feat(autopodbor): рендер-сервис на виртуалке — Node+Playwright (фаза 6)
Рисовалка топологии Б (§5.6): портал зовёт по HTTP как xf4, она рисует и
отдаёт данные. Ни базы, ни Redis, ни денег. Смоук на живом 2ГИС: листинг 12
фирм, карточка 1 — чистый initialState.

- browser.mjs — постоянный браузер + маскировка + контекст-на-страницу +
  museum double-tap для поиска 2ГИС + отсечка картинок/шрифтов/медиа.
- proxy-pool.mjs — round-robin пул прокси из env PROXIES (резидентные/мобильные RU).
- server.mjs — POST /render {url,type}→{data}, auth X-Render-Key, семафор
  MAX_CONCURRENCY, /health.
- package.json, README.md, smoke.mjs, .gitignore (node_modules не в гит).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:00:48 +03:00

95 lines
4.1 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'} type initialState → window.initialState (2ГИС); html → HTML
* @param {object|null} proxy playwright-объект прокси {server,username?,password?} или null
* @returns {Promise<string>} данные (пустая строка при неудаче)
*/
export async function render(url, type, proxy = null) {
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/');
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(2500);
// Пройти заглушку «обновите браузер» (ставит cookie-обход).
if (isGis) {
try {
const skip = page.getByText(/перейти в 2ГИС|Пропустить/i).first();
if (await skip.isVisible({ timeout: 2000 })) await skip.click({ timeout: 3000 });
} catch { /* заглушки нет — ок */ }
await page.waitForTimeout(3500);
// museum double-tap: для поиска return_url заглушки дважды закодирован (%20→«20») → повторный
// чистый заход по тому же URL (cookie-обход уже стоит) даёт корректный поиск. Для /firm/ не нужно.
if (isGisSearch) {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(6000);
}
} else {
await page.waitForTimeout(5000);
}
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();
}
}