Add 301 logic and cache
Some checks are pending
Build and Push Docker Image / build-and-push (push) Waiting to run
Some checks are pending
Build and Push Docker Image / build-and-push (push) Waiting to run
This commit is contained in:
parent
09f054b888
commit
361fa4fafe
1 changed files with 200 additions and 46 deletions
246
server.js
246
server.js
|
|
@ -1,30 +1,183 @@
|
|||
// server.js
|
||||
const express = require('express');
|
||||
const { chromium } = require('playwright');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// Использовать системный Chromium, если задан путь (например, /usr/bin/chromium в Debian)
|
||||
const executablePath = process.env.CHROMIUM_PATH || undefined; // можно оставить undefined, если Chromium в PATH [1][2]
|
||||
|
||||
// Базовый набор флагов для контейнера без systemd/dbus и без install-deps
|
||||
const executablePath = process.env.CHROMIUM_PATH || undefined;
|
||||
const chromiumArgs = [
|
||||
'--no-sandbox', // запуск без setuid sandbox в контейнере [14]
|
||||
'--disable-setuid-sandbox', // отключение setuid sandbox [14]
|
||||
'--disable-dev-shm-usage', // использовать /tmp вместо /dev/shm (если нет --ipc=host) [15][16]
|
||||
'--disable-gpu', // headless окружение [14]
|
||||
'--no-zygote', // упрощение процессов в контейнере [14]
|
||||
'--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;
|
||||
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) => {
|
||||
|
|
@ -33,46 +186,47 @@ app.get('/domains', async (req, res) => {
|
|||
res.status(400).json({ error: '"domain" query parameter is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `https://${domain}`;
|
||||
const seenDomains = new Set();
|
||||
let browser;
|
||||
let context;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
executablePath, // берётся из CHROMIUM_PATH при наличии [1][2]
|
||||
headless: true, // явный headless режим для контейнера [14]
|
||||
args: chromiumArgs, // флаги для стабильности в Docker [15][14]
|
||||
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,
|
||||
});
|
||||
|
||||
context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on('request', request => {
|
||||
const d = extractDomain(request.url());
|
||||
if (d) seenDomains.add(d);
|
||||
});
|
||||
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 30000 });
|
||||
|
||||
// Фильтрация доменов после закрытия страницы
|
||||
await context.close();
|
||||
await browser.close();
|
||||
|
||||
const filteredDomains = Array.from(seenDomains)
|
||||
.filter(d => !d.includes('doubleclick') && !d.includes('google'))
|
||||
.sort();
|
||||
|
||||
res.json({ domains: filteredDomains });
|
||||
} catch (e) {
|
||||
// Безопасно закрыть ресурсы при ошибке
|
||||
try { if (context) await context.close(); } catch {}
|
||||
try { if (browser) await browser.close(); } catch {}
|
||||
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}`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue