de544c25b0
Рисовалка топологии Б (§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>
95 lines
4.1 KiB
JavaScript
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();
|
|
}
|
|
}
|