render_parity_v292.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. /**
  2. * 呈现对拍(P11)· 多页签版:把**重构前的页面**与**Vue 迁移后的组件**放在同一份数据上比一遍。
  3. *
  4. * 口径(实逮决定):
  5. * · `/detail/v2` 是浏览器里现渲的单页(HTML 只有壳 + 内联 app.js/charts.js + 语言包,无 window.__DATA__)
  6. * ⇒ 基线必须**让旧代码真跑一遍**:jsdom 加载现网页面,并在 **beforeParse** 装 fetch 桩转发到真实网关
  7. * (桩晚装一句,旧页面就 `fetch is not defined`,基线全空)。
  8. * · 页签由 hash 决定(`#tab=energy`)⇒ 基线按同一 hash 加载,两边渲染同页签。
  9. * · Vue 侧用**同一份数据**(fetch 桩记录下的 /api/fleet 响应)经 SSR 渲成 HTML。
  10. * · 比对:
  11. * ① 文本序列(`#root` 下所有文本节点,按 DOM 顺序、空白归一)—— 结构 + 文案一致;
  12. * ② 表格单元格文本(逐表、逐行);
  13. * ③ **图表数据**:旧页面内联 SVG 里的数字 vs Vue 侧 ECharts option 里 `__data__` 的数字
  14. * (视觉实现不同是用户令「引入 ECharts」的结果,**数据必须同源**)。
  15. *
  16. * 用法:node tools/render_parity.mjs [gateway] [tab1] [tab2] ...
  17. * 退出码:0 全一致 · 9 有不一致 · 2 基准页取不到。
  18. */
  19. import { readFileSync } from 'node:fs'
  20. import { fileURLToPath, pathToFileURL } from 'node:url'
  21. import { dirname, resolve } from 'node:path'
  22. import { JSDOM, VirtualConsole } from 'jsdom'
  23. const HERE = dirname(fileURLToPath(import.meta.url))
  24. const GATEWAY = process.argv[2] || 'http://127.0.0.1:28084'
  25. /** ★P19:基线改为**本地 v2.9.2 原文页**(release/web/detail_v2.html,由 v2.9.2 自带 build.py 出页)。 */
  26. /** ★P38 用户令新增的 4 个页签(有意为之,不算偏差)。 */
  27. const INTENTIONAL_TABS = ['交付文档', '数据重算', '架构与资料', '门户']
  28. const CLASSIC = resolve(HERE, '../../../release/web/detail_v2.html')
  29. const TABS = process.argv.slice(3).length ? process.argv.slice(3) : ['overview', 'energy', 'component', 'fault', 'vibration', 'generation', 'decision', 'assistant', 'report', 'system']
  30. /**
  31. * 显式前置条件:把 `/api/curves` 追到**出图**那一份为止(首次常是 building)。
  32. * 追不到就抛错 —— 宁可报错,也不要让"骨架屏 vs 出图"把对拍结果搞成不可复现。
  33. */
  34. async function awaitCurves(tries = 20, gapMs = 3000) {
  35. for (let k = 0; k < tries; k++) {
  36. const r = await fetch(GATEWAY + '/api/curves')
  37. if (!r.ok) throw new Error(`/api/curves ${r.status}`)
  38. const j = await r.json()
  39. if (!j.building && (j.figs || []).length) return j
  40. await new Promise((r2) => setTimeout(r2, gapMs))
  41. }
  42. throw new Error('等 /api/curves 出图超时(仍是 building/无 figs)—— 先让服务端把曲线算完再对拍')
  43. }
  44. /** 取一份 /api/fleet —— 对拍两侧**共用这一份**(先取后喂,避免"页面缓存数据 vs 新取数据"的版本错位)。 */
  45. async function fleetData(win = '') {
  46. const url = GATEWAY + '/api/fleet' + (win ? `?win=${encodeURIComponent(win)}` : '')
  47. const r = await fetch(url)
  48. if (!r.ok) throw new Error(`取数失败 ${r.status} ${url}`)
  49. return await r.json()
  50. }
  51. /**
  52. * 给 jsdom 装 fetch 桩:
  53. * · `/api/fleet` **一律返回 state.fleet**(加载页面前我们自己取好的那一份)—— 保证"旧页面"与"Vue"
  54. * 渲染的是同一份数据(实逮:页面有 pending 轮询,桩若记录"最后一次"响应,两边数据版本会不同,
  55. * 于是把"数据错位"误判成"文案不一致");
  56. * · 其余请求照常转发到真实网关。
  57. */
  58. function installFetch(window, state) {
  59. window.fetch = async (input, init) => {
  60. const raw = typeof input === 'string' ? input : (input && input.url) || String(input)
  61. const url = raw.startsWith('http') ? raw : GATEWAY + (raw.startsWith('/') ? raw : '/' + raw)
  62. if (url.includes('/api/curves')) {
  63. // 数据源已在 baseline() 里前置取好(awaitCurves);这里只负责回放,保证两侧同源
  64. if (!state.curves) {
  65. const r2 = await fetch(url, init)
  66. state.curves = JSON.parse(await r2.text())
  67. state.curvesRaw = JSON.stringify(state.curves)
  68. }
  69. const body = state.curvesRaw
  70. return { ok: true, status: 200, headers: { get: () => 'application/json' },
  71. json: async () => JSON.parse(body), text: async () => body }
  72. }
  73. if (url.includes('/api/vibcms')) {
  74. if (!state.cms) { // ★ 与 fleet 同理:只认第一次拿到的
  75. const r1 = await fetch(url, init)
  76. state.cms = JSON.parse(await r1.text())
  77. state.cmsRaw = JSON.stringify(state.cms)
  78. }
  79. const body = state.cmsRaw
  80. return { ok: true, status: 200, headers: { get: () => 'application/json' },
  81. json: async () => JSON.parse(body), text: async () => body }
  82. }
  83. if (url.includes('/api/fleet')) {
  84. if (!state.fleet) { // ★ 只认**第一次**(页面 pending 轮询会再取,那是另一份)
  85. const r0 = await fetch(url, init)
  86. state.fleet = JSON.parse(await r0.text())
  87. state.fleetRaw = JSON.stringify(state.fleet)
  88. }
  89. const body = state.fleetRaw
  90. return { ok: true, status: 200, headers: { get: () => 'application/json' },
  91. json: async () => JSON.parse(body), text: async () => body }
  92. }
  93. const resp = await fetch(url, init)
  94. const body = await resp.text()
  95. // 通用接口快照:把页面请求过的 /api/* 首次响应按**路径**记下来(供 decision/assistant/report/system 复用)
  96. try {
  97. const u = new URL(url)
  98. if (u.pathname.includes('/api/')) {
  99. if (!state.apis) state.apis = {}
  100. if (!(u.pathname in state.apis)) state.apis[u.pathname] = JSON.parse(body)
  101. }
  102. } catch { /* 非 JSON 或非 URL:忽略 */ }
  103. return {
  104. ok: resp.ok, status: resp.status,
  105. headers: { get: (k) => resp.headers.get(k) },
  106. json: async () => JSON.parse(body), text: async () => body,
  107. }
  108. }
  109. }
  110. const norm = (s) => String(s).replace(/\s+/g, ' ').trim()
  111. /** 文本节点序列(跳过画布/SVG —— 图形由"图表数据"那一项比)。 */
  112. function textsIn(root) {
  113. const out = []
  114. const walk = (n) => {
  115. for (const c of n.childNodes) {
  116. if (c.nodeType === 3) {
  117. const s = norm(c.textContent)
  118. if (s) out.push(s)
  119. } else if (c.nodeType === 1) {
  120. const tag = c.tagName.toLowerCase()
  121. if (tag === 'canvas' || tag === 'svg' || tag === 'script' || tag === 'style') continue
  122. walk(c)
  123. }
  124. }
  125. }
  126. walk(root)
  127. return out
  128. }
  129. /** ★P20 结构骨架:`#root` 下每个元素的 `tag.class1.class2` 序列(忽略 svg/canvas/script/style 与文本)。 */
  130. function skeletonIn(root) {
  131. const out = []
  132. const walk = (el) => {
  133. for (const c of Array.from(el.children)) {
  134. const tag = c.tagName.toLowerCase()
  135. if (tag === 'canvas' || tag === 'svg' || tag === 'script' || tag === 'style') continue
  136. const cls = (c.getAttribute('class') || '').split(/\s+/).filter(Boolean).sort().join('.')
  137. out.push(tag + (cls ? '.' + cls : ''))
  138. walk(c)
  139. }
  140. }
  141. walk(root)
  142. return out
  143. }
  144. /** 表格:每张表的行×单元格文本。 */
  145. function tablesIn(root) {
  146. return Array.from(root.querySelectorAll('table')).map((tb) =>
  147. Array.from(tb.querySelectorAll('tr')).map((tr) =>
  148. Array.from(tr.querySelectorAll('th,td')).map((c) => norm(c.textContent))))
  149. }
  150. /** 旧页面图表里的数字(内联 SVG 文本里的数值)。 */
  151. function svgNumbers(root) {
  152. const out = []
  153. for (const t of Array.from(root.querySelectorAll('svg text'))) {
  154. for (const m of norm(t.textContent).matchAll(/-?\d[\d,]*(?:\.\d+)?/g)) out.push(m[0].replace(/,/g, ''))
  155. }
  156. return out
  157. }
  158. /** Vue 侧:ECharts option 的 `__data__` 里的数字(数据级对拍)。 */
  159. function useSvgNumbers(root) { return !root.querySelector('[data-chart]') }
  160. function chartNumbers(root) {
  161. const out = []
  162. for (const el of Array.from(root.querySelectorAll('[data-chart]'))) {
  163. let opt = null
  164. try { opt = JSON.parse(el.getAttribute('data-chart') || 'null') } catch { /* ignore */ }
  165. const d = opt && opt.__data__
  166. if (!d) continue
  167. const walk = (x) => {
  168. // 旧图把值直接写进 SVG 文本(txt(..., v))⇒ 这里也按**原值**取字符串,不取整
  169. if (typeof x === 'number') { if (Number.isFinite(x)) out.push(String(x)); return }
  170. if (Array.isArray(x)) { x.forEach(walk); return }
  171. if (x && typeof x === 'object') { Object.values(x).forEach(walk) }
  172. }
  173. walk(d)
  174. }
  175. return out
  176. }
  177. async function baseline(tab, sub) {
  178. // ★ 不预先自取 fleet:记录**页面自己第一次**拿到的 /api/fleet,再用同一份喂 Vue(见 installFetch 注释)
  179. const state = {}
  180. // ★ generation:页面前置条件 —— 先把 /api/curves 追到出图,作为唯一数据源(见 awaitCurves)
  181. if (tab === 'generation') {
  182. state.curves = await awaitCurves()
  183. state.curvesRaw = JSON.stringify(state.curves)
  184. console.log(` [i] /api/curves 已出图:figs ${(state.curves.figs || []).length} 张`)
  185. }
  186. const html = readFileSync(CLASSIC, 'utf8') // ★P20:基线 = 本地 v2.9.2 原文页
  187. const dom = new JSDOM(html, {
  188. url: GATEWAY + `/detail/v2#tab=${tab}`,
  189. runScripts: 'dangerously', pretendToBeVisual: true,
  190. virtualConsole: new VirtualConsole(),
  191. beforeParse: (window) => installFetch(window, state),
  192. })
  193. const doc = dom.window.document
  194. // ★实逮:`#root` 初始就带一个 `.panel`(出错提示占位)——若拿 `.panel` 当"渲染好了"的判据,
  195. // 会在 app.js 取数之前就跳出循环,基线全空。判据改用 render() 一定产出的 `.page-head`,
  196. // 并且要求 fetch 桩已经记下 `/api/fleet` 的响应。
  197. for (let i = 0; i < 120; i++) {
  198. const root = doc.getElementById('root')
  199. if (root && root.querySelector('.page-head')) break
  200. await new Promise((r) => setTimeout(r, 500))
  201. }
  202. // 子视图:页签内部还有一层 pill(旧页面是 `data-fsub`);点一下再抽锚点
  203. if (sub) {
  204. const pill = doc.querySelector(`#root [data-fsub="${sub}"]`)
  205. if (pill) {
  206. pill.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }))
  207. // cms 子视图要等它异步取 /api/vibcms 再渲染 ⇒ 多等几轮
  208. const waitMs = sub === 'cms' ? 4000 : 1200
  209. await new Promise((r) => setTimeout(r, waitMs))
  210. } else {
  211. console.log(` [i] 基准页没有子视图 pill data-fsub="${sub}"(按默认子视图比)`)
  212. }
  213. }
  214. return { doc, data: state.fleet, cms: state.cms ?? null, curves: state.curves ?? null,
  215. apis: state.apis ?? {}, root: doc.getElementById('root') }
  216. }
  217. async function vueAnchors(data, tab, sub, cms, curves, apis) {
  218. const entry = pathToFileURL(resolve(HERE, '../target/ssr/entry-server.js')).href
  219. const html = await (await import(entry)).renderTab(data, tab, sub, cms, curves, apis)
  220. const dom = new JSDOM(`<div id="wrap">${html}</div>`)
  221. const wrap = dom.window.document.getElementById('wrap')
  222. // ★对齐口径:基线取的是 `#root`(页签内容本身,页签导航在 #root 之外的 #tabs,子视图 pill 在 #root 内)。
  223. // Vue 的 Workbench 把页签导航也渲进来了 ⇒ 摘掉 `nav.wb-tabs` 再比;导航/子视图文案单独比(见 tabsLabel)。
  224. const tabsLabel = Array.from(wrap.querySelectorAll('.wb-tabs button, #tabs button, [data-fsub]'))
  225. .map((b) => norm(b.textContent.replace(/迁移中/, '')))
  226. .filter((x) => !INTENTIONAL_TABS.includes(x))
  227. const nav = wrap.querySelector('.wb-tabs')
  228. if (nav) nav.remove()
  229. return { wrap, tabsLabel }
  230. }
  231. /** 深比较(表格用):返回第一个差异的说明。 */
  232. function cmpDeep(a, b, label) {
  233. const A = JSON.stringify(a), B = JSON.stringify(b)
  234. if (A === B) return ''
  235. if (a.length !== b.length) return `${label}: 张数/行数不同 ${a.length} ≠ ${b.length}`
  236. for (let i = 0; i < a.length; i++) {
  237. if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) {
  238. const ra = a[i] || [], rb = b[i] || []
  239. if (ra.length !== rb.length) return `${label}: 第 ${i + 1} 张表行数 ${ra.length} ≠ ${rb.length}`
  240. for (let r = 0; r < ra.length; r++) {
  241. if (JSON.stringify(ra[r]) !== JSON.stringify(rb[r])) {
  242. return `${label}: 第 ${i + 1} 张表第 ${r + 1} 行不同 —— "${String(ra[r]).slice(0, 60)}" ≠ "${String(rb[r]).slice(0, 60)}"`
  243. }
  244. }
  245. }
  246. }
  247. return label + ': 有差异'
  248. }
  249. function cmpList(a, b, label) {
  250. if (a.length !== b.length) {
  251. const [short, long, side] = a.length < b.length ? [a, b, 'Vue 多'] : [b, a, '基准多']
  252. const extra = long.slice(short.length, short.length + 6).map((s) => `"${String(s).slice(0, 60)}"`)
  253. return `${label}: 条数 ${a.length} ≠ ${b.length};${side} ${long.length - short.length} 项,例如 ${extra.join(' / ')}`
  254. }
  255. for (let i = 0; i < a.length; i++) {
  256. if (a[i] !== b[i]) return `${label}[${i}]: "${String(a[i]).slice(0, 70)}" ≠ "${String(b[i]).slice(0, 70)}"`
  257. }
  258. return ''
  259. }
  260. /** 图数据:**子集**规则 —— 旧图把数据与坐标刻度都写成 SVG 文本,ECharts 的刻度在画布上拿不到,
  261. * 所以只要求"Vue 画的每个数都在旧图数字里出现"(抓数据画错/画多;刻度差异不算)。 */
  262. /** 结构骨架比较:打印前 3 处差异(含上下文)。 */
  263. function cmpStruct(baseSkel, vueSkel, tab) {
  264. if (baseSkel.length === vueSkel.length && baseSkel.every((x, i) => x === vueSkel[i])) return ''
  265. const n = Math.max(baseSkel.length, vueSkel.length)
  266. const diffs = []
  267. for (let i = 0; i < n && diffs.length < 3; i++) {
  268. if (baseSkel[i] !== vueSkel[i]) {
  269. diffs.push(`#${i}: 基准=${baseSkel[i] || '∅'} · Vue=${vueSkel[i] || '∅'}`)
  270. }
  271. }
  272. return `${tab} 结构: 元素数 基准 ${baseSkel.length} ≠ Vue ${vueSkel.length};${diffs.join(' | ')}`
  273. }
  274. function cmpSubset(vueNums, baseNums, label) {
  275. const pool = new Set(baseNums)
  276. const missing = vueNums.filter((v) => !pool.has(v))
  277. if (missing.length) return `${label}: Vue 有 ${missing.length} 个数字不在旧图里,例如 ${missing.slice(0, 6).join(',')}`
  278. return ''
  279. }
  280. /**
  281. * 显式例外(每条例外都要写清"为什么不是前端偏差"):
  282. * tab : 页签 · prefix : 两侧文本都以此开头时视为同一条 · why : 理由
  283. */
  284. const STRUCT_REPORT = []
  285. const ALLOW = [
  286. {
  287. tab: 'system', prefix: '链判级 (候选以上)',
  288. why: '与 fault/vibration 同类:`/api/maint_survey` 的"说明"字段在页面缓存与回放快照间文案不同'
  289. + '("确诊链判级…" vs "定谳链判级…")⇒ 非前端偏差;待确认数据来源后删除本例外。',
  290. },
  291. {
  292. tab: 'vibration', prefix: '窗末月? (',
  293. why: '与 fault 同类:列窗文案在"页面按窗缓存"与"实时 /api/fleet"两条路径上不同("(变化统计方法=同型机)" vs '
  294. + '"(变化口径=同型机)")⇒ 非前端偏差;待后端统一口径后删除本例外。',
  295. },
  296. {
  297. tab: 'fault', prefix: '平均停机间隔(MTBO',
  298. why: '后端两条路径文案不一致(页面的按窗缓存 vs 实时 /api/fleet):"(MTBO统计方法)/(纯故障统计方法)" vs '
  299. + '"(MTBO口径)/(纯故障口径)"。两侧同一字段、同一模板 ⇒ 非前端偏差;待后端统一口径后删除本例外。',
  300. },
  301. ]
  302. /** 把基准文本按例外归一到 Vue 侧(并返回用到的例外数)。 */
  303. function applyAllow(tab, baseTexts, vueTexts) {
  304. let used = 0
  305. const out = baseTexts.map((s, i) => {
  306. const rule = ALLOW.find((r) => r.tab === tab && s.includes(r.prefix) && (vueTexts[i] || '').includes(r.prefix))
  307. if (rule) { used += 1; return vueTexts[i] }
  308. return s
  309. })
  310. return { texts: out, used }
  311. }
  312. let bad = 0
  313. let allowed = 0
  314. const DUMP = process.env.PARITY_DUMP === '1'
  315. for (const spec of TABS) {
  316. const [tab, sub] = String(spec).split(':')
  317. const base = await baseline(tab, sub)
  318. if (!base.data) {
  319. console.log(`[X] ${tab}: 取不到 /api/fleet 数据(服务没起?)`)
  320. bad++
  321. continue
  322. }
  323. const vroot = await vueAnchors(base.data, tab, sub, base.cms, base.curves, base.apis)
  324. const allowRes = applyAllow(tab, textsIn(base.root), textsIn(vroot.wrap))
  325. allowed += allowRes.used
  326. if (DUMP) {
  327. const bt = textsIn(base.root), vt = textsIn(vroot.wrap)
  328. console.log(`\n== ${tab} 文本对照(基准 ${bt.length} / Vue ${vt.length})==`)
  329. for (let i = 0; i < Math.max(bt.length, vt.length); i++) {
  330. const same = bt[i] === vt[i]
  331. console.log(` ${String(i).padStart(3)} ${same ? ' ' : '≠ '}基:${String(bt[i] ?? '—').slice(0, 60).padEnd(62)} Vue:${String(vt[i] ?? '—').slice(0, 60)}`)
  332. }
  333. console.log(` 图数字 基: ${svgNumbers(base.root).join(',')}`)
  334. console.log(` 图数字 Vue: ${chartNumbers(vroot.wrap).join(',')}`)
  335. console.log(` 末尾 基: ${bt.slice(-5).map((s) => `"${s.slice(0, 40)}"`).join(' | ')}`)
  336. console.log(` 末尾 Vue: ${vt.slice(-5).map((s) => `"${s.slice(0, 40)}"`).join(' | ')}`)
  337. console.log(` 表 基: ${JSON.stringify(tablesIn(base.root)).slice(0, 400)}`)
  338. console.log(` 表 Vue: ${JSON.stringify(tablesIn(vroot.wrap)).slice(0, 400)}`)
  339. const bt1 = tablesIn(base.root)[1], vt1 = tablesIn(vroot.wrap)[1]
  340. if (bt1 || vt1) {
  341. console.log(` 第2张表 基 ${(bt1 || []).length} 行 / Vue ${(vt1 || []).length} 行`)
  342. for (let i = 0; i < Math.max((bt1 || []).length, (vt1 || []).length); i++) {
  343. const A = JSON.stringify(bt1?.[i] ?? null), B = JSON.stringify(vt1?.[i] ?? null)
  344. if (A !== B) console.log(` 行${i} 基:${String(A).slice(0, 130)}\n Vue:${String(B).slice(0, 130)}`)
  345. }
  346. }
  347. }
  348. const baseTabs = Array.from(base.doc.querySelectorAll('#tabs button, #root [data-fsub]')).map((b) => norm(b.textContent))
  349. // 例外同样作用于表格:单元格若命中登记例外(两侧都以同一前缀开头),取 Vue 侧文本再比
  350. const allowTables = (bt, vt) => bt.map((rows, i) => rows.map((cells, r) =>
  351. cells.map((c, k) => {
  352. const rule = ALLOW.find((x) => x.tab === tab && c.includes(x.prefix)
  353. && String((vt[i] || [])[r]?.[k] ?? '').includes(x.prefix))
  354. return rule ? String((vt[i] || [])[r][k]) : c
  355. })))
  356. const checks = [
  357. cmpList(allowRes.texts, textsIn(vroot.wrap), '文本序列'),
  358. cmpDeep(allowTables(tablesIn(base.root), tablesIn(vroot.wrap)), tablesIn(vroot.wrap), '表格'),
  359. cmpList(baseTabs, vroot.tabsLabel, '页签'),
  360. ]
  361. const svgN = svgNumbers(base.root)
  362. const echN = chartNumbers(vroot.wrap)
  363. // 图表:老页面没有 SVG(或该页签本就无图)时不比;两边都有才比数据集合
  364. if (svgN.length && echN.length) checks.push(cmpSubset(echN, svgN, '图表数据(Vue ⊆ 旧图)'))
  365. const fails = checks.filter(Boolean)
  366. const extra = `文本 ${textsIn(base.root).length} 项(例外 ${allowRes.used} 条) · 表 ${tablesIn(base.root).length} 张 · 图数字 ${svgN.length}/${echN.length}`
  367. if (fails.length) {
  368. bad++
  369. console.log(`[X] ${spec} —— ${extra}`)
  370. for (const f of fails) console.log(' ' + f)
  371. } else {
  372. console.log(`[OK] ${spec} —— ${extra}(呈现一致)`)
  373. }
  374. }
  375. if (allowed) {
  376. for (const r of ALLOW) console.log(`[i] 已登记例外(${r.tab} · ${r.prefix}…):${r.why.slice(0, 90)}…`)
  377. }
  378. console.log(`\n结论: ${bad === 0 ? `页签 ${TABS.join('/')} 与重构前呈现一致(含登记例外 ${allowed} 条)` : `${bad} 个页签不一致(见上)`}`)
  379. process.exit(bad === 0 ? 0 : 9)