// fetch_assets.js - 从 3dtank.com 镜像本地可运行客户端所需文件 // 用法: node fetch_assets.js [--index i.html] [--out web] [--concurrency 8] [--base https://3dtank.com] // 内容: // 1) index.html 里引用的 /play/**(字体、图标、manifest;main.js 跳过,用本地切割版) // 2) src/resources.json 里的 1218 个资源(static/**.svg|webp|json|mp3|webm|3ds...) const fs = require('fs'); const path = require('path'); const PROJECT = path.resolve(__dirname, '..'); const ROOT = path.resolve(PROJECT, '..'); 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 BASE = Arg('base', 'https://3dtank.com'); const INDEX_HTML = path.resolve(Arg('index', path.join(ROOT, 'i.html'))); const OUT_DIR = path.resolve(Arg('out', path.join(PROJECT, 'web'))); const CONCURRENCY = Number(Arg('concurrency', '8')); const RESOURCES = path.join(PROJECT, 'src', 'resources.json'); // 与 curlcmd 一致的请求头 const HEADERS = { accept: '*/*', 'accept-language': 'zh-CN,zh;q=0.9,en-GB;q=0.8,en;q=0.7,en-US;q=0.6', 'cache-control': 'no-cache', pragma: 'no-cache', origin: BASE, referer: BASE + '/', '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', }; // 收集待下载路径(不含域名,形如 /play/xxx) function CollectPaths() { const Paths = new Set(); const Html = fs.readFileSync(INDEX_HTML, 'utf8'); for (const M of Html.matchAll(/["'(](\/play\/[^"')]+)["')]/g)) Paths.add(M[1]); for (const M of Html.matchAll(/url\(['"]?(\/play\/[^'")]+)['"]?\)/g)) Paths.add(M[1]); const Res = JSON.parse(fs.readFileSync(RESOURCES, 'utf8')).resourceMap; for (const K of Object.keys(Res)) Paths.add('/play/' + Res[K]); // main.js 用本地切割版,不覆盖 const List = [...Paths].filter((P) => !/\/play\/static\/js\/main\.[0-9a-f]+\.js$/.test(P)); // 本地已存在的 hornet 文件也不要覆盖 return List.filter((P) => !/\/hornet_[^/]*\.js$/.test(P)); } async function DownloadOne(PathName) { const Rel = PathName.replace(/^\/+/, ''); const File = path.join(OUT_DIR, Rel); if (fs.existsSync(File) && fs.statSync(File).size > 0) return { PathName, skipped: true }; fs.mkdirSync(path.dirname(File), { recursive: true }); const Url = BASE + PathName; for (let Attempt = 1; Attempt <= 3; Attempt++) { try { const Resp = await fetch(Url, { headers: HEADERS, redirect: 'follow' }); if (!Resp.ok) throw new Error('HTTP ' + Resp.status); const Buf = Buffer.from(await Resp.arrayBuffer()); if (Buf.length === 0) throw new Error('empty body'); fs.writeFileSync(File, Buf); return { PathName, bytes: Buf.length }; } catch (Err) { if (Attempt === 3) return { PathName, error: String(Err.message || Err) }; await new Promise((R) => setTimeout(R, 300 * Attempt)); } } return { PathName, error: 'unreachable' }; } async function Pool(Items, Worker, Concurrency) { let Next = 0; const Results = []; const Runners = new Array(Math.min(Concurrency, Items.length)).fill(0).map(async () => { while (true) { const I = Next++; if (I >= Items.length) return; Results[I] = await Worker(Items[I]); } }); await Promise.all(Runners); return Results; } (async () => { const Paths = CollectPaths(); console.log('待下载: ' + Paths.length + ' 个(并发 ' + CONCURRENCY + ')'); console.log('来源: ' + BASE + ' 输出: ' + OUT_DIR); // 首页就地复制一份(i.html 即 index.html) fs.mkdirSync(OUT_DIR, { recursive: true }); fs.copyFileSync(INDEX_HTML, path.join(OUT_DIR, 'index.html')); const Started = Date.now(); let Done = 0; const Results = await Pool( Paths, async (P) => { const R = await DownloadOne(P); Done++; if (Done % 100 === 0 || R.error) { console.log(' 进度 ' + Done + '/' + Paths.length + (R.error ? (' [失败] ' + R.PathName + ' ' + R.error) : '')); } return R; }, CONCURRENCY ); const Failed = Results.filter((R) => R && R.error); const Skipped = Results.filter((R) => R && R.skipped).length; const Bytes = Results.reduce((S, R) => S + (R && R.bytes ? R.bytes : 0), 0); console.log('=== 完成 ==='); console.log(' 成功下载: ' + (Results.length - Failed.length - Skipped) + ' 跳过(已存在): ' + Skipped + ' 失败: ' + Failed.length); console.log(' 新增体积: ' + (Bytes / 1024 / 1024).toFixed(1) + ' MB 用时: ' + ((Date.now() - Started) / 1000).toFixed(0) + ' s'); if (Failed.length) { fs.writeFileSync(path.join(OUT_DIR, '..', 'names', 'fetch_failed.txt'), Failed.map((F) => F.PathName + ' ' + F.error).join('\n'), 'utf8'); console.log(' 失败清单: 3DTank/names/fetch_failed.txt'); } })();