feat: /m mirror — cache HTML + assets locally, rare upstream refresh (30min)

This commit is contained in:
aedes
2026-04-21 19:14:35 +03:00
parent 6186f31b66
commit 18e6630885

124
server.js
View File

@@ -224,6 +224,130 @@ const CHAT_OVERLAY = `
</script>
`;
// ══════════════════════════════════════════════════════════════
// MIRROR — one-time asset mirror + in-memory HTML cache
// Goal: cache everything locally, refresh HTML rarely, never
// hammer sechenov. Steady-state: ~1 upstream request / 30 min.
// ══════════════════════════════════════════════════════════════
const MIRROR_DIR = path.join(PUBLIC_DIR, 'assets', 'mirror');
const MIRROR_TTL_MS = 30 * 60 * 1000;
const MIRROR_MIN_GAP = 60 * 1000;
const UPSTREAM_DELAY = 500;
const BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
let cachedMirrorHtml = null;
let mirrorInProgress = false;
let lastMirrorAt = 0;
let mirrorSessionCookies = '';
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function safeFetch(url, extraHeaders = {}) {
const h = {
'User-Agent': BROWSER_UA,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'ru,en;q=0.9',
'Referer': UPSTREAM + '/',
...extraHeaders,
};
if (mirrorSessionCookies) h['Cookie'] = mirrorSessionCookies;
return fetch(url, { dispatcher: directAgent, headers: h });
}
function extractAssetUrls(html) {
const urls = new Set();
const patterns = [
/(?:href|src)="(\/[^"\s]+)"/g,
/url\(["']?(\/[^"'\s\)]+)/g,
];
for (const p of patterns) {
let m;
while ((m = p.exec(html)) !== null) {
const u = m[1];
if (u.startsWith('//')) continue;
if (u.startsWith('/api/') || u.startsWith('/assets/') || u.startsWith('/proxy/')) continue;
urls.add(u);
}
}
return [...urls];
}
async function mirrorAsset(urlPath) {
const pure = urlPath.split(/[?#]/)[0];
const local = path.join(MIRROR_DIR, pure);
try { await fs.promises.stat(local); return false; } catch {}
const r = await safeFetch(UPSTREAM + urlPath);
if (!r.ok) { console.warn(`[mirror] ${r.status} ${urlPath}`); return false; }
const ct = r.headers.get('content-type') || '';
let buf;
if (ct.includes('text/css')) {
let css = await r.text();
css = css.replace(/url\(\s*(["']?)\/(?!\/)/g, 'url($1/assets/mirror/');
buf = Buffer.from(css, 'utf8');
} else {
buf = Buffer.from(await r.arrayBuffer());
}
await fs.promises.mkdir(path.dirname(local), { recursive: true });
await fs.promises.writeFile(local, buf);
console.log(`[mirror] + ${pure} (${buf.length}b)`);
await sleep(UPSTREAM_DELAY);
return true;
}
async function refreshMirror() {
if (mirrorInProgress) return;
if (Date.now() - lastMirrorAt < MIRROR_MIN_GAP && cachedMirrorHtml) return;
mirrorInProgress = true;
lastMirrorAt = Date.now();
try {
console.log('[mirror] fetching HTML…');
const r = await safeFetch(UPSTREAM + '/auth.php');
if (!r.ok) throw new Error('upstream ' + r.status);
const setCookies = typeof r.headers.getSetCookie === 'function' ? r.headers.getSetCookie() : [];
if (setCookies.length) {
mirrorSessionCookies = setCookies.map(c => c.split(';')[0]).join('; ');
}
let html = await r.text();
const assetUrls = extractAssetUrls(html);
console.log(`[mirror] HTML ${html.length}b, ${assetUrls.length} assets referenced`);
let downloaded = 0;
for (const u of assetUrls) {
try { if (await mirrorAsset(u)) downloaded++; } catch (e) { console.warn('[mirror]', u, e.message); }
}
console.log(`[mirror] downloaded ${downloaded} new assets (cached: ${assetUrls.length - downloaded})`);
html = html
.replace(/(href|src)="\/(?!\/)/g, '$1="/assets/mirror/')
.replace(/url\(["']?\/(?!\/)/g, 'url(/assets/mirror/')
.replace(/https?:\/\/student\.sechenov\.ru/gi, '')
.replace(/<meta[^>]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, '')
.replace(/\btarget=["'](_top|_parent|_blank)["']/gi, '');
html = html.replace(/<\/body>/i, CHAT_OVERLAY + '</body>');
cachedMirrorHtml = html;
console.log('[mirror] ready');
} catch (e) {
console.error('[mirror fail]', e.message);
} finally {
mirrorInProgress = false;
}
}
setTimeout(() => refreshMirror(), 3000);
setInterval(() => refreshMirror(), MIRROR_TTL_MS);
app.get(['/m', '/m.html', '/mirror'], (req, res) => {
if (!cachedMirrorHtml) return res.status(503).send('mirror initializing — try again in 60s');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(cachedMirrorHtml);
});
app.use('/assets/mirror', express.static(MIRROR_DIR));
// Reverse-proxy disabled: Sechenov banned us by IP. Left here for reference.
app.use('/_disabled_reverse_proxy', async (req, res) => {
const upstreamUrl = UPSTREAM + req.url;