9d7b1e3ba1
Закрыт хвост §5.3: node-скриптовые точки Яндекса уведены на рендер-сервис, чтобы
под self_render ВЕСЬ Яндекс шёл через виртуалку+прокси (не крутил браузер на портале).
- YandexListSource (интерфейс) + LocalYandexListSource (xf4, локальный Playwright) +
RenderServiceYandexListSource (self-render, POST type=yandex-list на виртуалку).
- PlaywrightYandexDirectory (листинг шаг 1) и PlaywrightYandexPhoneSearch (разворот)
расшиты на YandexListSource — заодно стали тестируемы офлайн (6 новых тестов).
- PlaywrightYandexCardReader под self_render получает SelfRenderClient (был PageFetcher).
- Рендер-сервис: режим type=yandex-list (скролл ленты + сбор организаций → {orgs}),
проброс scrolls.
- Провайдер: yandexListSource() по флагу; разворот-Яндекс на виртуалку+прокси.
Итог: при self_render ВКЛ на xf4 и на локальном браузере портала не остаётся НИ ОДНОЙ
точки — 2ГИС и весь Яндекс через виртуалку+прокси. 459/459 тестов, Pint+Larastan чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
132 lines
6.0 KiB
JavaScript
132 lines
6.0 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 });
|
|
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();
|
|
}
|
|
}
|