// map_symbols.js - 生成新旧客户端 bundle 之间的符号映射表 // 原理:两版源码除"混淆重命名"外高度一致。用 K 元 token 块做锚点对齐, // 对齐后区域若 token 数相同则按位置一一对应,据此对标识符改名投票。 // 用法: node map_symbols.js <旧unpacked目录> <新bundle路径> <模块id逗号分隔> [输出json] // 输出: JSON,结构 { 模块id: { 旧名: 新名 }, "_merged": { 旧名: 新名 } } const fs = require('fs'); const path = require('path'); const K = 10; // 锚点块长度(token 数) const MIN_ID_LEN = 3; // 参与投票的标识符最小长度(过滤局部单字符名) // ===== 词法切分:标识符 / 数字 / 字符串 / 正则 / 标点 ===== function Tokenize(src) { const out = []; let i = 0; const n = src.length; const IdStart = /[A-Za-z_$]/; const IdPart = /[A-Za-z0-9_$]/; while (i < n) { const c = src[i]; // 空白(换行/空格/制表)不参与比对 if (c === ' ' || c === '\n' || c === '\r' || c === '\t' || c === '\f' || c === '\v') { i++; continue; } // 注释 if (c === '/' && src[i + 1] === '/') { while (i < n && src[i] !== '\n') i++; continue; } if (c === '/' && src[i + 1] === '*') { i += 2; while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++; i += 2; continue; } // 字符串 if (c === '"' || c === "'" || c === '`') { const quote = c; let j = i + 1; while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === quote) { j++; break; } j++; } out.push({ k: 'str', s: src.slice(i, j) }); i = j; continue; } // 正则字面量(启发式:前一个有效字符不是标识符/数字/右括号) if (c === '/') { let p = out.length - 1; const prev = p >= 0 ? out[p] : null; const okPrev = !prev || (prev.k === 'punct' && !')]'.includes(prev.s)); if (okPrev) { let j = i + 1; let closed = false; while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === '\n') break; if (src[j] === '[') { while (j < n && src[j] !== ']') { if (src[j] === '\\') j++; j++; } } if (src[j] === '/') { j++; closed = true; break; } j++; } if (closed) { while (j < n && /[a-z]/.test(src[j])) j++; out.push({ k: 're', s: src.slice(i, j) }); i = j; continue; } } } if (IdStart.test(c)) { let j = i + 1; while (j < n && IdPart.test(src[j])) j++; out.push({ k: 'id', s: src.slice(i, j) }); i = j; continue; } if (/[0-9]/.test(c)) { let j = i + 1; while (j < n && /[0-9a-fA-FxX._]/.test(src[j])) j++; out.push({ k: 'num', s: src.slice(i, j) }); i = j; continue; } out.push({ k: 'punct', s: c }); i++; } return out; } // ===== 从新 bundle 提取模块源码 ===== function ExtractNewModule(bundle, id) { const re = /(?<=[{,])(\d{3,6}):(?:\(|function)/g; let m; const marks = []; while ((m = re.exec(bundle)) !== null) { marks.push({ id: m[1], start: m.index, keyEnd: m.index + m[1].length }); } for (let i = 0; i < marks.length; i++) { if (marks[i].id !== String(Number(id))) continue; const end = i + 1 < marks.length ? marks[i + 1].start : bundle.length; // 从 ':' 之后开始(跳过 "87072:") return bundle.slice(marks[i].keyEnd + 1, end); } return null; } // ===== 读旧模块源码(去掉文件头注释)===== function ReadOldModule(dir, id) { const f = path.join(dir, 'module_' + id + '.js'); if (!fs.existsSync(f)) return null; let src = fs.readFileSync(f, 'utf8'); src = src.replace(/^(?:\s*\/\/[^\n]*\n)+/, ''); return src; } // ===== 过滤"无信息"标点 ===== // 两版由不同代 minifier 产出:括号/逗号/分号的增删不影响语义,但会打断对齐,故丢弃。 // 保留:标识符/数字/字符串/正则,以及 {} . [] = + - * / % < > ! & | ? : ~ ^ const SIG_PUNCT = '{}.[]=+-*/%<>!&|?:~^'; function FilterSignificant(tokens) { const out = []; for (const t of tokens) { if (t.k === 'punct') { if (SIG_PUNCT.indexOf(t.s) < 0) continue; out.push(t); } else { out.push(t); } } return out; } // ===== 流式对齐 ===== // 两个 token 流同源,差异只有两类:①标识符改名 ②minifier 风格差异/少量源码改动。 // 策略:同步推进;标识符对标识符即记录一条改名投票;否则在有限窗口内找一个"4 token 完全一致块"重同步。 const LOOK = 40; // 重同步搜索窗口(token 数) const SYNC_LEN = 4; // 重同步锚块长度 function SameTok(a, b) { return a.k === b.k && a.s === b.s; } function SameBlock(oldT, i, newT, j, len) { if (i + len > oldT.length || j + len > newT.length) return false; for (let x = 0; x < len; x++) if (!SameTok(oldT[i + x], newT[j + x])) return false; return true; } function WalkAlign(oldT, newT, onIdPair, counter, onUnsync, look) { const LOOK2 = look || LOOK; let i = 0, j = 0; const n = oldT.length, m = newT.length; while (i < n && j < m) { const a = oldT[i], b = newT[j]; if (SameTok(a, b)) { i++; j++; counter.same++; continue; } if (a.k === 'id' && b.k === 'id') { onIdPair(a.s, b.s); i++; j++; counter.rename++; continue; } // 重同步:优先以字符串/数字常量定位,其次 4-token 块 let bestK = -1, bestL = -1; for (let k = 0; k <= LOOK2 && i + k < n; k++) { const ta = oldT[i + k]; if (ta.k !== 'str' && ta.k !== 'num') continue; for (let l = 0; l <= LOOK2 && j + l < m; l++) { const tb = newT[j + l]; if (SameTok(ta, tb)) { bestK = k; bestL = l; break; } } if (bestK >= 0) break; } if (bestK < 0) { for (let k = 0; k <= LOOK2 && i + k < n; k++) { for (let l = 0; l <= LOOK2 && j + l < m; l++) { if (SameBlock(oldT, i + k, newT, j + l, SYNC_LEN)) { bestK = k; bestL = l; break; } } if (bestK >= 0) break; } } if (bestK < 0) { if (onUnsync) onUnsync(i, j, oldT, newT); i++; j++; counter.unsync++; continue; } counter.sync += (bestK + bestL); i += bestK; j += bestL; } } // ===== 递归唯一 token 切分 ===== // 思路(patience diff):在两侧都"只出现一次"的 token 天然对应,可作锚点切分; // 切出的段内再递归找唯一 token,直到段足够小,再做局部对齐。 const REGION_MAX = 4000; // 单段最大 token 数,超过则继续切分 const REGION_BAD = 0.25; // 段内未同步比例上限,超过则丢弃该段的投票 const MAX_DEPTH = 12; function LowerBound(arr, v) { let lo = 0, hi = arr.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid] < v) lo = mid + 1; else hi = mid; } return lo; } function UniqueAnchors(oldT, newT) { const cntO = new Map(), posO = new Map(); for (let i = 0; i < oldT.length; i++) { const k = oldT[i].k + ':' + oldT[i].s; cntO.set(k, (cntO.get(k) || 0) + 1); posO.set(k, i); } const cntN = new Map(), posN = new Map(); for (let j = 0; j < newT.length; j++) { const k = newT[j].k + ':' + newT[j].s; cntN.set(k, (cntN.get(k) || 0) + 1); posN.set(k, j); } const pairs = []; for (const [k, c] of cntO) { if (c !== 1 || cntN.get(k) !== 1) continue; pairs.push([posO.get(k), posN.get(k)]); } pairs.sort((a, b) => a[0] - b[0]); return LisLongest(pairs); } // 取按第二坐标严格递增的最长子序列,保证锚点单调 function LisLongest(pairs) { const tails = []; const prev = new Array(pairs.length).fill(-1); for (let i = 0; i < pairs.length; i++) { const v = pairs[i][1]; let lo = 0, hi = tails.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (pairs[tails[mid]][1] < v) lo = mid + 1; else hi = mid; } prev[i] = lo > 0 ? tails[lo - 1] : -1; tails[lo] = i; } const out = []; let k = tails.length ? tails[tails.length - 1] : -1; while (k >= 0) { out.push(pairs[k]); k = prev[k]; } return out.reverse(); } // 段内局部对齐(带质量门限,低质段整段丢弃) function LocalWalk(oldT, os, oe, newT, ns, ne, sink, counter) { const oLen = oe - os, nLen = ne - ns; if (oLen <= 0 || nLen <= 0) return; if (oLen > REGION_MAX || nLen > REGION_MAX) { counter.skipBig++; return; } const pairs = []; const c = { same: 0, rename: 0, sync: 0, unsync: 0 }; WalkAlign(oldT.slice(os, oe), newT.slice(ns, ne), (a, b) => pairs.push([a, b]), c, null, 150); counter.same += c.same; counter.rename += c.rename; counter.sync += c.sync; counter.unsync += c.unsync; const bad = (c.unsync * 2) / (oLen + nLen || 1); if (bad > REGION_BAD) { counter.badRegion++; return; } for (const [a, b] of pairs) sink(a, b); } function RecursiveAlign(oldT, newT, sink, counter, depth) { const anchors = UniqueAnchors(oldT, newT); if (anchors.length === 0) { LocalWalk(oldT, 0, oldT.length, newT, 0, newT.length, sink, counter); return; } const parts = []; let oi = 0, nj = 0; for (const [i, j] of anchors) { parts.push([oi, i, nj, j]); oi = i + 1; nj = j + 1; } parts.push([oi, oldT.length, nj, newT.length]); for (const [os, oe, ns, ne] of parts) { const oLen = oe - os, nLen = ne - ns; if (oLen <= 0 || nLen <= 0) continue; if (oLen <= REGION_MAX && nLen <= REGION_MAX) { LocalWalk(oldT, os, oe, newT, ns, ne, sink, counter); } else if (depth < MAX_DEPTH) { RecursiveAlign(oldT.slice(os, oe), newT.slice(ns, ne), sink, counter, depth + 1); } else { counter.skipBig++; } } } function AlignByAnchors(oldT, newT, sink, counter) { RecursiveAlign(oldT, newT, sink, counter, 0); } // ===== 主流程 ===== function SumVotes(votes) { const merged = {}; const detail = {}; for (const [oldName, m] of votes) { let best = null, total = 0; for (const [k, v] of m) { total += v; if (!best || v > best.v) best = { k, v }; } merged[oldName] = best.k; detail[oldName] = { to: best.k, keep: best.k === oldName, votes: best.v, total, ratio: +(best.v / total).toFixed(3), }; } return { merged, detail }; } function BuildMap(oldDir, newBundlePath, ids) { const bundle = fs.readFileSync(newBundlePath, 'utf8'); const perModule = {}; const globalVotes = new Map(); const stats = []; for (const id of ids) { const oldSrc = ReadOldModule(oldDir, id); const newSrc = ExtractNewModule(bundle, id); if (!oldSrc || !newSrc) { stats.push({ id, error: 'missing source' }); continue; } const oldT = FilterSignificant(Tokenize(oldSrc)); const newT = FilterSignificant(Tokenize(newSrc)); const counter = { same: 0, rename: 0, sync: 0, unsync: 0, skipBig: 0, badRegion: 0, voted: 0 }; const votes = new Map(); AlignByAnchors(oldT, newT, (a, b) => { if (a.length < MIN_ID_LEN) return; counter.voted++; if (!votes.has(a)) votes.set(a, new Map()); const m = votes.get(a); m.set(b, (m.get(b) || 0) + 1); if (!globalVotes.has(a)) globalVotes.set(a, new Map()); const g = globalVotes.get(a); g.set(b, (g.get(b) || 0) + 1); }, counter); const per = {}; for (const [oldName, m] of votes) { let best = null, total = 0; for (const [k, v] of m) { total += v; if (!best || v > best.v) best = { k, v }; } if (best.v >= 2 && best.v / total >= 0.6) per[oldName] = best.k; } perModule[id] = per; stats.push({ id, oldTok: oldT.length, newTok: newT.length, same: counter.same, rename: counter.rename, sync: counter.sync, unsync: counter.unsync, skipBig: counter.skipBig, badRegion: counter.badRegion, voted: counter.voted, mapped: Object.keys(per).length, }); } const { merged, detail } = SumVotes(globalVotes); perModule['_merged'] = merged; perModule['_detail'] = detail; perModule['_stats'] = stats; // 跨模块冲突检测:同一旧名在不同模块映射到不同新名 const conflicts = []; const seen = new Map(); for (const id of ids) { const per = perModule[id]; if (!per) continue; for (const [oldName, newName] of Object.entries(per)) { if (!seen.has(oldName)) { seen.set(oldName, new Map()); } const m = seen.get(oldName); if (!m.has(newName)) m.set(newName, []); m.get(newName).push(id); } } for (const [oldName, m] of seen) { if (m.size > 1) { const items = []; for (const [newName, ids2] of m) items.push(newName + '(' + ids2.join('/') + ')'); conflicts.push(oldName + ' => ' + items.join(' | ')); } } perModule['_conflicts'] = conflicts; return perModule; } function main() { const oldDir = process.argv[2]; const newBundle = process.argv[3]; let ids = (process.argv[4] || '').split(',').filter(Boolean); const outPath = process.argv[5] || path.join(__dirname, 'symbol_map.json'); if (!oldDir || !newBundle || ids.length === 0) { console.error('用法: node map_symbols.js <旧unpacked目录> <新bundle> <模块id逗号分隔|all> [输出json]'); process.exit(1); } if (ids.length === 1 && ids[0] === 'all') { ids = fs.readdirSync(oldDir) .filter((f) => /^module_\d+\.js$/.test(f)) .map((f) => f.replace(/^module_|\.js$/g, '')); console.log('模块数: ' + ids.length); } const result = BuildMap(oldDir, newBundle, ids); fs.writeFileSync(outPath, JSON.stringify(result, null, 2), 'utf8'); console.log('=== 统计 ==='); for (const s of result._stats) { console.log(s.error ? ('[' + s.id + '] ' + s.error) : ('[' + s.id + '] 旧token=' + s.oldTok + ' 新token=' + s.newTok + ' 一致=' + s.same + ' 改名=' + s.rename + ' 重同步丢失=' + s.sync + ' 未同步=' + s.unsync + ' 跳过段=' + s.skipBig + ' 低质段=' + s.badRegion + ' 投票=' + s.voted + ' 映射=' + s.mapped)); } const names = Object.keys(result._merged); console.log('=== 映射条目: ' + names.length + ' ==='); console.log('输出: ' + outPath); } module.exports = { Tokenize, FilterSignificant, ExtractNewModule, ReadOldModule, WalkAlign, UniqueAnchors, RecursiveAlign, AlignByAnchors, BuildMap }; if (require.main === module) main();