gekata/server.js
g00dvin 361fa4fafe
Some checks are pending
Build and Push Docker Image / build-and-push (push) Waiting to run
Add 301 logic and cache
2025-09-12 09:13:47 +00:00

233 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// server.js
const express = require('express');
const { chromium } = require('playwright');
const Database = require('better-sqlite3');
const app = express();
const port = process.env.PORT || 3000;
const executablePath = process.env.CHROMIUM_PATH || undefined;
const chromiumArgs = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--no-zygote',
];
const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS || '21600', 10);
const MAX_REDIRECT_STEPS = parseInt(process.env.MAX_REDIRECT_STEPS || '20', 10); // анти-цикл по глубине
const db = new Database(process.env.SQLITE_PATH || './cache.db');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS domain_cache (
domain TEXT PRIMARY KEY,
result_json TEXT NOT NULL, -- JSON массива связанных доменов
final_url TEXT,
redirect_chain_json TEXT, -- JSON журнала редиректов
updated_at INTEGER NOT NULL,
ttl_at INTEGER NOT NULL
);
`);
const stmtSelect = db.prepare(`
SELECT result_json, final_url, redirect_chain_json, updated_at, ttl_at
FROM domain_cache WHERE domain = ?
`);
const stmtUpsert = db.prepare(`
INSERT INTO domain_cache (domain, result_json, final_url, redirect_chain_json, updated_at, ttl_at)
VALUES (@domain, @result_json, @final_url, @redirect_chain_json, @updated_at, @ttl_at)
ON CONFLICT(domain) DO UPDATE SET
result_json = excluded.result_json,
final_url = excluded.final_url,
redirect_chain_json = excluded.redirect_chain_json,
updated_at = excluded.updated_at,
ttl_at = excluded.ttl_at
`);
app.use(express.json());
function extractDomain(url) {
try { return new URL(url).hostname; } catch { return null; }
}
let browser;
async function getBrowser() {
if (browser && browser.isConnected()) return browser;
browser = await chromium.launch({
executablePath,
headless: true,
args: chromiumArgs,
});
return browser;
}
// Вспомогательная функция для сборки полного журнала редиректов через цепочку redirectedFrom()
function buildRedirectChainForResponse(resp) {
const chain = [];
const currentReq = resp.request();
let prev = currentReq.redirectedFrom();
let toUrl = currentReq.url();
const status = resp.status();
while (prev) {
chain.push({ from: prev.url(), to: toUrl, status });
toUrl = prev.url();
prev = prev.redirectedFrom();
}
return chain.reverse();
}
async function scanDomainOnce(originDomain) {
const startUrl = `https://${originDomain}`;
const b = await getBrowser();
const context = await b.newContext();
const page = await context.newPage();
const seenDomains = new Set();
const redirectLog = [];
const visitedUrls = new Set(); // для детекции циклов
let redirectSteps = 0;
// Фиксируем все запросы
page.on('request', req => {
const d = extractDomain(req.url());
if (d) seenDomains.add(d);
});
// Фиксируем ответы и редиректные цепочки
page.on('response', resp => {
const url = resp.url();
const d = extractDomain(url);
if (d) seenDomains.add(d);
// Добавим элементы цепочки, если ответ был редиректом (3xx)
const status = resp.status();
if (status >= 300 && status < 400) {
const piece = buildRedirectChainForResponse(resp);
redirectLog.push(...piece);
}
});
try {
let currentUrl = startUrl;
// Анти-цикл: свой контроль над goto в несколько шагов — через ожидание события navigation и проверку URL
// Однако Playwright следует редиректам сам; для анти-цикла контролируем уникальность URL после перехода
const resp = await page.goto(currentUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
// После авто-редиректов Playwright мы проверим фактическую цепочку через обработчики и page.url()
// Защита от «вечных» редиректов: проверим историю URL в performance entries
// Простой и надёжный способ: считать шаги смены URL в waitForNavigation с url predicate — но нам достаточно лимита по постфакту.
// Проверим финальный URL и убедимся, что не было явного зацикливания по уже виденным URL.
const finalUrl = page.url();
if (visitedUrls.has(finalUrl)) {
throw new Error('Redirect loop detected');
}
visitedUrls.add(finalUrl);
// Как дополнительная защита — лимит по шагам 3xx из собранного redirectLog
// Если цепочка слишком длинная, считаем её небезопасной.
redirectSteps = redirectLog.length;
if (redirectSteps > MAX_REDIRECT_STEPS) {
throw new Error(`Too many redirects (${redirectSteps})`);
}
await context.close();
const relatedDomains = Array.from(seenDomains)
.filter(d => !d.includes('doubleclick') && !d.includes('google'))
.sort();
return {
finalUrl,
relatedDomains,
redirectChain: redirectLog,
};
} catch (e) {
try { await context.close(); } catch {}
throw e;
}
}
function getFromCache(domain) {
const row = stmtSelect.get(domain);
if (!row) return null;
const now = Math.floor(Date.now() / 1000);
if (row.ttl_at > now) {
return {
relatedDomains: JSON.parse(row.result_json),
finalUrl: row.final_url || null,
redirectChain: row.redirect_chain_json ? JSON.parse(row.redirect_chain_json) : [],
cached: true,
cachedAt: row.updated_at,
ttlAt: row.ttl_at,
};
}
return null;
}
function putToCache(domain, result) {
const now = Math.floor(Date.now() / 1000);
const ttlAt = now + CACHE_TTL_SECONDS;
stmtUpsert.run({
domain,
result_json: JSON.stringify(result.relatedDomains || []),
final_url: result.finalUrl || null,
redirect_chain_json: JSON.stringify(result.redirectChain || []),
updated_at: now,
ttl_at: ttlAt,
});
}
app.get('/domains', async (req, res) => {
const { domain } = req.query;
if (!domain) {
res.status(400).json({ error: '"domain" query parameter is required' });
return;
}
try {
const cached = getFromCache(domain);
if (cached) {
res.json({
domain,
finalUrl: cached.finalUrl,
relatedDomains: cached.relatedDomains,
redirectChain: cached.redirectChain,
cached: true,
cachedAt: cached.cachedAt,
ttlAt: cached.ttlAt,
});
return;
}
const result = await scanDomainOnce(domain);
putToCache(domain, result);
res.json({
domain,
finalUrl: result.finalUrl,
relatedDomains: result.relatedDomains,
redirectChain: result.redirectChain,
cached: false,
});
} catch (e) {
res.status(500).json({ error: e.message || 'Internal server error' });
}
});
app.get('/health', (_req, res) => res.json({ ok: true }));
process.on('SIGTERM', async () => {
try { if (browser) await browser.close(); } catch {}
process.exit(0);
});
process.on('SIGINT', async () => {
try { if (browser) await browser.close(); } catch {}
process.exit(0);
});
app.listen(port, () => {
console.log(`Domain scanner service listening on port ${port}`);
});