From 18e66308851e71080ddafd0ce38ffed450499f65 Mon Sep 17 00:00:00 2001 From: aedes Date: Tue, 21 Apr 2026 19:14:35 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20/m=20mirror=20=E2=80=94=20cache=20HTML?= =?UTF-8?q?=20+=20assets=20locally,=20rare=20upstream=20refresh=20(30min)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/server.js b/server.js index d67a001..eebe3e9 100644 --- a/server.js +++ b/server.js @@ -224,6 +224,130 @@ const CHAT_OVERLAY = ` `; +// ══════════════════════════════════════════════════════════════ +// 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(/]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, '') + .replace(/\btarget=["'](_top|_parent|_blank)["']/gi, ''); + html = html.replace(/<\/body>/i, CHAT_OVERLAY + ''); + + 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;