// prefetch_assets.js - 预拉取车库/大厅所需的静态资源到本地镜像目录 // 用法: node prefetch_assets.js [--concurrency 8] [--only images] // // 背景: // 本地镜像只镜像了 JS,`/play/static/images/*` 全靠 serve.js 现场回源 3dtank.com 并落盘 // (见 serve.js 的「自愈」分支)。从战局返回车库时车库界面要一次性加载大量贴图/图标, // 首次全部走回源 → 明显卡顿。 // 本脚本把 webpack 资源表(unpacked/resources.json)里的所有静态资源**预先下载**到 // `3DTank/web/play/static/`,之后 serve.js 直接本地直出,返回车库秒开。 // // 产物位置与 serve.js 的 WEB_DIR 一致: // URL /play/static/images/xxx.webp → 3DTank/web/play/static/images/xxx.webp const fs = require('fs'); const path = require('path'); const PROJECT = path.resolve(__dirname, '..'); const ROOT = path.resolve(PROJECT, '..'); const WEB_DIR = path.join(PROJECT, 'web'); const RES_JSON = path.join(ROOT, '3dtank.com', 'play', 'static', 'js', 'unpacked', 'resources.json'); const BASE_URL = 'https://3dtank.com/play/'; const Argv = process.argv.slice(2); const Arg = (Name, Def) => { const I = Argv.indexOf('--' + Name); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def; }; const CONCURRENCY = Math.max(1, Number(Arg('concurrency', '8')) || 8); const HEADERS = { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0', 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', origin: 'https://3dtank.com', referer: 'https://3dtank.com/play/', }; function ReadResourceMap() { if (!fs.existsSync(RES_JSON)) { console.error('[prefetch] 找不到资源表: ' + RES_JSON); process.exit(1); } const Json = JSON.parse(fs.readFileSync(RES_JSON, 'utf8')); const Map = Json.resourceMap || {}; // 去重:多个模块可能引用同一个文件 return Array.from(new Set(Object.values(Map).filter((V) => typeof V === 'string' && V))); } function TargetFile(Rel) { // 安全:禁止跳出 WEB_DIR const Clean = Rel.replace(/^\/+/, ''); const Out = path.resolve(WEB_DIR, 'play', Clean); if (!Out.startsWith(path.resolve(WEB_DIR))) return null; return Out; } async function DownloadOne(Rel, Stats) { const Out = TargetFile(Rel); if (!Out) { Stats.skip++; return; } if (fs.existsSync(Out)) { Stats.cached++; return; } const Url = BASE_URL + Rel.replace(/^\/+/, ''); for (let Try = 0; Try < 3; Try++) { try { const Res = await fetch(Url, { headers: HEADERS, redirect: 'follow' }); if (!Res.ok) { if (Res.status === 404) { Stats.missing++; console.warn('[404] ' + Rel); return; } throw new Error('HTTP ' + Res.status); } const Buf = Buffer.from(await Res.arrayBuffer()); fs.mkdirSync(path.dirname(Out), { recursive: true }); fs.writeFileSync(Out, Buf); Stats.downloaded++; Stats.bytes += Buf.length; if (Stats.downloaded % 50 === 0) { console.log( '[prefetch] 已下载 ' + Stats.downloaded + ' / ' + Stats.total + '(' + (Stats.bytes / 1048576).toFixed(1) + ' MB)', ); } return; } catch (Err) { if (Try === 2) { Stats.failed++; console.warn('[fail] ' + Rel + ' : ' + Err.message); } else { await new Promise((R) => setTimeout(R, 400 * (Try + 1))); } } } } async function Main() { const List = ReadResourceMap(); const Stats = { total: List.length, downloaded: 0, cached: 0, missing: 0, failed: 0, skip: 0, bytes: 0 }; console.log('[prefetch] 资源表条目: ' + List.length + ',并发: ' + CONCURRENCY); console.log('[prefetch] 输出目录: ' + path.join(WEB_DIR, 'play')); let Index = 0; async function Worker() { while (Index < List.length) { const Rel = List[Index++]; await DownloadOne(Rel, Stats); } } await Promise.all(Array.from({ length: CONCURRENCY }, Worker)); console.log( '[prefetch] 完成:新下载 ' + Stats.downloaded + ',已存在 ' + Stats.cached + ',404 ' + Stats.missing + ',失败 ' + Stats.failed + ',合计 ' + (Stats.bytes / 1048576).toFixed(1) + ' MB', ); } Main();