Files
portal/render-service/server.mjs
T
Дмитрий 7512ec2bad feat(autopodbor): полное прокси-покрытие Яндекса через виртуалку (фаза 7b)
Закрыт хвост §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>
2026-07-05 19:18:00 +03:00

69 lines
3.0 KiB
JavaScript

// HTTP рендер-сервис на виртуалке (топология Б, §5.6). POST /render {url,type} → {data}.
// Портал (SelfRenderClient) зовёт как xf4. Ни базы, ни Redis, ни денег — только рисует.
// Секрет портал↔виртуалка — заголовок X-Render-Key (env RENDER_KEY). Параллельность контекстов —
// env MAX_CONCURRENCY (по памяти виртуалки, §9). Прокси — env PROXIES. Всё из окружения, не из гита.
import http from 'node:http';
import { render, closeBrowser } from './browser.mjs';
import { createProxyPool } from './proxy-pool.mjs';
const PORT = Number(process.env.PORT || 8790);
const KEY = String(process.env.RENDER_KEY || '');
const MAX = Math.max(1, Number(process.env.MAX_CONCURRENCY || 4));
const proxies = createProxyPool(process.env.PROXIES);
// Семафор: не больше MAX браузер-контекстов одновременно (память). Лишние ждут очереди.
let active = 0;
const waiters = [];
function acquire() {
if (active < MAX) { active++; return Promise.resolve(); }
return new Promise((res) => waiters.push(res));
}
function release() {
active--;
const next = waiters.shift();
if (next) { active++; next(); }
}
function readBody(req) {
return new Promise((resolve) => {
let buf = '';
req.on('data', (c) => { buf += c; if (buf.length > 1_000_000) req.destroy(); });
req.on('end', () => resolve(buf));
});
}
const server = http.createServer(async (req, res) => {
const json = (code, obj) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj));
};
if (req.method === 'GET' && req.url === '/health') return json(200, { ok: true, active, max: MAX, proxies: proxies.size });
if (req.method !== 'POST' || req.url !== '/render') return json(404, { error: 'not found' });
if (KEY === '' || req.headers['x-render-key'] !== KEY) return json(401, { error: 'unauthorized' });
let payload;
try { payload = JSON.parse(await readBody(req)); } catch { return json(400, { error: 'bad json' }); }
const url = String(payload?.url || '');
const allowed = ['initialState', 'html', 'yandex-list'];
const type = allowed.includes(payload?.type) ? payload.type : 'html';
const scrolls = Math.max(1, Math.min(30, Number(payload?.scrolls) || 6));
if (!/^https?:\/\//i.test(url)) return json(400, { error: 'bad url' });
await acquire();
try {
const data = await render(url, type, proxies.next(), scrolls);
json(200, { data });
} catch (e) {
json(200, { data: '', error: String(e).slice(0, 200) }); // не роняем поток портала — пустые данные
} finally {
release();
}
});
server.listen(PORT, () => console.log(`[render-service] :${PORT} max=${MAX} proxies=${proxies.size}`));
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, async () => { await closeBrowser(); server.close(() => process.exit(0)); });
}