项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

564 行
20 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env node
/**
* Map App 的 Service Worker 离线快照采集脚本。
* 用 Puppeteer 打开 dev server 上的 Map App,捕获所有发往 Google Maps
* 相关域名的响应,落盘到 mobilegym-data/map/cache/ 或 mobilegym-data/map/vector/,
* 并生成 manifest.json。vector profile 还会从完整 manifest 派生一个小的
* bootstrap-manifest.json,供 Service Worker 启动阶段快速加载。运行时由
* /cdn/map/... 接入这些文件。
*
* 用法:
* 1. 在另一个 terminal: npm run dev
* 2. node apps/Map/scripts/snapshot_map_assets.mjs
* - 默认 headless=false,会弹一个浏览器窗口
* - 默认会自动打开 Map App,进行一组预设的搜索/缩放交互
* - 你也可以在浏览器里手动继续操作,最后回到终端按 Ctrl+C 触发保存
* 3. SIGINT 时写入 manifest.json 并把 body 写到 files/
*
* 环境变量:
* DEV_URL dev server URL,默认 http://localhost:5173
* MAP_SNAPSHOT_PROFILE "vector" 写入 mobilegym-data/map/vector/;默认 "raster" 写入 mobilegym-data/map/cache/
* MAP_SNAPSHOT_OUT_DIR 覆盖输出目录
* HEADLESS "1" 走 headless 模式
* MANUAL "1" 跳过自动交互,仅捕获手动操作
* AUTO_EXIT_MS 非 0 时,自动交互完成后再等待 N ms,自动 flush + 退出(CI/无人值守)
* GRID_SCALE grid 采集缩放系数,默认 1(按内置 COVERAGE 配置)。>1 加密,<1 稀疏
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
import puppeteer from 'puppeteer';
import {
updateServiceWorkerCacheName,
writeVectorBootstrapManifest,
} from './bootstrap_manifest_utils.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const APP_DIR = resolve(__dirname, '..');
const ROOT = resolve(APP_DIR, '../..');
const SW_DIR = resolve(APP_DIR, 'sw');
const SNAPSHOT_PROFILE = process.env.MAP_SNAPSHOT_PROFILE === 'vector' ? 'vector' : 'raster';
const VECTOR_PROFILE = SNAPSHOT_PROFILE === 'vector';
const DATA_ROOT = resolve(ROOT, 'mobilegym-data');
const DEFAULT_SNAPSHOT_DIR = VECTOR_PROFILE
? resolve(DATA_ROOT, 'map/vector')
: resolve(DATA_ROOT, 'map/cache');
const SNAPSHOT_DIR = process.env.MAP_SNAPSHOT_OUT_DIR
? resolve(process.env.MAP_SNAPSHOT_OUT_DIR)
: DEFAULT_SNAPSHOT_DIR;
const FILES_DIR = resolve(SNAPSHOT_DIR, 'files');
const MANIFEST_PATH = resolve(SNAPSHOT_DIR, 'manifest.json');
const SW_PATH = resolve(SW_DIR, 'map-sw.js');
const BOOTSTRAP_MANIFEST_PATH = resolve(SW_DIR, 'vector/bootstrap-manifest.json');
const PLACES_PATH = resolve(APP_DIR, 'data/places.json');
const VECTOR_MANIFEST_SOURCE = 'mobilegym-data/map/vector/manifest.json';
const TARGET_HOSTS = new Set([
'maps.googleapis.com',
'maps.gstatic.com',
'www.gstatic.com',
'mts0.googleapis.com',
'mts1.googleapis.com',
'khms0.googleapis.com',
'khms1.googleapis.com',
'khms2.googleapis.com',
'khms3.googleapis.com',
'fonts.googleapis.com',
'fonts.gstatic.com',
'lh3.googleusercontent.com',
'streetviewpixels-pa.googleapis.com',
]);
const DEV_URL = process.env.DEV_URL || 'http://localhost:5173';
const HEADLESS = process.env.HEADLESS === '1';
const MANUAL_ONLY = process.env.MANUAL === '1';
const AUTO_EXIT_MS = Number.parseInt(process.env.AUTO_EXIT_MS || '0', 10);
const collected = new Map();
const stats = {
skippedRasterImages: 0,
staticMapImages: 0,
vectorIcons: 0,
vectorTiles: 0,
};
function ensureDirs() {
if (!existsSync(SNAPSHOT_DIR)) mkdirSync(SNAPSHOT_DIR, { recursive: true });
if (existsSync(FILES_DIR)) {
for (const name of readdirSync(FILES_DIR)) {
if (name === '.gitkeep') continue;
rmSync(resolve(FILES_DIR, name), { recursive: true, force: true });
}
} else {
mkdirSync(FILES_DIR, { recursive: true });
}
}
function loadSearchTerms() {
if (!existsSync(PLACES_PATH)) return [];
try {
const data = JSON.parse(readFileSync(PLACES_PATH, 'utf-8'));
const keys = Object.keys(data.autocomplete_index || data.search_index || {});
return keys
.filter((k) => k.length >= 2)
.sort()
.slice(0, 12);
} catch {
return [];
}
}
function guessExt(contentType) {
if (!contentType) return '.bin';
const ct = contentType.toLowerCase();
if (ct.startsWith('image/png')) return '.png';
if (ct.startsWith('image/jpeg')) return '.jpg';
if (ct.startsWith('image/webp')) return '.webp';
if (ct.startsWith('image/gif')) return '.gif';
if (ct.startsWith('image/svg')) return '.svg';
if (ct.includes('javascript')) return '.js';
if (ct.startsWith('application/json')) return '.json';
if (ct.startsWith('text/css')) return '.css';
if (ct.startsWith('font/woff2') || ct.includes('woff2')) return '.woff2';
if (ct.startsWith('font/woff') || ct.includes('woff')) return '.woff';
if (ct.startsWith('text/')) return '.txt';
return '.bin';
}
async function captureResponse(response) {
try {
const reqUrl = response.url();
const url = new URL(reqUrl);
if (!TARGET_HOSTS.has(url.host)) return;
if (collected.has(reqUrl)) return;
if (response.status() < 200 || response.status() >= 300) return;
const headers = response.headers();
const contentType = headers['content-type'] || 'application/octet-stream';
if (VECTOR_PROFILE && shouldSkipVectorProfileResponse(url, contentType)) {
stats.skippedRasterImages++;
return;
}
const buf = await readResponseBody(response, contentType);
if (!buf || buf.length === 0) return;
if (VECTOR_PROFILE && isVectorTileResponse(url, contentType)) stats.vectorTiles++;
if (VECTOR_PROFILE && isVectorIconResponse(url, contentType)) stats.vectorIcons++;
if (VECTOR_PROFILE && isStaticMapImageResponse(url, contentType)) stats.staticMapImages++;
collected.set(reqUrl, {
contentType,
cacheControl: headers['cache-control'] || '',
status: response.status(),
body: buf,
});
process.stdout.write(`\r[snapshot] 已捕获 ${collected.size} 个响应 `);
} catch {
/* skip */
}
}
async function readResponseBody(response, contentType) {
const ct = (contentType || '').toLowerCase();
if (!ct.includes('charset=x-user-defined')) {
return response.buffer().catch(() => null);
}
const text = await response.text().catch(() => null);
if (text === null) return null;
const bytes = Buffer.alloc(text.length);
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
bytes[i] = code >= 0xf780 && code <= 0xf7ff ? code - 0xf700 : code & 0xff;
}
return bytes;
}
function shouldSkipVectorProfileResponse(url, contentType) {
const ct = (contentType || '').toLowerCase();
if (url.pathname === '/maps/vt' || url.pathname.startsWith('/maps/vt/')) {
if (url.pathname.startsWith('/maps/vt/icon/')) return false;
return ct.startsWith('image/');
}
if (/^khms\d\.googleapis\.com$/.test(url.host) || /^mts\d\.googleapis\.com$/.test(url.host)) {
return ct.startsWith('image/');
}
return false;
}
function isVectorTileResponse(url, contentType) {
const ct = (contentType || '').toLowerCase();
return (
url.host === 'maps.googleapis.com'
&& url.pathname.startsWith('/maps/vt/pb=')
&& !ct.startsWith('image/')
);
}
function isVectorIconResponse(url, contentType) {
const ct = (contentType || '').toLowerCase();
return (
url.host === 'maps.googleapis.com'
&& url.pathname.startsWith('/maps/vt/icon/')
&& ct.startsWith('image/')
);
}
function isStaticMapImageResponse(url, contentType) {
const ct = (contentType || '').toLowerCase();
return (
url.host === 'maps.googleapis.com'
&& url.pathname.includes('StaticMapService.GetMapImage')
&& ct.startsWith('image/')
);
}
function readSimulatorLocation() {
const simCfgPath = resolve(ROOT, 'os/data/simulatorConfig.ts');
try {
const content = readFileSync(simCfgPath, 'utf-8');
const lat = content.match(/latitude:\s*([\d.-]+)/);
const lng = content.match(/longitude:\s*([\d.-]+)/);
if (lat && lng) return { lat: parseFloat(lat[1]), lng: parseFloat(lng[1]) };
} catch { /* fall through */ }
return { lat: 39.9794688, lng: 116.3323982 };
}
function offsetLatLng(lat, lng, dyMeters, dxMeters) {
const dLat = dyMeters / 111_000;
const dLng = dxMeters / (111_000 * Math.cos((lat * Math.PI) / 180));
return { lat: lat + dLat, lng: lng + dLng };
}
async function driveMapTo(page, center, zoom) {
await page.evaluate((c, z) => {
const m = window.__mapInstance;
if (!m) return false;
m.setCenter(c);
m.setZoom(z);
return true;
}, center, zoom);
// 等 tiles 加载完毕:监听 idle 事件,最多等 3 秒
await page.evaluate(() => new Promise((resolve) => {
const m = window.__mapInstance;
if (!m || !window.google?.maps?.event) { resolve(); return; }
let done = false;
const finish = () => { if (!done) { done = true; resolve(); } };
window.google.maps.event.addListenerOnce(m, 'idle', finish);
setTimeout(finish, 3000);
}));
// idle 事件只表示视口稳定,瓦片请求可能还在飞行中;多等 500ms 让网络完成
await wait(500);
if (VECTOR_PROFILE) {
const renderingType = await page.evaluate(() => {
const m = window.__mapInstance;
return m && typeof m.getRenderingType === 'function' ? m.getRenderingType() : '';
});
if (renderingType && renderingType !== 'VECTOR') {
throw new Error(`当前 Google Maps renderingType=${renderingType},不是 VECTOR`);
}
}
}
/**
* 在模拟器中心点周围按 grid 漫游 + 多 zoom 级别采集,确保 SW 缓存覆盖一定半径。
*
* 不同 zoom 单 tile 覆盖范围差异大(z10 ≈30km,z18 ≈250m),所以采用 per-zoom 配置:
* - 低 zoomz10-12)单 tile 已覆盖大区域,只需中心 1 点
* - 中 zoomz13-152×2 ~ 3×3 grid
* - 高 zoomz16-184×4 grid 覆盖核心活动半径
* 总共 ~70 个位置,每位置 setCenter+setZoom 后等 idle,~3 分钟。
*/
async function gridPanZoomCapture(page, simCenter, gridScale) {
const COVERAGE = [
{ zoom: 10, gridN: 1, radiusM: 0 },
{ zoom: 11, gridN: 1, radiusM: 0 },
{ zoom: 12, gridN: 1, radiusM: 0 },
{ zoom: 13, gridN: 2, radiusM: 2000 },
{ zoom: 14, gridN: 3, radiusM: 3000 },
{ zoom: 15, gridN: 4, radiusM: 3000 },
{ zoom: 16, gridN: 4, radiusM: 3000 },
{ zoom: 17, gridN: 4, radiusM: 2000 },
{ zoom: 18, gridN: 4, radiusM: 1000 },
];
const ready = await page.evaluate(() => Boolean(window.__mapInstance));
if (!ready) {
console.warn('[snapshot] window.__mapInstance 未暴露,跳过 grid 采集(确认 GoogleMap.tsx 暴露 mapInstance');
return;
}
for (const { zoom, gridN: configN, radiusM } of COVERAGE) {
const gridN = Math.max(1, Math.round(configN * gridScale));
const step = gridN > 1 ? (radiusM * 2) / (gridN - 1) : 0;
const total = gridN * gridN;
console.log(`\n[snapshot] grid 采集 zoom=${zoom}, 半径=${radiusM}m, ${gridN}×${gridN}=${total}${step ? `, step=${step.toFixed(0)}m` : ''}`);
let n = 0;
for (let i = 0; i < gridN; i++) {
for (let j = 0; j < gridN; j++) {
const dy = gridN > 1 ? (i - (gridN - 1) / 2) * step : 0;
const dx = gridN > 1 ? (j - (gridN - 1) / 2) * step : 0;
const pos = offsetLatLng(simCenter.lat, simCenter.lng, dy, dx);
await driveMapTo(page, pos, zoom);
n++;
if (n % 4 === 0 || n === total) {
process.stdout.write(`\r ${n}/${total} (累计 ${collected.size} 个响应) `);
}
}
}
console.log(`\r 完成 ${n}/${total} (累计 ${collected.size} 个响应) `);
}
}
async function drivePresetInteractions(page) {
console.log('\n[snapshot] 等待 OS 启动...');
await page.waitForFunction(() => typeof window.__OS__ !== 'undefined' && window.__OS__.openApp, {
timeout: 30_000,
});
console.log('[snapshot] 打开 Map App');
await page.evaluate(() => window.__OS__.openApp('map'));
await wait(3000);
await page.waitForFunction(
() => {
const win = window;
return win.google && win.google.maps && win.google.maps.Map;
},
{ timeout: 30_000 },
).catch(() => {
console.warn('[snapshot] 警告: Google Maps SDK 未在 30s 内就绪');
});
await wait(2500);
const terms = loadSearchTerms();
if (terms.length > 0) {
console.log(`[snapshot] 触发 ${terms.length} 个搜索词以填充 autocomplete/places 响应`);
for (const term of terms) {
await page.evaluate((q) => {
const input = document.querySelector('input[type="search"], input[placeholder]');
if (input) {
input.value = q;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}, term);
await wait(800);
}
}
const simCenter = readSimulatorLocation();
const gridScale = Number.parseFloat(process.env.GRID_SCALE || '1');
await gridPanZoomCapture(page, simCenter, gridScale);
console.log('\n[snapshot] 预设交互完成。你可以在浏览器里继续手动操作,完成后回到终端按 Ctrl+C 保存。');
}
function wait(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// Google API key 通用模式(AIzaSy + 33 字符)。写盘前从 URL 和响应体里抹掉,
// 避免把私有 key 落到 disk / 提交到 git。
const GOOGLE_API_KEY_PATTERN = /AIzaSy[A-Za-z0-9_-]{33}/g;
function stripKeyFromUrl(rawUrl) {
try {
const u = new URL(rawUrl);
u.searchParams.delete('key');
u.searchParams.delete('token');
if (u.pathname === '/maps/vt' && u.searchParams.has('pb')) {
u.searchParams.set('pb', normalizeTilePb(u.searchParams.get('pb') || ''));
} else if (u.pathname.startsWith('/maps/vt/pb=')) {
u.pathname = normalizeTilePb(u.pathname);
}
return u.toString();
} catch {
return rawUrl.replace(/([?&])key=[^&#]*(&?)/, (_, lead, trail) => (trail ? lead : ''));
}
}
function normalizeTilePb(value) {
return value
.replace(/(!2sm!3i)\d+/g, '$1E')
.replace(/(!28i)\d+/g, '$1E');
}
function looksTextual(contentType, buf) {
const ct = (contentType || '').toLowerCase();
if (ct.startsWith('image/') || ct.startsWith('font/') || ct.startsWith('audio/') || ct.startsWith('video/')) return false;
// 嗅探 NUL 字节 → 二进制
const sniffEnd = Math.min(buf.length, 512);
for (let i = 0; i < sniffEnd; i++) {
if (buf[i] === 0) return false;
}
return true;
}
function scrubBodyIfTextual(contentType, buf) {
if (!looksTextual(contentType, buf)) return { body: buf, replaced: 0 };
const text = buf.toString('utf-8');
let count = 0;
const cleaned = text.replace(GOOGLE_API_KEY_PATTERN, () => { count++; return ''; });
if (count === 0) return { body: buf, replaced: 0 };
return { body: Buffer.from(cleaned, 'utf-8'), replaced: count };
}
function makeSnapshotCacheName(entries) {
const seed = entries
.map((entry) => [
entry.url,
entry.file,
entry.status,
entry.size,
entry.bodyHash,
].join('\t'))
.join('\n');
const hash = createHash('sha1').update(seed).digest('hex').slice(0, 12);
return VECTOR_PROFILE ? `map-vector-cache-${hash}` : `map-cache-${hash}`;
}
function updateMapSwCacheName(cacheName) {
const constName = VECTOR_PROFILE ? 'VECTOR_CACHE_NAME' : 'RASTER_CACHE_NAME';
const prefix = VECTOR_PROFILE ? 'map-vector-cache-' : 'map-cache-';
updateServiceWorkerCacheName({
swPath: SW_PATH,
constName,
cacheName,
prefix,
logPrefix: '[snapshot]',
});
}
function writeBootstrapManifestForVectorProfile(entries, sourceCacheName) {
// The bootstrap manifest must be regenerated whenever the vector manifest is
// regenerated. Otherwise startup resources can drift while the SW only checks
// the small bootstrap index for non-tile requests.
if (!VECTOR_PROFILE) return null;
const result = writeVectorBootstrapManifest({
entries,
outputPath: BOOTSTRAP_MANIFEST_PATH,
source: VECTOR_MANIFEST_SOURCE,
sourceCacheName,
});
updateServiceWorkerCacheName({
swPath: SW_PATH,
constName: 'BOOTSTRAP_CACHE_NAME',
cacheName: result.cacheName,
prefix: 'map-bootstrap-cache-',
logPrefix: '[snapshot]',
});
return result;
}
async function flushToDisk() {
console.log(`\n[snapshot] 共 ${collected.size} 个响应,写入磁盘...`);
const entryByUrl = new Map();
let urlScrubs = 0;
let bodyScrubs = 0;
for (const [rawUrl, { contentType, cacheControl, status, body }] of collected) {
const cleanUrl = stripKeyFromUrl(rawUrl);
if (cleanUrl !== rawUrl) urlScrubs++;
const { body: cleanBody, replaced } = scrubBodyIfTextual(contentType, body);
bodyScrubs += replaced;
const bodyHash = createHash('sha1').update(cleanBody).digest('hex');
const ext = guessExt(contentType);
// hash 用清洗后的 url,保证落盘文件名也不依赖 key
const hash = createHash('sha1').update(cleanUrl).digest('hex').slice(0, 16);
const file = `${hash}${ext}`;
writeFileSync(resolve(FILES_DIR, file), cleanBody);
entryByUrl.set(cleanUrl, {
url: cleanUrl,
file,
contentType,
cacheControl: cacheControl || undefined,
status,
size: cleanBody.length,
bodyHash,
});
}
const entries = [...entryByUrl.values()];
const sortedEntries = entries.sort((a, b) => a.url.localeCompare(b.url));
const cacheName = makeSnapshotCacheName(sortedEntries);
const manifest = {
version: 1,
generatedAt: new Date().toISOString(),
cacheName,
entries: sortedEntries,
};
writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
updateMapSwCacheName(cacheName);
const bootstrapManifest = writeBootstrapManifestForVectorProfile(sortedEntries, cacheName);
const totalKB = (entries.reduce((sum, e) => sum + (e.size || 0), 0) / 1024).toFixed(1);
console.log(`[snapshot] manifest 写入 ${MANIFEST_PATH}`);
if (bootstrapManifest) {
console.log(`[snapshot] bootstrap manifest 写入 ${BOOTSTRAP_MANIFEST_PATH} (${bootstrapManifest.entries.length} 条启动资源)`);
}
console.log(`[snapshot] ${entries.length} 条响应, 总计 ${totalKB} KB`);
if (VECTOR_PROFILE) {
console.log(`[snapshot] 矢量统计: 捕获 ${stats.vectorTiles} 个 vector tile 响应,${stats.vectorIcons} 个 icon 响应,${stats.staticMapImages} 个 static map image 响应,跳过 ${stats.skippedRasterImages} 个 raster tile/image 响应`);
}
console.log(`[snapshot] 安全清洗: ${urlScrubs} 个 URL 去 key,${bodyScrubs} 处响应体抹掉 API key`);
}
async function main() {
ensureDirs();
console.log(`[snapshot] Puppeteer 启动 (${HEADLESS ? 'headless' : 'headful'})`);
console.log(`[snapshot] profile=${SNAPSHOT_PROFILE}, 输出目录 ${SNAPSHOT_DIR}`);
console.log(`[snapshot] 打开 ${DEV_URL}`);
const browser = await puppeteer.launch({
headless: HEADLESS,
defaultViewport: HEADLESS ? { width: 480, height: 900 } : null,
args: [
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-angle=metal',
...(HEADLESS ? ['--no-sandbox', '--disable-setuid-sandbox'] : ['--window-size=520,940']),
],
});
const [page] = await browser.pages();
await page.setBypassServiceWorker(true);
page.on('response', captureResponse);
page.on('pageerror', (err) => console.error('[snapshot][pageerror]', err.message));
await page.goto(DEV_URL, { waitUntil: 'domcontentloaded' });
if (!MANUAL_ONLY) {
try {
await drivePresetInteractions(page);
} catch (e) {
console.warn('[snapshot] 自动交互失败,回退到手动模式:', e.message);
}
} else {
console.log('[snapshot] MANUAL 模式:自行操作浏览器,完成后按 Ctrl+C');
}
await new Promise((resolveExit) => {
let saving = false;
const finish = async (reason) => {
if (saving) return;
saving = true;
console.log(`\n[snapshot] 触发 flush (${reason})`);
try {
await flushToDisk();
} catch (e) {
console.error('[snapshot] 写盘失败:', e);
}
await browser.close().catch(() => {});
resolveExit();
};
process.once('SIGINT', () => finish('SIGINT'));
if (AUTO_EXIT_MS > 0) {
console.log(`[snapshot] AUTO_EXIT_MS=${AUTO_EXIT_MS},等待该时长后自动 flush`);
setTimeout(() => finish('AUTO_EXIT_MS'), AUTO_EXIT_MS);
}
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});