#!/usr/bin/env node // 给设计小样截图、录屏和做基本检查。只依赖 Node 22+ 和本机的 Chrome / Chromium / Edge。 // 用法见 references/tools.md;`node shoot.mjs --help` 打印同样的说明。 import { spawn, spawnSync } from "node:child_process"; import { createServer } from "node:http"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; const HELP = `常用命令: node scripts/shoot.mjs page.html --size desktop --out shots node scripts/shoot.mjs page.html --size desktop --steps "click .nav button; wait 300" --out shots node scripts/shoot.mjs page.html --size desktop --states idle,done --evidence --steps "send: click text=Send" --out evidence 用法:node shoot.mjs <页面地址或文件> [选项] --out <目录> 输出目录,默认 ./shots --size <尺寸,...> 默认 390x844;desktop=1440x900,phone/mobile=390x844,可混用数字尺寸 --states 依次用 ?state=<名字> 打开并各截一张 --param <名字> 状态参数名,默认 state --query <查询参数> 给地址加参数,例如 --query "a=1&b=2";可和 --states 一起用 --zoom <倍数,...> 默认 1;例如 --zoom 1,2,每个倍数各出一套;配 --evidence 时覆盖默认截图倍数 --full 截整页,默认只截视口 --mask 另截一份遮掉全部文字的版本 --sheet 把状态拼成并排图,配合 --mask 再拼遮字版 --mark "1=<选择器>;..." 给匹配的元素画框和编号,另出标注版;不写编号时按顺序编号 --steps "<动作>" 可写多次,每组从重新打开的页面开始;可起名:"open: click .open" --record 每组录屏并留最后一个变化动作的三帧;无名组沿用 record.mp4、motion-start/mid/end.jpg --entry 配合 --record,先开录再打开页面,录首次出场 --hold <毫秒> 动作结束后再录多久,默认 1200 --motion 探测首次进入、动作反馈、首屏滚动和从头滚到底的动效;单屏跳过滚动 --evidence 一次取齐状态截图、并排图和遮字并排图、首状态 2 倍图、动效探测、 首次出场录屏和每组动作录屏;显式 --zoom 按倍数截每个状态;一项失败仍继续 --compare <参考图> 截图与 png/jpg/webp 参考图出四格对比和九宫格差异报告 --wait <毫秒> 页面加载后等待时间,默认 400;截图另等有限动画,最多 2 秒 --timeout-scale <倍数> 整体放宽协议、启动和编码超时,例如 --timeout-scale 2;默认 1 --dry-run 只检查选项和动作写法,输出解析 JSON,不启动浏览器 动作写法(用分号分隔;引号、方括号、圆括号里的分号不拆): click <选择器> | hover <选择器> | dblclick <选择器> | waitfor <选择器> drag <选择器> ,例如 drag .tiles .paper 240 0 type <选择器> <文字>,例如 type [aria-label="Message box"] "hello world" type 在当前光标处插入文字;fill 先清空原内容,再填入文字并触发 input 和 change。 fill <选择器> <文字>,例如 fill #message "hello world" type 和 fill 的选择器是第一个词,后代选择器可包引号:fill ".form input" hello select <选择器> <值或可见文字>,例如 select #country "China" key <按键或组合键>,例如 key ControlOrMeta+A;macOS 用 Meta,其他用 Control scroll | wait <时间>,例如 wait450、wait 450ms、wait 0.5s waitfor 最多等 5 秒,等元素出现并可见。 别名:doubleclick=dblclick,press=key,sleep=wait。 选择器写法(动作和 --mark 通用): CSS:click .nav button:nth-child(3),click [aria-label="Send money"],无需外层引号 文字:click text=More 或 click text="Send money",先找相等文字,再找包含文字 包含文字:click button:has-text("Undo"),单双引号都可;省略 CSS 时查全部元素 选择器只在主文档里找,不进入 iframe 或 Shadow DOM;找不到时报告 iframe 和开放 shadow root 数量。 选项都可写成 --名字=值 或 --名字 值;开关不带值。重复状态、尺寸和倍数会去重;文件名冲突会自动加编号。 元素匹配多个时取第一个可见的;匹配到但隐藏时说明原因,并给可见元素的写法。 点击、双击和悬停前检查中心点遮挡和禁用状态;提示写进输出和 report.json 的问题汇总,动作照常执行,返回码不变。 截图单次最多等 90 秒,超时后重开浏览器重试这一项一次;仍失败则继续后面的项,返回码 1。 其他 CDP 请求默认等 30 秒;超时或浏览器意外断开时退出,可用 --timeout-scale 2 放宽。 多项命令每完成一项向标准错误打印进度,标准输出仍是最后的汇总。 每张图检查控制台错误、横向溢出和加载失败的图片,明细写进 report.json; 默认做法和可读性提示写进 lint 字段。录屏需要 ffmpeg,没有时只留帧。 和 --states 一起用: 普通截图会在每个状态执行每组 --steps;动效探测和动作录屏只从第一个状态、第一种尺寸开始, --evidence 每组各录一次,不会给每个状态各录一遍。 --evidence 默认只有第一个状态出 2 倍图;每个状态都要时写 --zoom 1,2 或 --zoom 2, 文件会多出将近一倍,只在各状态都要放大查细节或交付高分辨率图时这样做。 输出文件(写进 --out): <状态>.png,遮字版 <状态>-masked.png,标注版 <状态>-marked.png,并排图 sheet.png 和 sheet-masked.png, 还原比对 <状态>-compare.png(四格依次是参考、当前、叠加和差异热图)。多个倍数各出一套,2 倍的文件名带 @2x。 @2x 是同一个视口用 2 倍像素密度截的图,页面布局不变,不是把页面放大到 200% 后的重排。 录屏:单个无名组是 record.mp4 和 motion-start/mid/end.jpg;具名或多组是 record-<组名>.mp4 和 motion-<组名>-start/mid/end.jpg;首次出场是 record-entry.mp4 和 motion-entry-start/mid/end.jpg。 report.json 的最外层是数组,每个元素是一张图或一项检查的结果;lint、motion、issues 在各个元素里。结构示意: [{"file":"idle.png","lint":[],"issues":[]}, {"file":"motion-probe","motion":{},"issues":[]}] 录屏怎样取样: 动作录屏的开始帧取最后一个 click、dblclick、drag、type、fill、select 或 key 派发前, 中间帧取这个动作反馈的中段,结束帧取 hold 结束时;等待、悬停和滚动不改变取样动作。 出场三帧从第一次出现内容取到稳定;没有动作的普通录屏取整段的头、中、尾。 各帧的文件名和时间在 report.json 的 frames.start/mid/end 里,录屏时长在 durationMs 里。 动效本身不足 3 秒时,录屏短于 3 秒是正常的。 录屏的最后一帧和结束时新截的图差得明显时,汇总和 report.json 的 warnings 里会有警告, 并用结束截图补上录像的最后一帧;这时静止终态以 motion-<组名>-end.jpg 为准,过渡过程不能据此判通过。 其他: --compare 先用 --size 设成参考图的逻辑尺寸,例如 2880 宽的 2 倍图用 1440 宽; 差异占比只用来找先修哪一块、看修改有没有让差异变少,不是还原度的分数。 --motion 只说明动了没有、动了多少:首次进入没有动画、动作没有反馈、从头滚到底没有变化会记为问题; 页面不能滚动时跳过滚动检查;Canvas 和 WebGL 里的动画可能探测不到,以录屏为准。 本地文件会用只监听本机的临时服务器打开;地址里带 ? 或 & 时加引号,或改用 --query。 WebGL 页面在没有显卡的机器上改用软件渲染;画布没能创建绘图上下文时会记为问题,这时截图是空的。`; function fail(message) { console.error(`shoot:${message}`); process.exit(1); } function distance(a, b) { let row = Array.from({ length: b.length + 1 }, (_, i) => i); for (let i = 0; i < a.length; i++) { const next = [i + 1]; for (let j = 0; j < b.length; j++) next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + (a[i] !== b[j]))); row = next; } return row[b.length]; } const closest = (word, choices) => [...choices].sort((a, b) => distance(word, a) - distance(word, b))[0]; const ACTIONS = ["click", "hover", "dblclick", "drag", "type", "fill", "select", "waitfor", "key", "scroll", "wait"]; const ALIASES = { doubleclick: "dblclick", press: "key", sleep: "wait" }; const unquote = (s) => /^(["'])[\s\S]*\1$/.test(s) ? s.slice(1, -1).replace(/\\(["'\\])/g, "$1") : s; // Split only outside quoted strings and selector brackets. Preserve the source for diagnostics. function splitOutside(text, words = false) { const parts = []; let start = 0, quote = null, outerQuote = null, stack = []; for (let i = 0; i < text.length; i++) { const c = text[i]; if (c === "\\") { i++; continue; } // Outer quotes around a selector may contain attribute quotes, even unescaped ones. if (outerQuote) { if (!quote && !stack.length && c === outerQuote) { outerQuote = null; continue; } if (quote) { if (c === quote) quote = null; continue; } if (c === "[" || c === "(") { stack.push(c); continue; } if (c === "]" || c === ")") { stack.pop(); continue; } if (stack.length && (c === '"' || c === "'")) quote = c; continue; } if (quote) { if (c === quote) quote = null; continue; } const quoteStarts = (c === '"' || c === "'") && (i === 0 || /[\s=(]/.test(text[i - 1])); const prefix = text.slice(start, i).trim(); const selectorQuote = words ? !parts.length && !prefix : /^(?:click|hover|dblclick|doubleclick|drag|type|fill|select|waitfor)$/.test(prefix) || /^(?:\d+\s*=\s*)?$/.test(prefix); if (quoteStarts) { if (selectorQuote) outerQuote = c; else quote = c; continue; } if (c === "[" || c === "(") stack.push(c); else if (c === "]" || c === ")") { if (stack.pop() !== (c === "]" ? "[" : "(")) throw new Error(`括号没有配对,收到 ${text};例如 click [aria-label="Send money"]`); } else if (!stack.length && (words ? /\s/.test(c) : c === ";")) { if (text.slice(start, i).trim()) parts.push(text.slice(start, i).trim()); start = i + 1; } } if (quote || outerQuote || stack.length) throw new Error(`引号或括号没有配对,收到 ${text};例如 click [aria-label="Send money"]`); if (text.slice(start).trim()) parts.push(text.slice(start).trim()); return parts; } function parseSelector(source) { const value = unquote(source.trim()); if (!value) throw new Error(`缺少选择器,收到 ${source};例如 click .open`); if (value.startsWith("text=")) return { kind: "text", value: unquote(value.slice(5).trim()) }; const m = value.match(/^(.*):has-text\(\s*(["'])([\s\S]*)\2\s*\)$/); if (m) return { kind: "has-text", css: m[1].trim() || "*", value: unquote(m[2] + m[3] + m[2]) }; return { kind: "css", value }; } function parseSteps(text) { let name = null; const named = text.match(/^([A-Za-z0-9_-]+):\s+([\s\S]*)$/); if (named && ![...ACTIONS, ...Object.keys(ALIASES)].includes(named[1])) { name = named[1]; text = named[2]; } const steps = splitOutside(text).map((raw, i) => { const m = raw.match(/^(\S+)(?:\s+([\s\S]*))?$/); let action = ALIASES[m[1]] || m[1], tail = m[2] || ""; if (/^(wait|sleep)\d/.test(action)) { tail = action.replace(/^(wait|sleep)/, ""); action = "wait"; } const bad = (example) => { throw new Error(`第 ${i + 1} 步动作参数不对,收到 ${raw};例如 ${example}`); }; if (!ACTIONS.includes(action)) throw new Error(`第 ${i + 1} 步不认识的动作,收到 ${raw};是不是想写 ${closest(action, [...ACTIONS, ...Object.keys(ALIASES)])}?全部动作:${ACTIONS.join("、")};别名:${Object.keys(ALIASES).join("、")}`); const step = { action, raw, params: {} }; if (["click", "hover", "dblclick", "waitfor"].includes(action)) { if (!tail) bad(`${action} .open`); step.selector = parseSelector(tail); } else if (action === "drag") { const match = tail.match(/^([\s\S]+?)\s+([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s+([+-]?(?:\d+(?:\.\d*)?|\.\d+))$/); if (!match) bad("drag .tiles .paper 240 0"); step.selector = parseSelector(match[1]); step.params = { dx: +match[2], dy: +match[3] }; } else if (["type", "fill", "select"].includes(action)) { const tokens = splitOutside(tail, true); if (tokens.length < 2) bad(`${action} #field "hello world"`); step.selector = parseSelector(tokens[0]); step.params = action === "select" ? { value: unquote(tail.slice(tokens[0].length).trim()) } : { text: unquote(tail.slice(tokens[0].length).trim()) }; } else if (action === "wait") { const match = tail.match(/^(\d+(?:\.\d+)?)(ms|s)?$/); if (!match) bad("wait 450ms"); step.params = { ms: +match[1] * (match[2] === "s" ? 1000 : 1) }; } else if (action === "scroll") { if (!tail || !Number.isFinite(Number(tail))) bad("scroll -400"); step.params = { dy: +tail }; } else { const keys = unquote(tail).split("+"); const mods = keys.slice(0, -1); if (!tail || /\s/.test(tail) || mods.some((k) => !["Alt", "Control", "Meta", "Shift", "ControlOrMeta"].includes(k)) || !keys.at(-1)) bad("key ControlOrMeta+A"); step.params = { key: keys.at(-1), modifiers: mods }; } return step; }); return { name, steps }; } const args = process.argv.slice(2); if (!args.length || args.includes("--help") || args.includes("-h")) { console.log(HELP); process.exit(args.length ? 0 : 1); } const opt = { out: "shots", size: "390x844", param: "state", zoom: "1", hold: "1200", wait: "400" }; const options = [...HELP.matchAll(/^ (--\S+)/gm)].map((m) => m[1]); const booleanOptions = new Set(["full", "mask", "sheet", "record", "motion", "entry", "evidence", "dry-run", "force"]); const flags = new Set(), stepsInputs = [], positional = [], suppliedOptions = new Set(); let lastFlag = null; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith("--")) { const equal = a.indexOf("="), option = equal < 0 ? a : a.slice(0, equal), key = option.slice(2); if (!options.includes(option) && key !== "force") { const glued = options.find((o) => a.startsWith(o) && /^\d/.test(a.slice(o.length))); const suggestion = glued ? `${glued} ${a.slice(glued.length)}` : closest(option, options); const examples = { out: "shots", size: "desktop", states: "idle,done", param: "state", query: '"a=1&b=2"', zoom: "1,2", mark: '"1=.open"', steps: '"click .open"', hold: "1000", compare: "ref.png", wait: "400" }; const example = glued ? suggestion : `${suggestion}${examples[suggestion.slice(2)] ? " " + examples[suggestion.slice(2)] : ""}`; fail(`不认识的选项 ${a};是不是想写 ${example}?\n可用选项:${options.join(" ")}`); } if (booleanOptions.has(key)) { if (equal >= 0) fail(`${option} 不带参数,收到 ${a};例如 ${option}`); flags.add(key); lastFlag = option; } else { lastFlag = null; if (equal < 0 && (i + 1 >= args.length || args[i + 1].startsWith("--"))) fail(`${option} 需要一个值`); const value = equal < 0 ? args[++i] : a.slice(equal + 1); if (!value) fail(`${option} 需要一个值,收到 ${a};例如 ${option} 值`); suppliedOptions.add(key); if (key === "steps") stepsInputs.push(value); else opt[key] = value; } } else { if (lastFlag === "--motion" && /^(?:[.#\[]|text=)|:has-text\(/.test(a)) fail(`--motion 不带参数,收到 --motion 和 ${a};要探测的动作写进 --steps,例如 --motion --steps "click ${a}"`); if (positional.length) fail(`页面地址或多余参数冲突,收到 ${positional[0]} 和 ${a}${lastFlag ? `;${lastFlag} 不带参数` : ""};例如 page.html --steps "click .open"`); // A flag may precede the one valid target; selector-like extras are never targets. if (lastFlag && /^(?:[.#\[]|text=)/.test(a) && !existsSync(a)) fail(`${lastFlag} 不带参数,收到 ${lastFlag} 和 ${a};例如 page.html ${lastFlag}`); positional.push(a); lastFlag = null; } } const target = positional[0]; if (!target) fail("缺少页面地址或文件。"); const notices = []; function deduplicate(values, key, label) { const seen = new Set(), removed = []; const unique = values.filter((value) => { const id = key(value); if (seen.has(id)) { removed.push(id); return false; } seen.add(id); return true; }); if (removed.length) notices.push(`${label} 已去重:${[...new Set(removed)].join("、")}`); return unique; } let groups, sizes, zooms, marks; try { groups = stepsInputs.map(parseSteps); const names = groups.map((g, i) => g.name || (groups.length > 1 ? String(i + 1) : "")); if (new Set(names).size !== names.length) throw new Error(`动作组名字重复,收到 ${names.join("、")};例如 --steps "open: click .open" --steps "send: click .send"`); groups.forEach((g, i) => g.id = names[i]); sizes = opt.size.split(",").map((s) => { const value = ({ desktop: "1440x900", phone: "390x844", mobile: "390x844" })[s.trim()] || s.trim(); const m = value.match(/^(\d+)x(\d+)$/); if (!m || +m[1] < 1 || +m[2] < 1) throw new Error(`尺寸不对,收到 ${s};例如 --size desktop,390x844`); return { w: +m[1], h: +m[2] }; }); zooms = opt.zoom.split(",").map((s) => { const n = Number(s); if (!Number.isFinite(n) || n <= 0) throw new Error(`倍数不对,收到 ${s};例如 --zoom 1,2`); return n; }); sizes = deduplicate(sizes, (s) => `${s.w}x${s.h}`, "尺寸"); zooms = deduplicate(zooms, String, "倍数"); for (const key of ["hold", "wait"]) if (!/^\d+(?:\.\d+)?$/.test(opt[key])) throw new Error(`--${key} 需要毫秒数,收到 ${opt[key]};例如 --${key} 1000`); if (opt["timeout-scale"] !== undefined && (!Number.isFinite(Number(opt["timeout-scale"])) || Number(opt["timeout-scale"]) < 1)) throw new Error(`超时倍数不对,收到 ${opt["timeout-scale"]};例如 --timeout-scale 2`); marks = (opt.mark ? splitOutside(opt.mark) : []).map((s, i) => { const m = s.match(/^(\d+)\s*=\s*(.+)$/); return { label: m ? m[1] : String(i + 1), selector: parseSelector(m ? m[2] : s) }; }); } catch (error) { fail(error.message); } const timeoutScale = Number(opt["timeout-scale"] || 1); const timeoutMs = (ms) => Math.min(2147483647, ms * timeoutScale); const quoteArg = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'"; const retryCommand = `node ${quoteArg(resolve(process.argv[1]))} ${quoteArg(target)} --timeout-scale 2`; let currentRetryCommand = retryCommand; const slowHint = () => `同时开了很多个任务时会变慢;可先单独重跑:${currentRetryCommand}`; const states = deduplicate(opt.states ? opt.states.split(",").map((s) => s.trim()).filter(Boolean) : [null], String, "状态"); if (!states.length) fail(`--states 没有状态,收到 ${opt.states};例如 --states idle,done`); const stateIds = states.map((s, i) => !s ? "page" : /^[A-Za-z0-9_-]{1,80}$/.test(s) ? s : `state-${i + 1}-${createHash("sha256").update(s).digest("hex").slice(0, 12)}`); const refImage = opt.compare ? resolve(opt.compare) : null; if (refImage && !existsSync(refImage)) fail(`找不到参考图:${opt.compare}`); if (refImage && !/\.(png|jpe?g|webp)$/i.test(refImage)) fail("--compare 只接受 png、jpg 或 webp"); if (refImage && (flags.has("record") || flags.has("evidence"))) fail("--compare 用在截图上,不和 --record 或 --evidence 一起用;例如 --compare ref.png"); if (flags.has("dry-run")) { console.log(JSON.stringify({ target, options: opt, flags: [...flags], sizes, zooms, states, groups, marks, notices }, null, 2)); process.exit(0); } if (typeof WebSocket !== "function") fail("需要 Node 22 或更新的版本。"); const out = resolve(opt.out); mkdirSync(out, { recursive: true }); const temporaryFrames = new Set(), encoders = new Set(); function removeFrames() { for (const dir of temporaryFrames) rmSync(dir, { recursive: true, force: true }); temporaryFrames.clear(); } // ---------- 本地文件用一个只监听本机的静态服务器打开,模块脚本和 fetch 才能正常工作 ---------- const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif", ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", ".otf": "font/otf", ".mp4": "video/mp4", ".webm": "video/webm", ".txt": "text/plain; charset=utf-8", ".wasm": "application/wasm", ".glb": "model/gltf-binary", ".gltf": "model/gltf+json", ".bin": "application/octet-stream", ".geojson": "application/geo+json", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".pdf": "application/pdf", ".csv": "text/csv; charset=utf-8", ".ico": "image/x-icon", ".mov": "video/quicktime", ".m4a": "audio/mp4", ".hdr": "application/octet-stream", ".exr": "image/x-exr", ".ktx2": "image/ktx2", }; const privatePath = (path) => path.split(/[\\/]/).some((part) => part.startsWith(".") || /^(?:credentials?|secrets?|id_(?:rsa|dsa|ecdsa|ed25519))(?:[._-]|$)/i.test(part)); let server = null; async function resolveTarget(t) { if (/^https?:\/\//.test(t)) return t; const file = resolve(t); if (!existsSync(file)) fail(`找不到文件:${t}`); const root = realpathSync(statSync(file).isDirectory() ? file : dirname(file)); const page = statSync(file).isDirectory() ? "index.html" : basename(file); server = createServer((req, res) => { try { const origin = `http://127.0.0.1:${server.address().port}`; const url = new URL(req.url, origin); if (req.headers.host !== new URL(origin).host || url.origin !== origin || !["GET", "HEAD"].includes(req.method)) { res.writeHead(404).end(); return; } const path = decodeURIComponent(url.pathname); if (privatePath(path)) { res.writeHead(404).end(); return; } const local = realpathSync(resolve(join(root, path))); const fromRoot = relative(root, local); const type = MIME[extname(local).toLowerCase()]; if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot) || privatePath(fromRoot) || !type || !statSync(local).isFile()) { res.writeHead(404).end(); return; } res.writeHead(200, { "Content-Type": type, "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store" }); res.end(req.method === "HEAD" ? undefined : readFileSync(local)); } catch { res.writeHead(404).end(); } }); await new Promise((ok) => server.listen(0, "127.0.0.1", ok)); return `http://127.0.0.1:${server.address().port}/${encodeURIComponent(page)}`; } // ---------- 启动一个独立的临时浏览器,不碰用户自己的浏览器数据 ---------- function findChrome() { const env = process.env.CHROME_PATH; if (env && existsSync(env)) return env; const candidates = { darwin: [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", ], win32: [ `${process.env["PROGRAMFILES"]}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env["LOCALAPPDATA"]}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env["PROGRAMFILES(X86)"]}\\Microsoft\\Edge\\Application\\msedge.exe`, `${process.env["PROGRAMFILES"]}\\Microsoft\\Edge\\Application\\msedge.exe`, ], }[process.platform]; for (const c of candidates || []) if (c && existsSync(c)) return c; for (const name of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"]) { const r = spawnSync("which", [name], { encoding: "utf8" }); if (r.status === 0 && r.stdout.trim()) return r.stdout.trim(); } fail("没找到 Chrome、Chromium 或 Edge;安装其一,或用环境变量 CHROME_PATH 指定路径。"); } const chromePath = findChrome(); let profile, chrome, cleaning, ws, handedOff; // 启动的那个进程不一定是真正的浏览器:有的版本把浏览器交给另一个进程后自己退出。 // 所以关闭时先让浏览器自己退出,再按本次临时目录的名字找还活着的进程。 function closeBrowser() { return new Promise((ok) => { const socket = ws, timer = setTimeout(() => ok(false), 3000); socket.onerror = null; socket.onclose = () => { clearTimeout(timer); ok(true); }; try { socket.send(JSON.stringify({ id: 2147483647, method: "Browser.close" })); } catch { clearTimeout(timer); ok(false); } }); } function profileProcesses() { const name = basename(profile); if (process.platform === "win32") { const script = `Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -and $_.CommandLine.Contains('${name}') } | ForEach-Object { $_.ProcessId }`; const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", timeout: 20000, windowsHide: true }); return (r.stdout || "").split(/\s+/).map(Number).filter((pid) => pid && pid !== process.pid); } const r = spawnSync("ps", ["-axww", "-o", "pid=,command="], { encoding: "utf8", timeout: 10000 }); return (r.stdout || "").split("\n").filter((line) => line.includes(name)).map((line) => Number(line.trim().split(/\s+/)[0])).filter((pid) => pid && pid !== process.pid); } function killProfileProcesses() { // 查两遍:第一遍之后才冒出来的子进程也要关掉。 for (let pass = 0; pass < 2; pass++) { const pids = profileProcesses(); if (!pids.length) return; for (const pid of pids) { try { process.kill(pid, "SIGKILL"); } catch {} } } } function removeProfile(closed) { if (!profile) return; const remove = (maxRetries) => rmSync(profile, { recursive: true, force: true, maxRetries, retryDelay: 100 }); try { // 浏览器不是启动的那个进程时,连接断开不等于它已经退出,等不到它的退出事件,直接按目录名收尾。 if (!closed || handedOff) killProfileProcesses(); try { remove(5); } catch { killProfileProcesses(); remove(10); } } catch (error) { console.error(`shoot:临时目录没删掉(${error.code || error.message}),可以稍后手动删除:${profile}`); } profile = undefined; } async function stopChrome() { const closed = ws?.readyState === WebSocket.OPEN ? await closeBrowser() : false; if (chrome && chrome.exitCode === null && chrome.signalCode === null) await new Promise((ok) => { const timer = setTimeout(() => { chrome.kill("SIGKILL"); ok(); }, 3000); chrome.once("close", () => { clearTimeout(timer); ok(); }); chrome.kill(); }); removeProfile(closed); } function cleanup() { return cleaning ||= (async () => { for (const child of encoders) child.kill("SIGKILL"); await stopChrome(); server?.close(); removeFrames(); })(); } process.on("exit", () => { try { for (const child of encoders) child.kill("SIGKILL"); removeFrames(); } catch {} try { chrome?.kill(); } catch {} try { server?.close(); } catch {} try { removeProfile(false); } catch {} }); process.on("SIGINT", async () => { await cleanup(); process.exit(130); }); process.on("SIGTERM", async () => { await cleanup(); process.exit(143); }); async function launchChrome() { profile = mkdtempSync(join(tmpdir(), "oil-shoot-")); handedOff = false; chrome = spawn(chromePath, ["--headless=new", "--enable-unsafe-swiftshader", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", "--mute-audio", "--disable-extensions", "about:blank"], { stdio: ["ignore", "ignore", "pipe"] }); const portFile = join(profile, "DevToolsActivePort"); const fromPortFile = () => { try { const [port, path] = readFileSync(portFile, "utf8").split("\n").map((line) => line.trim()); return /^\d+$/.test(port) && path?.startsWith("/devtools/browser/") ? `ws://127.0.0.1:${port}${path}` : null; } catch { return null; } }; return new Promise((ok, no) => { let buf = "", exited; const timer = setTimeout(() => finish(new Error("timeout")), timeoutMs(45000)); const poll = setInterval(() => { const url = fromPortFile(); if (url) finish(null, url); }, 100); const onData = (d) => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); if (m) finish(null, m[1]); }; const onError = (e) => finish(e); // 浏览器可能已经交给另一个进程,端口文件稍后才出现,所以不马上判失败。 const onExit = () => { handedOff = true; exited = setTimeout(() => finish(new Error("浏览器提前退出")), timeoutMs(10000)); }; function finish(error, url) { clearTimeout(timer); clearTimeout(exited); clearInterval(poll); chrome.stderr.off("data", onData); chrome.off("error", onError); chrome.off("exit", onExit); error ? no(error) : ok(url); } chrome.stderr.on("data", onData); chrome.once("error", onError); chrome.once("exit", onExit); }); } let wsUrl; for (let attempt = 0; attempt < 2; attempt++) { try { wsUrl = await launchChrome(); break; } catch (error) { await stopChrome(); if (attempt || error.message !== "timeout") fail(`浏览器启动失败:${error.message === "timeout" ? `${45 * timeoutScale} 秒内没有启动,重试一次仍失败` : error.message};${slowHint()}`); console.error(`shoot:浏览器 ${45 * timeoutScale} 秒内没有启动,正在重试一次;${slowHint()}`); } } // ---------- Chrome DevTools 协议 ---------- let currentContext = "连接浏览器", transportFailure = null, finishing = false; async function during(context, work) { const previous = currentContext; currentContext = context; try { return await work(); } finally { currentContext = previous; } } let sessionId, browserNeedsRestart = false; let seq = 0; const pending = new Map(), listeners = []; let saveFatalReport = () => {}; function abortTransport(error) { if (transportFailure || finishing) return; transportFailure = error; process.exitCode = 1; for (const request of pending.values()) { clearTimeout(request.timer); request.no(error); } pending.clear(); console.error(`shoot:${error.message}`); try { saveFatalReport(error); } catch {} void cleanup().finally(() => process.exit(1)); } async function connectTransport(url) { ws = new WebSocket(url); ws.onclose = () => abortTransport(new Error(`${currentContext}:浏览器连接意外断开,已停止执行;${slowHint()}`)); ws.onerror = () => abortTransport(new Error(`${currentContext}:浏览器连接出错,已停止执行;${slowHint()}`)); await new Promise((ok, no) => { const timer = setTimeout(() => { const error = new Error(`连接浏览器:${30 * timeoutScale} 秒内没有响应;${slowHint()}`); no(error); abortTransport(error); }, timeoutMs(30000)); ws.onopen = () => { clearTimeout(timer); ok(); }; const failed = () => { clearTimeout(timer); no(transportFailure || new Error("连接浏览器失败")); }; ws.addEventListener("close", failed, { once: true }); ws.addEventListener("error", failed, { once: true }); }); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.id && pending.has(msg.id)) { const { ok, no, timer, context } = pending.get(msg.id); pending.delete(msg.id); clearTimeout(timer); msg.error ? no(new Error(`${context}:${msg.error.message}`)) : ok(msg.result); } else if (msg.method) listeners.forEach((fn) => fn(msg)); }; } const send = (method, params = {}, targetSession) => new Promise((ok, no) => { if (transportFailure || ws.readyState !== WebSocket.OPEN) { no(transportFailure || new Error(`${currentContext}:浏览器连接已经断开。`)); return; } const id = ++seq, context = currentContext; // Rasterization competes with other tasks for CPU/GPU time; allow three ordinary request budgets. const limit = timeoutMs(method === "Page.captureScreenshot" ? 90000 : 30000); const timer = setTimeout(() => { const error = new Error(`${context}:CDP ${method} ${limit / 1000} 秒内没有响应;${slowHint()}`); if (method === "Page.captureScreenshot") { pending.delete(id); browserNeedsRestart = true; error.code = "SCREENSHOT_TIMEOUT"; no(error); } else abortTransport(error); }, limit); pending.set(id, { ok, no, timer, context }); try { ws.send(JSON.stringify({ id, method, params, ...(targetSession ? { sessionId: targetSession } : {}) })); } catch { abortTransport(new Error(`${context}:发送 CDP ${method} 时浏览器连接断开,已停止执行;${slowHint()}`)); } }); try { await connectTransport(wsUrl); } catch { await cleanup(); process.exit(1); } async function setupSend(...args) { try { return await send(...args); } catch (error) { if (transportFailure) { await cleanup(); process.exit(1); } throw error; } } const cdp = (method, params) => setupSend(method, params, sessionId); async function setupSession() { const { targetId } = await setupSend("Target.createTarget", { url: "about:blank" }); ({ sessionId } = await setupSend("Target.attachToTarget", { targetId, flatten: true })); await cdp("Page.enable"); await cdp("Runtime.enable"); await cdp("Log.enable"); // 记下创建失败或丢失的 WebGL 上下文:截图照样成功,画布却是空的。 // 先试 webgl2、失败后退回 webgl 的页面不算失败,只看最后有没有拿到。 await cdp("Page.addScriptToEvaluateOnNewDocument", { source: `(() => { const gl = window.__oilWebgl = { failed: [], ok: [], lost: 0 }; const get = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function (type, ...rest) { const ctx = get.call(this, type, ...rest); if (/^(webgl2?|experimental-webgl)$/.test(type)) { if (!ctx) gl.failed.push(this); else if (!gl.ok.includes(this)) { gl.ok.push(this); this.addEventListener("webglcontextlost", () => gl.lost++); } } return ctx; }; })()` }); } await setupSession(); async function restartBrowser() { ws.onclose = ws.onerror = ws.onmessage = null; ws.close(); for (const request of pending.values()) { clearTimeout(request.timer); request.no(new Error("截图超时,正在重开浏览器")); } pending.clear(); await stopChrome(); try { await connectTransport(await launchChrome()); await setupSession(); browserNeedsRestart = false; } catch (error) { abortTransport(new Error(`浏览器重开失败,收到 ${error.message};${slowHint()}`)); throw error; } } let problems = []; function isAutoFavicon(url) { try { return new URL(url || "").pathname === "/favicon.ico"; } catch { return false; } } listeners.push((m) => { if (m.sessionId !== sessionId) return; if (m.method === "Runtime.exceptionThrown") problems.push(`脚本错误:${m.params.exceptionDetails?.exception?.description?.split("\n")[0] || m.params.exceptionDetails?.text}`); if (m.method === "Runtime.consoleAPICalled" && m.params.type === "error") problems.push(`控制台错误:${m.params.args.map((a) => a.value ?? a.description ?? "").join(" ").slice(0, 200)}`); // Browsers ask for /favicon.ico on their own; a mockup without one has no page problem. if (m.method === "Log.entryAdded" && m.params.entry.level === "error" && !isAutoFavicon(m.params.entry.url)) problems.push(`加载错误:${m.params.entry.text.slice(0, 200)} ${m.params.entry.url || ""}`.trim()); }); const evaluate = async (expression) => { const r = await cdp("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); return r.result.value; }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function setViewport(w, h, scale) { await cdp("Emulation.setDeviceMetricsOverride", { width: w, height: h, deviceScaleFactor: scale, mobile: w < 600 }); await cdp("Emulation.setTouchEmulationEnabled", { enabled: w < 600 }); } async function open(url, wait = Number(opt.wait)) { return during(`${currentContext} / 打开页面 ${url}`, async () => { let loadedListener, timer; const loaded = new Promise((ok) => { loadedListener = (m) => { if (m.sessionId === sessionId && m.method === "Page.loadEventFired") ok(); }; listeners.push(loadedListener); timer = setTimeout(ok, timeoutMs(15000)); }); try { let nav = await cdp("Page.navigate", { url }); // 被取消不等于打不开:浏览器自己的导航偶尔会打断这一次(Edge 的新配置上见过),等一下再试一次。 if (nav.errorText === "net::ERR_ABORTED") { await sleep(300); nav = await cdp("Page.navigate", { url }); } if (nav.errorText) throw new Error(`打不开 ${url}:${nav.errorText}`); await loaded; await evaluate(`document.fonts ? document.fonts.ready.then(() => true) : true`); await sleep(wait); } finally { clearTimeout(timer); const index = listeners.indexOf(loadedListener); if (index >= 0) listeners.splice(index, 1); } }); } async function check() { const found = await evaluate(`(() => { const out = []; const doc = document.documentElement; if (doc.scrollWidth > innerWidth + 1) out.push("横向溢出:页面宽 " + doc.scrollWidth + "px,视口 " + innerWidth + "px"); for (const img of document.images) if (img.complete && img.naturalWidth === 0) out.push("图片没加载出来:" + (img.getAttribute("src") || "").slice(0, 120)); const gl = window.__oilWebgl; if (gl) { const blank = new Set(gl.failed.filter((c) => !gl.ok.includes(c))).size; if (blank) out.push("WebGL:" + blank + " 个画布没能创建绘图上下文,截图里是空的"); if (gl.lost) out.push("WebGL:绘图上下文丢失 " + gl.lost + " 次"); } return out; })()`); return [...problems, ...found]; } // ---------- 默认做法提示:模型默认审美和可读性里能机械判断的几项 ---------- // 在页面里运行,不能引用外层变量。只报不拦:命中的改掉,或在交付说明里写出理由。 function lintPage() { const LABELS = { eyebrow: "标题上方的眉标", numbered: "标题上方的编号标签", sideStripe: "彩色单侧边线", gradientText: "渐变文字", nestedCards: "卡片里套卡片", emojiIcon: "表情符号当图标", englishLabel: "中文界面里的英文大写标签", contrastLow: "文字对比度不达标(正文低于 4.5:1,大字低于 3:1)", grayOnColor: "有色底上的灰字", smallText: "正文小于 13px", tightLeading: "多行正文行高过紧", longMeasure: "正文行太长", stuck: "首屏有内容停在透明状态(出场动画没触发?)", headingSpacing: "标题离上文比离下文还近", }; const found = new Map(); const describe = (el) => { const cls = typeof el.className === "string" && el.className.trim() ? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".") : ""; const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 24); return el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + cls + (text ? "「" + text + "」" : ""); }; const add = (rule, el, note) => { let r = found.get(rule); if (!r) found.set(rule, r = { rule, label: LABELS[rule], count: 0, examples: [] }); r.count++; if (r.examples.length < 3) r.examples.push(describe(el) + (note ? "(" + note + ")" : "")); }; const css = (el, pseudo) => getComputedStyle(el, pseudo); const shown = (el) => { const r = el.getBoundingClientRect(), s = css(el); return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none"; }; // 颜色统一画到画布上再读回,oklch、color-mix 这类写法也能比较。 const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1; const ctx = canvas.getContext("2d", { willReadFrequently: true }); const colors = new Map(); const rgba = (value) => { if (!ctx || !value) return { r: 0, g: 0, b: 0, a: 0 }; if (colors.has(value)) return colors.get(value); ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = "rgba(0,0,0,0)"; ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1); const d = ctx.getImageData(0, 0, 1, 1).data; const c = { r: d[0], g: d[1], b: d[2], a: d[3] / 255 }; colors.set(value, c); return c; }; const luminance = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const ratio = (a, b) => { const x = luminance(a), y = luminance(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }; const chroma = ({ r, g, b }) => (Math.max(r, g, b) - Math.min(r, g, b)) / 255; const over = (top, bottom) => ({ r: top.r * top.a + bottom.r * (1 - top.a), g: top.g * top.a + bottom.g * (1 - top.a), b: top.b * top.a + bottom.b * (1 - top.a), a: 1, }); const media = [...document.querySelectorAll("img,video,canvas,picture,iframe,svg image")] .filter(shown).map((m) => m.getBoundingClientRect()).filter((r) => r.width * r.height > 2000); const overMedia = (r) => media.some((m) => { const w = Math.min(r.right, m.right) - Math.max(r.left, m.left), h = Math.min(r.bottom, m.bottom) - Math.max(r.top, m.top); return w > 0 && h > 0 && w * h > r.width * r.height * 0.3; }); // 文字背后的底色:沿祖先往上叠,遇到背景图、渐变或媒体就算不知道。 const backdrop = (el) => { const layers = []; for (let node = el; node && node.nodeType === 1; node = node.parentElement) { const s = css(node); if (s.backgroundImage !== "none") return null; const c = rgba(s.backgroundColor); if (c.a > 0) { layers.push(c); if (c.a >= 0.99) break; } } return layers.reverse().reduce((base, c) => over(c, base), { r: 255, g: 255, b: 255, a: 1 }); }; const ownText = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join("").trim(); const all = [...document.body.querySelectorAll("*")].slice(0, 8000) .filter((el) => !el.closest("svg,script,style,noscript,template,head,[aria-hidden='true'],#oil-mark")); const texts = all.filter((el) => ownText(el).length >= 2 && shown(el)); const cjkCount = (document.body.innerText.match(/[一-鿿]/g) || []).length; const latinCount = (document.body.innerText.match(/[A-Za-z]/g) || []).length; const chinese = cjkCount > 120 && cjkCount > latinCount / 2; // 眉标与编号标签:紧贴在标题正上方、比标题小很多的一行短字 const flaggedAbove = new Set(); const headings = [...document.querySelectorAll("h1,h2,h3")].filter(shown); for (const h of headings) { const hs = parseFloat(css(h).fontSize); if (hs < 20) continue; let prev = h.previousElementSibling, node = h; while (!prev && node.parentElement && node.parentElement !== document.body) { node = node.parentElement; prev = node.previousElementSibling; } if (!prev || !shown(prev) || prev.closest("nav,[aria-label*='readcrumb' i],[class*='breadcrumb' i]")) continue; if (prev.querySelector("a,button,input,select,textarea,img,video,canvas") || /^(A|BUTTON|INPUT|IMG)$/.test(prev.tagName)) continue; const text = (prev.innerText || "").trim(); if (!text || text.length > 48 || text.includes("\n")) continue; let holder = prev; while (!ownText(holder) && holder.children.length === 1) holder = holder.children[0]; const s = css(holder), size = parseFloat(s.fontSize); if (size > 16 || size > hs * 0.6) continue; const pr = prev.getBoundingClientRect(), hr = h.getBoundingClientRect(); if (pr.bottom > hr.top + 4 || hr.top - pr.bottom > Math.max(32, hs * 1.2) || pr.right < hr.left || pr.left > hr.right) continue; const caps = s.textTransform === "uppercase" || (/[A-Z]{3}/.test(text) && text === text.toUpperCase()); const tracked = parseFloat(s.letterSpacing) / size >= 0.05; const mono = /mono|courier|consolas|menlo/i.test(s.fontFamily); const ps = css(prev); const pill = parseFloat(ps.borderTopLeftRadius) >= pr.height / 3 && (rgba(ps.backgroundColor).a > 0.1 || parseFloat(ps.borderTopWidth) >= 1); const numbered = /^0\d$/.test(text) || /^(?:0?\d{1,2}|[IVX]{1,4})(?:\s*[\/·—–|]\s*|[.:]\s+)\S/.test(text); if (numbered) { add("numbered", prev); flaggedAbove.add(h); } else if (caps || tracked || mono || pill) { add("eyebrow", prev); flaggedAbove.add(h); } } // 标题离下文应比离上文近:标题属于它后面的内容 for (const h of headings) { if (flaggedAbove.has(h)) continue; const parent = css(h.parentElement); if (parent.display.includes("grid") || (parent.display.includes("flex") && !parent.flexDirection.startsWith("column"))) continue; let prev = h.previousElementSibling, next = h.nextElementSibling; while (prev && !shown(prev)) prev = prev.previousElementSibling; while (next && !shown(next)) next = next.nextElementSibling; if (!prev || !next) continue; const hr = h.getBoundingClientRect(); const above = hr.top - prev.getBoundingClientRect().bottom, below = next.getBoundingClientRect().top - hr.bottom; if (above >= 0 && below >= 12 && above + 4 < below) add("headingSpacing", h, "上 " + Math.round(above) + "px,下 " + Math.round(below) + "px"); } for (const el of all) { const s = css(el); if (s.display === "none") continue; const r = el.getBoundingClientRect(); if (!r.width || !r.height) continue; // 单侧彩色边线:边框或贴边的伪元素,中性色的结构分隔线不算 const sides = { left: parseFloat(s.borderLeftWidth), right: parseFloat(s.borderRightWidth), top: parseFloat(s.borderTopWidth), bottom: parseFloat(s.borderBottomWidth) }; for (const side of ["left", "right"]) { const w = sides[side], others = Math.max(sides[side === "left" ? "right" : "left"], sides.top, sides.bottom); const c = rgba(s[side === "left" ? "borderLeftColor" : "borderRightColor"]); if (w >= 2 && w >= others * 2 && /solid|double/.test(s[side === "left" ? "borderLeftStyle" : "borderRightStyle"]) && c.a >= 0.4 && chroma(c) >= 0.15 && r.height >= 16 && r.width > sides.left + sides.right + 24 && r.height < innerHeight * 0.8) add("sideStripe", el, w + "px"); } for (const pseudo of ["::before", "::after"]) { const p = css(el, pseudo); if (p.content === "none" || p.position !== "absolute") continue; const pw = parseFloat(p.width), ph = parseFloat(p.height), c = rgba(p.backgroundColor); if (pw >= 2 && pw <= 8 && ph >= r.height * 0.6 && r.height >= 24 && r.width > 60 && c.a >= 0.4 && chroma(c) >= 0.15 && (parseFloat(p.left) <= 2 || parseFloat(p.right) <= 2)) add("sideStripe", el, pseudo); } if (/text/.test(s.backgroundClip + " " + s.getPropertyValue("-webkit-background-clip")) && /gradient/.test(s.backgroundImage) && (el.textContent || "").trim()) add("gradientText", el); } // 卡片里套卡片:有边框或阴影的圆角块,放在另一个圆角块里 const boundary = (el) => { if (el.matches('[role="listbox"],[role="menu"],[role="dialog"],[role="tooltip"],[popover]')) return true; const position = css(el).position; if (position === "fixed") return true; if (position !== "absolute" || !el.parentElement) return false; const r = el.getBoundingClientRect(), parent = el.parentElement.getBoundingClientRect(); // A positioned card inside a panel is still a card; a popup crosses its DOM parent's boundary. return r.left < parent.left - 1 || r.top < parent.top - 1 || r.right > parent.right + 1 || r.bottom > parent.bottom + 1; }; const content = (el) => (el.textContent || "").trim() || el.querySelector("img,svg,canvas,video,input,select,textarea,button,a,[contenteditable]"); const card = (el, inner) => { const s = css(el), r = el.getBoundingClientRect(); if (boundary(el) || !content(el)) return false; if (r.width < 120 || r.height < 56 || /^(BUTTON|A|INPUT|SELECT|TEXTAREA|IMG|VIDEO|CANVAS|LABEL|SUMMARY|PRE|CODE)$/.test(el.tagName)) return false; if (parseFloat(s.borderTopLeftRadius) < 6) return false; const border = ["Top", "Right", "Bottom", "Left"].every((k) => parseFloat(s["border" + k + "Width"]) >= 1 && rgba(s["border" + k + "Color"]).a > 0.05); const shadow = s.boxShadow !== "none"; const fill = rgba(s.backgroundColor).a > 0.05; if (inner) return border || (shadow && fill); return (border || shadow || fill) && r.width * r.height < innerWidth * innerHeight * 0.6; }; const rCompact = (el) => el.getBoundingClientRect().height <= 100; for (const el of all) { if (!card(el, true)) continue; for (let up = el.parentElement, depth = 0; up && up !== document.body && depth < 8; up = up.parentElement, depth++) { if (boundary(up)) break; // A compact attachment with its own removal/retry control is editor content, not another panel. const editor = up.matches("form,[role='textbox']") || up.querySelector("textarea,[contenteditable='true'],[contenteditable='']"); if (editor && rCompact(el) && el.querySelector("button,a,[role='button']") && !el.querySelector("h1,h2,h3,h4,section,article,textarea,[contenteditable]")) break; if (card(up, false)) { add("nestedCards", el, "外层 " + describe(up).split("「")[0]); break; } } } const emoji = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)(?:‍\p{Extended_Pictographic}️?)*$/u; const reaction = (el) => { const button = el.closest("button,[role='button']"); if (!button) return false; const text = (button.textContent || "").trim(); const symbol = ownText(el); // aria-pressed alone also describes toolbar toggles: require an emoji-only value or an adjacent count. return (button.hasAttribute("aria-pressed") && text === symbol) || (text.startsWith(symbol) && /^\s*\d+(?:[.,]\d+)?\s*$/.test(text.slice(symbol.length))); }; const emojiHits = texts.filter((el) => emoji.test(ownText(el)) && !el.closest("p,blockquote,li > p,[role='listbox'],[role='menu'],[popover]") && !reaction(el)); if (emojiHits.length >= 2) emojiHits.forEach((el) => add("emojiIcon", el)); if (chinese) { const labels = texts.filter((el) => { const t = (el.innerText || "").trim(); if (!t || t.length > 40 || /[一-鿿]/.test(t) || el.closest("code,pre,kbd,samp")) return false; const letters = (t.match(/[A-Za-z]/g) || []).length; const upper = css(el).textTransform === "uppercase" || (/[A-Z]{2}/.test(t) && t === t.toUpperCase()); return upper && (letters >= 8 || /[A-Za-z]{2,}\s+[A-Za-z]{2,}/.test(t)); }); if (labels.length >= 2) labels.forEach((el) => add("englishLabel", el)); } for (const el of texts) { const s = css(el), r = el.getBoundingClientRect(); if (el.closest("button:disabled,[disabled],[aria-disabled='true'],input,textarea,select,option")) continue; const size = parseFloat(s.fontSize); let alpha = 1; for (let node = el; node && node.nodeType === 1; node = node.parentElement) alpha *= parseFloat(css(node).opacity); const bg = overMedia(r) ? null : backdrop(el); const fg = rgba(s.color); if (bg && alpha > 0.05 && fg.a > 0) { const color = over({ ...fg, a: fg.a * alpha }, bg); const contrast = ratio(color, bg); const large = size >= 24 || (size >= 18.6 && parseInt(s.fontWeight, 10) >= 700); // 低于 1.5:1 时文字几乎看不见,多半是文字后面还垫着一层查不到的元素(滑块、绝对定位的底板),不报 if (contrast < 1.5) continue; if (contrast < (large ? 3 : 4.5)) add("contrastLow", el, contrast.toFixed(2) + ":1"); else if (chroma(bg) >= 0.25 && chroma(color) < 0.06 && luminance(color) > 0.08 && luminance(color) < 0.6) add("grayOnColor", el); } // 正文:两行以上的段落 const text = ownText(el); const zh = /[一-鿿]/.test(text); if (text.length < (zh ? 30 : 60) || el.closest("code,pre,table,nav,button,label,figcaption,kbd")) continue; const lh = s.lineHeight === "normal" ? size * 1.2 : parseFloat(s.lineHeight); if (r.height < lh * 1.8) continue; if (size < 12.5) add("smallText", el, size + "px"); if (lh / size < (zh ? 1.4 : 1.3)) add("tightLeading", el, (lh / size).toFixed(2)); const perLine = zh ? r.width / size : r.width / (size * 0.5); if (perLine > (zh ? 46 : 95)) add("longMeasure", el, "每行约 " + Math.round(perLine) + (zh ? " 字" : " 个字符")); } // 首屏里停在透明的内容:出场动画没触发时截图会缺一块 for (const el of all) { const r = el.getBoundingClientRect(); if (r.top >= innerHeight || r.bottom <= 0 || r.width < 40 || r.height < 16) continue; if (!(ownText(el).length >= 4 || (el.tagName === "IMG" && el.complete))) continue; if (el.closest("[role='dialog'],[role='tooltip'],[role='menu'],[hidden],dialog:not([open]),details:not([open])")) continue; let alpha = 1, hider = null; for (let node = el; node && node.nodeType === 1; node = node.parentElement) { const o = parseFloat(css(node).opacity); if (o < 0.5 && !hider) hider = node; alpha *= o; } if (alpha >= 0.05 || !hider) continue; const hs = css(hider); if (/absolute|fixed/.test(hs.position) || hs.pointerEvents === "none" || css(el).visibility === "hidden") continue; add("stuck", el); } return [...found.values()]; } async function lint() { try { return await evaluate(`(${lintPage.toString()})()`); } catch (error) { return [{ rule: "lintError", label: "默认做法检查没跑完", count: 1, examples: [error.message.slice(0, 160)] }]; } } const lintLine = (items) => items.map((i) => `${i.label} ${i.count} 处(${i.examples.join(",")})`).join(";"); async function settleAnimations(limit) { if (limit <= 0) return 0; return during(`${currentContext} / 等待有限动画结束`, () => evaluate(`(async () => { const start = performance.now(), limit = ${limit}; let waited = false; while (true) { const animations = document.getAnimations().filter((a) => (a.playState === "running" || a.pending) && a.effect && Number.isFinite(a.effect.getComputedTiming().endTime)); if (!animations.length || performance.now() - start >= limit) break; waited = true; await Promise.race([Promise.all(animations.map((a) => a.finished.catch(() => {}))), new Promise((ok) => setTimeout(ok, Math.min(50, Math.max(0, limit - (performance.now() - start))))) ]); } return waited ? Math.min(limit, Math.round(performance.now() - start)) : 0; })()`)); } async function screenshot(file, full) { let clip; if (full) { const { contentSize } = await cdp("Page.getLayoutMetrics"); clip = { x: 0, y: 0, width: Math.ceil(contentSize.width), height: Math.ceil(contentSize.height), scale: 1 }; } const { data } = await cdp("Page.captureScreenshot", { format: "png", captureBeyondViewport: !!full, ...(clip ? { clip } : {}) }); writeFileSync(file, Buffer.from(data, "base64")); return file; } // Keep color intact: SVG icons and CSS decorations may use currentColor. const MASK_CSS = `*,*::before,*::after{text-shadow:none!important;-webkit-text-fill-color:transparent!important;caret-color:transparent!important} ::placeholder{color:transparent!important}svg text,svg tspan{fill:transparent!important;stroke:transparent!important}`; const mask = () => evaluate(`(() => { const s = document.createElement("style"); s.id = "oil-mask"; s.textContent = ${JSON.stringify(MASK_CSS)}; document.head.append(s); return true; })()`); // 标注版:框和编号画在页面最上层,按文档坐标定位,整页截图时也对得上。 async function mark() { for (const { selector } of marks) await locate(selector, "--mark", false); const result = await evaluate(`((selectors) => { const find = ${findSource}; const layer = document.createElement("div"); layer.id = "oil-mark"; layer.style.cssText = "position:absolute;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none"; const color = "#e8175d"; const missing = []; selectors.forEach(({ label, selector }) => { let found; try { found = find(selector); } catch { missing.push(selector + "(选择器写错了)"); return; } const boxes = found.map((el) => el.getBoundingClientRect()).filter((r) => r.width > 0 && r.height > 0); if (!boxes.length) { missing.push(selector + (found.length ? "(元素不可见)" : "")); return; } boxes.forEach((r, j) => { const box = document.createElement("div"); box.style.cssText = "position:absolute;box-sizing:border-box;border:2px solid " + color + ";border-radius:3px;" + "left:" + (r.left + scrollX - 4) + "px;top:" + (r.top + scrollY - 4) + "px;width:" + (r.width + 8) + "px;height:" + (r.height + 8) + "px"; if (j === 0) { // 小元素的编号放到框外,免得盖住元素;左边放不下就放右边。 const small = r.width < 48 || r.height < 28; const pos = !small ? "left:-12px;top:-12px" : r.left + scrollX - 34 >= 0 ? "left:-30px;top:" + (r.height / 2 - 7) + "px" : "right:-30px;top:" + (r.height / 2 - 7) + "px"; const tag = document.createElement("span"); tag.textContent = label; tag.style.cssText = "position:absolute;" + pos + ";min-width:22px;height:22px;padding:0 6px;box-sizing:border-box;border-radius:11px;" + "background:" + color + ";color:#fff;font:600 13px/22px -apple-system,'PingFang SC',sans-serif;text-align:center;box-shadow:0 0 0 2px #fff"; box.append(tag); } layer.append(box); }); }); document.body.append(layer); return missing; })(${JSON.stringify(marks)})`); if (result.length) throw new Error(`--mark 找不到元素:${result.join(";")}`); } const unmark = () => evaluate(`(document.getElementById("oil-mark")?.remove(), true)`); // ---------- 动作 ---------- // Serialized into the page; each helper includes its own dependencies. function hiddenReason(el) { const describe = (e) => e.tagName.toLowerCase() + (e.id ? "#" + e.id : ""); for (let e = el; e; e = e.parentElement) { const style = getComputedStyle(e); const owner = e === el ? "" : "被祖先 " + describe(e) + " 隐藏:"; if (style.display === "none") return owner + "display:none"; // An ancestor's hidden visibility can be overridden by the child. if (e === el && (style.visibility === "hidden" || style.visibility === "collapse")) { let owner = e; while (owner.parentElement && getComputedStyle(owner.parentElement).visibility === style.visibility) owner = owner.parentElement; return (owner === el ? "" : "被祖先 " + describe(owner) + " 隐藏:") + "visibility:" + style.visibility; } if (Number(style.opacity) === 0) return owner + "opacity:0"; if (style.contentVisibility === "hidden") return owner + "content-visibility:hidden"; if (e !== el && e.tagName === "DETAILS" && !e.open && !e.querySelector(":scope > summary")?.contains(el)) return "被祖先 " + describe(e) + " 隐藏:details 未展开"; } const r = el.getBoundingClientRect(); if (!r.width || !r.height) return "尺寸为 0(宽 " + r.width + "px,高 " + r.height + "px)"; if (!el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return "被祖先或浏览器布局隐藏"; return null; } function findElements(selector, includeHidden = false) { const reason = hiddenReason; const text = (el) => (includeHidden && reason(el) ? el.textContent : typeof el.innerText === "string" ? el.innerText : el.textContent || "").trim().replace(/\s+/g, " "); const all = [...document.querySelectorAll(selector.kind === "css" ? selector.value : selector.kind === "has-text" ? selector.css : "*")]; const visible = all.filter((el) => !reason(el)); if (selector.kind === "css") return includeHidden ? all : visible; const wanted = selector.value.trim().replace(/\s+/g, " "); const match = (pool) => { // Script literals and metadata are not page text, including in hidden-match diagnostics. pool = pool.filter((el) => !el.closest("script,style,template,noscript,head")); if (selector.kind === "has-text") return pool.filter((el) => text(el).includes(wanted)); let found = pool.filter((el) => text(el) === wanted); if (!found.length) found = pool.filter((el) => text(el).includes(wanted)); const depth = (el) => { let n = 0; for (; el; el = el.parentElement) n++; return n; }; found.sort((a, b) => depth(b) - depth(a) || Number(b.matches("button,a,[role=button],input,label,summary,[tabindex]")) - Number(a.matches("button,a,[role=button],input,label,summary,[tabindex]"))); return found; }; return includeHidden ? match(all) : match(visible).slice(0, 1); } const findSource = findElements.toString().replace("const reason = hiddenReason;", "const reason = " + hiddenReason.toString() + ";"); async function locate(selector, context, scroll = true, action = "click", params = {}) { const result = await evaluate(String.raw`((selector) => { const find = ${findSource}, reason = ${hiddenReason.toString()}; let found, usable; try { found = find(selector, true); usable = find(selector); } catch { return { invalid: true }; } const el = usable[0]; if (el) { let r = el.getBoundingClientRect(); if (${scroll} && (r.bottom <= 0 || r.right <= 0 || r.top >= innerHeight || r.left >= innerWidth)) { el.scrollIntoView({ block: "center", inline: "center" }); r = el.getBoundingClientRect(); } const x = Math.max(1, Math.min(innerWidth - 1, r.left + r.width / 2)), y = Math.max(1, Math.min(innerHeight - 1, r.top + r.height / 2)); const warnings = []; if (${scroll && ["click", "dblclick", "hover"].includes(action)}) { const identify = (element) => { const parts = []; for (let e = element; e; e = e.parentElement) { let part = e.localName; if (e.id) part += "#" + CSS.escape(e.id); else { part += [...e.classList].slice(0, 3).map((c) => "." + CSS.escape(c)).join(""); const siblings = e.parentElement ? [...e.parentElement.children].filter((s) => s.localName === e.localName) : [e]; if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(e) + 1) + ")"; } parts.unshift(part); if (document.querySelectorAll(parts.join(" > ")).length === 1) break; } return parts.join(" > "); }; const top = document.elementFromPoint(x, y); if (top && top !== el && !el.contains(top)) warnings.push("中心点被 " + identify(top) + " 挡住,动作仍照常执行"); const disabled = []; if (el.hasAttribute("disabled") || el.matches(":disabled")) disabled.push("disabled"); if (el.getAttribute("aria-disabled")?.toLowerCase() === "true") disabled.push('aria-disabled="true"'); if (getComputedStyle(el).pointerEvents === "none") disabled.push("pointer-events: none"); if (disabled.length) warnings.push("目标处于禁用状态(" + disabled.join("、") + "),动作仍照常执行"); } return { x, y, warnings }; } const hints = [], suggestions = []; const css = selector.kind === "css" ? selector.value : selector.css || ""; const attrs = [...css.matchAll(/\[([^\s~|^$*!=\]]+)(?:[^\]]*)\]/g)].map((m) => m[1]); for (const attr of selector.kind === "css" ? [...new Set(attrs)] : []) { const candidates = [...document.querySelectorAll("*")].filter((e) => e.hasAttribute(attr)); candidates.sort((a, b) => Number(!!reason(a)) - Number(!!reason(b))); const seen = new Set(); for (const e of candidates) { const value = e.getAttribute(attr); if (seen.has(value)) continue; seen.add(value); const query = "[" + CSS.escape(attr) + "=" + JSON.stringify(value) + "]"; if (hints.length < 5) hints.push(attr + "=" + JSON.stringify(value) + (reason(e) ? "(不可见)" : "")); if (!reason(e)) suggestions.push(query); } if (!candidates.length && hints.length < 5) hints.push("页面没有属性 " + attr); } if (selector.kind !== "css") { const distance = ${distance.toString()}; const texts = [...new Set([...document.querySelectorAll("body *")].filter((e) => !reason(e)) .map((e) => (typeof e.innerText === "string" ? e.innerText : e.textContent || "").trim().replace(/\s+/g, " ")).filter((t) => t && t.length < 160))]; const wanted = selector.value.trim().replace(/\s+/g, " "); const contains = (t) => t.includes(wanted) || wanted.includes(t); texts.sort((a, b) => Number(contains(b)) - Number(contains(a)) || distance(a, wanted) - distance(b, wanted)); hints.push(...texts.slice(0, 5).map((t) => "可见文字 " + JSON.stringify(t))); suggestions.push(...texts.slice(0, 5).map((t) => "text=" + JSON.stringify(t))); } if (!hints.length) { const prefix = css.replace(/\s*[^\s]+$/, "").replace(/[>+~]\s*$/, "").trim(); let matches = []; try { if (prefix) matches = [...document.querySelectorAll(prefix)]; } catch {} hints.push("去掉最后一段后 " + JSON.stringify(prefix || "(空)") + " 匹配 " + matches.length + " 个元素"); if (matches.some((e) => !reason(e))) suggestions.push(prefix); } const closed = found[0]?.closest("details:not([open])"), summary = closed?.querySelector(":scope > summary"); const opener = summary && !reason(summary) ? (closed.id ? "#" + CSS.escape(closed.id) + " > summary" : "details:not([open]) > summary") : null; let iframes = 0, shadowRoots = 0; if (!found.length) { const roots = [document]; for (let i = 0; i < roots.length; i++) for (const e of roots[i].querySelectorAll("*")) { if (e.localName === "iframe") iframes++; if (e.shadowRoot) { shadowRoots++; roots.push(e.shadowRoot); } } } return { opener, missing: !found.length, count: found.length, reasons: [...new Set(found.map(reason))].slice(0, 5), hints: hints.slice(0, 5), suggestion: suggestions[0], iframes, shadowRoots }; })(${JSON.stringify(selector)})`); const source = selector.kind === "css" ? selector.value : selector.kind === "text" ? `text=${selector.value}` : `${selector.css}:has-text(${JSON.stringify(selector.value)})`; const suffix = action === "drag" ? ` ${params.dx ?? 0} ${params.dy ?? 0}` : ["type", "fill"].includes(action) ? ` ${JSON.stringify(params.text ?? "文字")}` : action === "select" ? ` ${JSON.stringify(params.value ?? "选项")}` : ""; const example = result.opener && !result.suggestion ? `click ${result.opener}` : `${action} ${result.suggestion || (selector.kind === "css" ? ".open" : 'text="Send money"')}${suffix}`; if (result.invalid) throw new Error(`${context}:${source} 不是合法的 CSS;支持文字写法 text="Send money" 和 button:has-text("Undo"),例如 ${action} text="Send money"${suffix}`); const scope = result.iframes || result.shadowRoots ? `;选择器只在主文档里找,页面里有 ${result.iframes} 个 iframe、${result.shadowRoots} 个开放的 shadow root` : ""; if (result.missing) throw new Error(`${context}:找不到元素 ${source};线索:${result.hints.join(";")};例如 ${example}${scope}`); if (result.count) throw new Error(`${context}:匹配到 ${result.count} 个元素 ${source},但都不可见,不能操作(${result.reasons.join(";")});先执行让它出现的那一步,或换成当前可见的元素;线索:${result.hints.join(";")};例如 ${example}`); for (const warning of result.warnings || []) { const message = `${context}:${warning}`; problems.push(message); console.log(`提示:${message}`); } return result; } const KEYS = { ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39, Enter: 13, Escape: 27, Tab: 9, " ": 32, Space: 32, Home: 36, End: 35, PageUp: 33, PageDown: 34, Backspace: 8, Delete: 46 }; const mouse = (type, x, y, extra = {}) => cdp("Input.dispatchMouseEvent", { type, x, y, button: "left", pointerType: "mouse", ...extra }); async function runSteps(steps = [], observer = {}) { for (const [i, step] of steps.entries()) { const { action, selector, params } = step; const context = `第 ${i + 1} 步「${step.raw}」`; await during(`${currentContext} / ${context}`, async () => { await observer.step?.(step, i); if (action === "wait") { await sleep(params.ms); return; } if (action === "waitfor") { const end = Date.now() + timeoutMs(5000); while (true) { try { await locate(selector, context, false, action, params); break; } catch (error) { if ((!error.message.includes("找不到元素") && !error.message.includes("都不可见")) || Date.now() >= end) throw error; await sleep(100); } } return; } if (["click", "hover", "dblclick", "drag"].includes(action)) { const p = await locate(selector, context, true, action, params); await mouse("mouseMoved", p.x, p.y); if (action === "hover") { await sleep(200); return; } await observer.before?.(step, i); await mouse("mousePressed", p.x, p.y, { clickCount: 1, buttons: 1 }); if (action === "drag") { for (let n = 1; n <= 24; n++) { await mouse("mouseMoved", p.x + params.dx * n / 24, p.y + params.dy * n / 24, { buttons: 1 }); await sleep(16); } await mouse("mouseReleased", p.x + params.dx, p.y + params.dy, { clickCount: 1 }); } else { await mouse("mouseReleased", p.x, p.y, { clickCount: 1 }); if (action === "dblclick") { await mouse("mousePressed", p.x, p.y, { clickCount: 2 }); await mouse("mouseReleased", p.x, p.y, { clickCount: 2 }); } } await observer.after?.(step, i); await sleep(120); } else if (["type", "fill", "select"].includes(action)) { await locate(selector, context, false, action, params); await observer.before?.(step, i); await evaluate(`(() => { const el = (${findSource})(${JSON.stringify(selector)})[0]; el.focus(); ${action === "select" ? `if (!(el instanceof HTMLSelectElement)) throw new Error(${JSON.stringify(`${context}:select 需要原生下拉框;例如 select #country China`)}); const option = [...el.options].find((o) => o.value === ${JSON.stringify(params.value)}) || [...el.options].find((o) => o.textContent.trim() === ${JSON.stringify(params.value)}); if (!option) throw new Error(${JSON.stringify(`${context}:找不到下拉选项 ${params.value};例如 select #country China`)}); el.value = option.value; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true }));` : action === "fill" ? ` if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { const prototype = el instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype; const set = Object.getOwnPropertyDescriptor(prototype, "value").set; set.call(el, ""); set.call(el, ${JSON.stringify(params.text)}); } else if (el.isContentEditable) { el.replaceChildren(); el.textContent = ${JSON.stringify(params.text)}; const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range); } else throw new Error(${JSON.stringify(`${context}:fill 需要输入框、文本域或可编辑元素;例如 fill #message "hello world"`)}); el.dispatchEvent(new Event("input", { bubbles: true, composed: true })); el.dispatchEvent(new Event("change", { bubbles: true }));` : ""} return true; })()`); if (action === "type") await cdp("Input.insertText", { text: params.text }); await observer.after?.(step, i); await sleep(120); } else if (action === "key") { const modifiers = params.modifiers.reduce((n, k) => n | ({ Alt: 1, Control: 2, Meta: 4, Shift: 8, ControlOrMeta: process.platform === "darwin" ? 4 : 2 })[k], 0); const key = params.key === "Space" ? " " : params.key; const code = KEYS[params.key] ?? key.toUpperCase().charCodeAt(0); const event = { key, code: /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : /^\d$/.test(key) ? `Digit${key}` : params.key, windowsVirtualKeyCode: code, modifiers }; await observer.before?.(step, i); await cdp("Input.dispatchKeyEvent", { type: "keyDown", ...event, ...(modifiers & 4 && key.toLowerCase() === "a" ? { commands: ["selectAll"] } : {}) }); await cdp("Input.dispatchKeyEvent", { type: "keyUp", ...event }); await observer.after?.(step, i); await sleep(80); } else if (action === "scroll") { await evaluate(`scrollBy(0, ${params.dy}), true`); await sleep(200); } }); } } // ---------- 并排图:用同一个浏览器把截图排成一张 ---------- async function sheet(items, file, w, h) { const cell = Math.min(w, 420); const escapeHtml = (text) => String(text).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); const figures = items.map(({ path, label }) => `
${escapeHtml(label)}
`).join(""); const html = `
${figures}
`; const tmp = join(profile, "sheet.html"); writeFileSync(tmp, html); const width = items.length * cell + (items.length - 1) * 24 + 64; await setViewport(width, Math.round((cell * h) / w) + 120, 1); await open(`file://${tmp}`); return screenshot(file, true); } // ---------- 对比图:截图还原时和参考图并排、叠加、出差异热图 ---------- const IMAGE_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" }; const CELLS = ["左上", "中上", "右上", "左中", "正中", "右中", "左下", "中下", "右下"]; async function compare(buildPath, refPath, file) { const ref = `data:${IMAGE_TYPES[extname(refPath).toLowerCase()]};base64,${readFileSync(refPath).toString("base64")}`; const build = `data:image/png;base64,${readFileSync(buildPath).toString("base64")}`; const html = `
参考
当前
叠加(当前 50% 盖在参考上)
差异
`; const tmp = join(profile, "compare.html"); writeFileSync(tmp, html); await setViewport(2 * 720 + 20 + 56, 900, 1); await open(`file://${tmp}`); let result = null; for (let i = 0; i < 50 && !result; i++) { result = await evaluate(`window.__compare || null`); if (!result) await sleep(100); } if (!result || result.error) throw new Error(`对比图没做出来:${result?.error || "超时"}`); await screenshot(file, true); const worst = result.cells.map((v, i) => ({ v, i })).sort((x, y) => y.v - x.v).slice(0, 3).map(({ v, i }) => `${CELLS[i]} ${v}%`); const gap = Math.abs(result.refHeight - result.buildHeight) / result.buildHeight > 0.05 ? `;参考图按宽度缩放后高 ${result.refHeight}px,截图高 ${result.buildHeight}px,只比了重叠部分` : ""; return { file: basename(file), overall: result.overall, cells: Object.fromEntries(CELLS.map((c, i) => [c, result.cells[i]])), message: `${basename(file)}:差异明显的像素占 ${result.overall}%,最多的格子 ${worst.join("、")}${gap}` }; } // 外部编码也异步运行,浏览器断开时不会被同步编码挡住。 function runFfmpeg(args, timeout) { return new Promise((ok) => { const child = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] }); encoders.add(child); let stderr = "", done = false; const finish = (status, error) => { if (done) return; done = true; clearTimeout(timer); encoders.delete(child); ok({ status, stderr, error }); }; const timer = setTimeout(() => { child.kill("SIGKILL"); finish(null, { code: "ETIMEDOUT" }); child.stderr.destroy(); }, timeoutMs(timeout)); child.stderr.on("data", (data) => { stderr = (stderr + data).slice(-16384); }); child.once("error", (error) => finish(null, error)); child.once("close", (code) => finish(code)); }); } // ---------- 录屏 ---------- // Keep finite animation intervals even after a short feedback has finished. Runs before page scripts. function recordTiming() { let baseline = new Set(), start = null, end = null, raf, active = true; function sample() { for (const a of document.getAnimations()) { if (baseline.has(a) || !a.effect || !a.playbackRate || typeof a.startTime !== "number") continue; const timing = a.effect.getComputedTiming(), rate = a.playbackRate; if (!Number.isFinite(timing.endTime) || a.playState === "paused" || !["DocumentTimeline", undefined].includes(a.timeline?.constructor.name)) continue; const zero = performance.timeOrigin + a.startTime; const first = Math.min(zero + timing.delay / rate, zero + timing.endTime / rate); const last = Math.max(zero + timing.delay / rate, zero + timing.endTime / rate); start = start === null ? first : Math.min(start, first); end = end === null ? last : Math.max(end, last); } } function tick() { if (!active) return; sample(); raf = requestAnimationFrame(tick); } window.__oilRecordTiming = { reset() { baseline = new Set(document.getAnimations()); start = end = null; }, read() { if (active) sample(); return { start, end }; }, stop() { active = false; cancelAnimationFrame(raf); }, }; tick(); } async function frameDifference(first, second) { return evaluate(`(async () => { const load = (data) => new Promise((ok, no) => { const img = new Image(); img.onload = () => ok(img); img.onerror = () => no(new Error("录屏末帧无法解码")); img.src = "data:image/jpeg;base64," + data; }); const a = await load(${JSON.stringify(readFileSync(first).toString("base64"))}); const b = await load(${JSON.stringify(readFileSync(second).toString("base64"))}); if (a.width !== b.width || a.height !== b.height) return { different: true, dimensions: [a.width, a.height, b.width, b.height] }; const canvas = document.createElement("canvas"); canvas.width = a.width; canvas.height = a.height; const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(a, 0, 0); const p = ctx.getImageData(0, 0, a.width, a.height).data; ctx.clearRect(0, 0, a.width, a.height); ctx.drawImage(b, 0, 0); const q = ctx.getImageData(0, 0, b.width, b.height).data; const cols = Math.ceil(a.width / 32), tiles = new Uint32Array(cols * Math.ceil(a.height / 32)); let changed = 0; for (let y = 0; y < a.height; y++) for (let x = 0; x < a.width; x++) { const i = (y * a.width + x) * 4; if ((Math.abs(p[i] - q[i]) + Math.abs(p[i + 1] - q[i + 1]) + Math.abs(p[i + 2] - q[i + 2])) / 3 > 32) { changed++; tiles[Math.floor(y / 32) * cols + Math.floor(x / 32)]++; } } const maxTile = tiles.reduce((n, v) => Math.max(n, v), 0); return { different: changed >= 32 && (changed / (a.width * a.height) > .005 || maxTile >= 48), changedPixels: changed, maxTilePixels: maxTile, pixelRatio: changed / (a.width * a.height) }; })()`); } function screencastList(frames, finished) { // JPEG's default 25fps time base rounds timestamps and can discard a short feedback frame. // Use the same millisecond precision as the durations before resampling the video to 30fps. const file = (frame) => `file '${basename(frame.name)}'\noption framerate 1000`; // Keep a full 30fps output frame of the final still even when recording ends between ticks. return frames.map((f, i) => `${file(f)}\nduration ${Math.max(i === frames.length - 1 ? 0.034 : 0.016, ((frames[i + 1]?.t ?? finished) - f.t)).toFixed(3)}`).join("\n") + `\n${file(frames.at(-1))}\n`; } async function record(url, w, h, { steps = [], name = "", entry = flags.has("entry"), zoom = zooms[0] } = {}) { const video = `record${name ? "-" + name : ""}.mp4`; const motion = `motion${name ? "-" + name : ""}`; const frames = []; const dir = join(out, name ? `frames-${name}` : "frames"); mkdirSync(dir, { recursive: true }); temporaryFrames.add(dir); let identifier, actionStart, actionEnd, beforeAction, painted = 0; const lastAction = steps.findLastIndex((s) => ["click", "dblclick", "drag", "type", "fill", "select", "key"].includes(s.action)); const captureFrame = async (label) => { const { data } = await cdp("Page.captureScreenshot", { format: "jpeg", quality: 88 }); const frame = { name: join(dir, `${label}.jpg`), t: Date.now() / 1000, source: "Page.captureScreenshot" }; writeFileSync(frame.name, Buffer.from(data, "base64")); return frame; }; const onFrame = (m) => { if (transportFailure || m.sessionId !== sessionId || m.method !== "Page.screencastFrame") return; const name = join(dir, `f${String(frames.length).padStart(4, "0")}.jpg`); writeFileSync(name, Buffer.from(m.params.data, "base64")); frames.push({ name, t: Math.max(frames.at(-1)?.t || 0, m.params.metadata.timestamp), source: "Page.screencastFrame" }); cdp("Page.screencastFrameAck", { sessionId: m.params.sessionId }).catch(() => {}); }; try { await setViewport(w, h, zoom); ({ identifier } = await cdp("Page.addScriptToEvaluateOnNewDocument", { source: `(${recordTiming.toString()})()` })); if (entry) { // 起始的空白页自己写明白底:系统是深色主题时,浏览器会把 about:blank 画成深灰,同一段录屏换台机器开头就不一样。 await open("data:text/html,", 0); // Seed the video with a painted blank page, never the preceding evidence item. frames.push(await captureFrame("blank")); } else await open(url); listeners.push(onFrame); await cdp("Page.startScreencast", { format: "jpeg", quality: 88, everyNthFrame: 1 }); if (entry) { await sleep(50); await open(url); painted = await evaluate(`(() => { const p = performance.getEntriesByName("first-contentful-paint")[0] || performance.getEntriesByType("paint")[0]; return (performance.timeOrigin + (p?.startTime || 0)) / 1000; })()`); } else await sleep(500); await runSteps(steps, { step: async (step, i) => { // A later hover or scroll can have its own animation; it is not this action's feedback. if (lastAction >= 0 && i > lastAction && ["hover", "scroll"].includes(step.action)) await evaluate(`(__oilRecordTiming.stop(), true)`); }, before: async (_, i) => { if (i !== lastAction) return; beforeAction = await captureFrame("before-action"); await evaluate(`(__oilRecordTiming.reset(), true)`); actionStart = Date.now() / 1000; }, after: async (_, i) => { if (i === lastAction) actionEnd = Date.now() / 1000; }, }); if (entry && lastAction < 0) await settleAnimations(2000); await sleep(Number(opt.hold)); const finished = Date.now() / 1000; const final = await captureFrame("hold-end"); if (final) final.t = finished; const streamEnd = frames.filter((frame) => frame.t <= finished).at(-1); const warnings = []; let consistency = { status: "unavailable", reference: `${motion}-end.jpg` }; if (streamEnd) { const difference = await frameDifference(streamEnd.name, final.name); consistency = { status: "matched", reference: `${motion}-end.jpg`, ...difference }; if (difference.different) { // Confirm the page is still before attributing the mismatch to the stream. No frame comparison can prove intermediate motion is correct. await sleep(100); const verification = await captureFrame("verification"); const stability = await frameDifference(final.name, verification.name); consistency.status = stability.different ? "moving" : "mismatch"; if (!stability.different) { const original = `record${name ? "-" + name : ""}-stream-end.jpg`; writeFileSync(join(out, original), readFileSync(streamEnd.name)); consistency.original = original; warnings.push(`录屏末帧与静止截图差异明显,可能没拍全;静止终态以 ${motion}-end.jpg 为准,已补为录像最后一帧,原末帧保留在 ${original};过渡请单独重录:${currentRetryCommand} --hold 2000`); } } } const feedback = await evaluate(`__oilRecordTiming.read()`); const issues = await check(); await cdp("Page.stopScreencast"); listeners.splice(listeners.indexOf(onFrame), 1); if (!frames.length) throw new Error("录屏没有拿到画面"); if (final) { // The stream may deliver a frame from after --hold while checks are running. while (frames.length > 1 && frames.at(-1).t > finished) frames.pop(); // Plain recordings keep their original head/middle/tail samples when the two paths agree. if (entry || lastAction >= 0 || consistency.different) frames.push(final); } const nearest = (time, pool = frames) => pool.reduce((best, f) => Math.abs(f.t - time) < Math.abs(best.t - time) ? f : best); let pick, sampling; if (lastAction >= 0) { const from = Math.max(actionStart, (feedback.start ?? actionStart * 1000) / 1000); const to = Math.max(from, Math.min(finished, (feedback.end ?? (actionEnd * 1000 + 200)) / 1000)); const candidates = [...frames.filter((f) => f.t >= actionStart && f.t <= finished), final]; pick = { start: beforeAction, mid: nearest((from + to) / 2, candidates), end: final }; sampling = { mode: "action", step: lastAction + 1, action: steps[lastAction].action, raw: steps[lastAction].raw }; } else if (entry) { const content = frames.filter((f) => f.t >= painted); const from = content[0]?.t ?? final.t; const to = Math.max(from, Math.min(finished, (feedback.end ?? finished * 1000) / 1000)); pick = { start: content[0] || final, mid: nearest((from + to) / 2, [...content, final]), end: final }; sampling = { mode: "entry" }; } else { pick = { start: frames[0], mid: frames[Math.floor(frames.length / 2)], end: frames[frames.length - 1] }; sampling = { mode: "recording" }; } for (const [k, f] of Object.entries(pick)) writeFileSync(join(out, `${motion}-${k}.jpg`), readFileSync(f.name)); const frameReport = Object.fromEntries(Object.entries(pick).map(([k, f]) => [k, { file: `${motion}-${k}.jpg`, source: f.source, timeMs: Math.max(0, Math.round((f.t - frames[0].t) * 1000)), }])); const details = { frames: frameReport, sampling, consistency, warnings, durationMs: Math.round((finished - frames[0].t) * 1000) }; const ffmpeg = (await runFfmpeg(["-version"], 5000)).status === 0; if (!ffmpeg) return { issues, ...details, failed: true, message: `录屏:没装 ffmpeg,只留了 ${motion}-start/mid/end.jpg 三帧` }; // Generated basenames are safe for concat's quoting, even when --out contains an apostrophe. // Keep the final still frame through the end of --hold; screencasts only emit changed frames. const list = screencastList(frames, finished); writeFileSync(join(dir, "list.txt"), list); const r = await runFfmpeg(["-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", join(dir, "list.txt"), "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,fps=30", "-pix_fmt", "yuv420p", join(out, video)], 60000); if (r.status !== 0) return { issues, ...details, failed: true, message: `录屏:ffmpeg 合成失败(${r.error?.code === "ETIMEDOUT" ? `${60 * timeoutScale} 秒内没有合成完成` : r.error?.message || r.stderr.trim().split("\n").pop()}),已保留 ${motion}-start/mid/end.jpg 三帧` }; rmSync(dir, { recursive: true, force: true }); return { issues, ...details, message: `录屏:${video}(${(finished - frames[0].t).toFixed(1)} 秒)和 ${motion}-start/mid/end.jpg` }; } finally { const index = listeners.indexOf(onFrame); if (index >= 0) listeners.splice(index, 1); try { if (!transportFailure && !browserNeedsRestart) await cdp("Page.stopScreencast"); } finally { try { if (!transportFailure && !browserNeedsRestart) { await evaluate(`(window.__oilRecordTiming?.stop(), delete window.__oilRecordTiming, true)`); if (identifier) await cdp("Page.removeScriptToEvaluateOnNewDocument", { identifier }); } } finally { rmSync(dir, { recursive: true, force: true }); temporaryFrames.delete(dir); } } } } // ---------- 动效探测 ---------- // Runs before page scripts: records which elements animate in each phase and how far they move. const MOTION_PROBE = `(() => { if (window.__oilMotion) return; const tracked = new Map(); let phase = "load"; const seen = new Set(); function touch(el, source) { if (!(el instanceof Element) || el.id === "oil-mask") return; let t = tracked.get(el); if (!t) { if (tracked.size >= 400) return; t = { phases: {} }; tracked.set(el, t); } let p = t.phases[phase]; if (!p) p = t.phases[phase] = { first: null, last: null, frames: 0, move: 0, size: 0, opacity: 0, sources: new Set() }; p.sources.add(source); if (source.startsWith("js:")) t.lastJs = performance.now(); } addEventListener("animationstart", (e) => touch(e.target, "css:" + e.animationName), true); addEventListener("transitionrun", (e) => touch(e.target, "transition:" + e.propertyName), true); new MutationObserver((list) => { for (const m of list) touch(m.target, "js:" + m.attributeName); }) .observe(document, { subtree: true, attributes: true, attributeFilter: ["style", "transform", "viewBox", "d", "x", "y", "cx", "cy", "r", "points", "opacity", "stroke-dashoffset"] }); // 首屏阶段按视口坐标量:被固定住的舞台不算在动,舞台里的层次变化才算。 function read(el) { const r = el.getBoundingClientRect(); const page = phase === "hero" ? 0 : 1; return { x: r.left + scrollX * page, y: r.top + scrollY * page, w: r.width, h: r.height, o: +getComputedStyle(el).opacity }; } function sample() { if (document.getAnimations) for (const a of document.getAnimations()) { if (a.playState !== "running" || !a.effect || !a.effect.target) continue; const tl = a.timeline && a.timeline.constructor && a.timeline.constructor.name; const scrollLinked = tl === "ScrollTimeline" || tl === "ViewTimeline"; if (scrollLinked) touch(a.effect.target, "scroll-timeline:" + (a.animationName || "animation")); else if (!seen.has(a)) { seen.add(a); if (!a.animationName && !a.transitionProperty) touch(a.effect.target, "waapi"); } } for (const [el, t] of tracked) { const p = t.phases[phase]; if (!p || !el.isConnected) continue; const now = read(el); if (!p.first) { p.first = p.last = now; continue; } const l = p.last; if (Math.abs(now.x - l.x) + Math.abs(now.y - l.y) + Math.abs(now.w - l.w) + Math.abs(now.h - l.h) > 0.1 || Math.abs(now.o - l.o) > 0.005) p.frames++; p.last = now; p.move = Math.max(p.move, Math.hypot(now.x - p.first.x, now.y - p.first.y)); p.size = Math.max(p.size, Math.abs(now.w - p.first.w) / Math.max(1, p.first.w), Math.abs(now.h - p.first.h) / Math.max(1, p.first.h)); p.opacity = Math.max(p.opacity, Math.abs(now.o - p.first.o)); } requestAnimationFrame(sample); } requestAnimationFrame(sample); window.__oilMotion = { setPhase(next) { phase = next; }, summary(name) { const items = []; for (const [el, t] of tracked) { const p = t.phases[name]; if (!p) continue; const label = el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (typeof el.className === "string" && el.className.trim() ? "." + el.className.trim().split(/\\s+/).slice(0, 2).join(".") : ""); const infinite = el.getAnimations ? el.getAnimations().some((a) => a.effect && a.effect.getTiming && a.effect.getTiming().iterations === Infinity) : false; const loop = infinite || (t.lastJs && performance.now() - t.lastJs < 250); if (p.frames < 3) continue; // one-off jumps are state writes, not motion items.push({ el: label, move: Math.round(p.move), size: +p.size.toFixed(3), opacity: +p.opacity.toFixed(2), loop: !!loop, sources: [...p.sources].slice(0, 3) }); } const visible = items.filter((i) => ((name !== "scroll" && name !== "hero") || !i.loop) && (i.move >= 1 || i.size >= 0.005 || i.opacity >= 0.05 || i.sources.some((s) => s.startsWith("scroll-timeline")))); visible.sort((a, b) => (b.move + b.size * 400 + b.opacity * 40) - (a.move + a.size * 400 + a.opacity * 40)); return { elements: visible.length, loops: visible.filter((i) => i.loop).length, maxMove: Math.max(0, ...visible.map((i) => i.move)), maxSize: Math.max(0, ...visible.map((i) => i.size)), maxOpacity: Math.max(0, ...visible.map((i) => i.opacity)), top: visible.slice(0, 8), }; }, }; })()`; async function probeMotion(url, w, h, steps = []) { const { identifier } = await cdp("Page.addScriptToEvaluateOnNewDocument", { source: MOTION_PROBE }); try { problems = []; await setViewport(w, h, 1); await open(url); await sleep(1200); const phases = { load: await evaluate(`__oilMotion.summary("load")`) }; if (steps.length) { await evaluate(`__oilMotion.setPhase("steps"), true`); await runSteps(steps); await sleep(900); phases.steps = await evaluate(`__oilMotion.summary("steps")`); } await evaluate(`(scrollTo(0, 0), __oilMotion.setPhase("hero"), true)`); await sleep(150); const height = await evaluate(`document.documentElement.scrollHeight - innerHeight`); const scrollable = height > 4; const heroEnd = Math.min(height, Math.round(h * 1.5)); for (let y = 0; y <= heroEnd; y += Math.round(h / 10)) { await evaluate(`scrollTo(0, ${y}), true`); await sleep(70); } await sleep(400); phases.hero = await evaluate(`__oilMotion.summary("hero")`); await evaluate(`(__oilMotion.setPhase("scroll"), true)`); for (let y = heroEnd; y < height; y += Math.round(h / 4)) { await evaluate(`scrollTo(0, ${y}), true`); await sleep(90); } await evaluate(`scrollTo(0, ${height}), true`); await sleep(600); phases.scroll = await evaluate(`__oilMotion.summary("scroll")`); const issues = []; const names = { load: "首次进入", steps: "--steps 动作", hero: "首屏滚动", scroll: "从头滚到底" }; const weak = (p) => p.maxMove < 4 && p.maxSize < 0.02 && p.maxOpacity < 0.3; for (const [k, p] of Object.entries(phases)) { // 首屏景深是可选手法:只报告层数和幅度,供选了它的页面核对,不记为问题。 if (k === "hero") { p.layers = p.top.filter((i) => i.size >= 0.05 || i.move >= h * 0.05).length; continue; } if (k === "scroll") { p.scrollable = scrollable; if (!p.elements && scrollable) issues.push("滚动:没有检测到随滚动出现的变化;落地页、品牌页、发布页和展览页需要一段滚动叙事"); continue; } if (!p.elements) issues.push(`${names[k]}:没有检测到动画`); else if (p.loops === p.elements) issues.push(`${names[k]}:只有持续循环的动画,没有一次性的${k === "load" ? "出场" : "反馈"}`); else if (weak(p)) issues.push(`${names[k]}:动画幅度太小,看不出来(最大位移 ${p.maxMove}px,尺寸变化 ${(p.maxSize * 100).toFixed(1)}%,透明度变化 ${p.maxOpacity})`); } const brief = (k, p) => k === "scroll" && !p.scrollable ? "页面不滚动,跳过滚动检查" : k === "hero" ? `首屏滚动 ${p.layers} 层在变,最大缩放 ${(p.maxSize * 100).toFixed(1)}%,最大位移 ${p.maxMove}px` : `${names[k]} ${p.elements} 个元素在动,最大位移 ${p.maxMove}px,透明度变化 ${p.maxOpacity}`; return { phases, issues: [...problems, ...issues], message: "动效探测:" + Object.entries(phases).map(([k, p]) => brief(k, p)).join(";") }; } finally { await cdp("Page.removeScriptToEvaluateOnNewDocument", { identifier }); } } // ---------- 主流程 ---------- const base = await resolveTarget(target); const withState = (s) => { const u = new URL(base); for (const [key, value] of new URLSearchParams(opt.query || "")) u.searchParams.set(key, value); if (s) u.searchParams.set(opt.param, s); return u.toString(); }; const report = [], lines = []; const usedFiles = new Set(); function reserveFamily(preferred, files) { let name = preferred, n = 2; while (files(name).some((file) => usedFiles.has(file))) name = `${preferred ? preferred + "-" : ""}${n++}`; files(name).forEach((file) => usedFiles.add(file)); if (name !== preferred) notices.push(`文件名冲突,已区分:${files(preferred)[0]} → ${files(name)[0]}`); return name; } saveFatalReport = (error) => { report.push({ file: "browser", failed: true, issues: [...problems, error.message] }); writeFileSync(join(out, "report.json"), JSON.stringify(report, null, 2)); }; const evidence = flags.has("evidence"); // One completed item produces one stderr progress line; stdout keeps its existing summary. let completed = 0, progressTotal = 0; function progress(file, failed = false) { console.error(`[${++completed}/${progressTotal}] ${file} ${failed === "skipped" ? "跳过,已记入报告" : failed ? "失败,已记入报告" : "完成"}`); } function skipped(file, metadata, reason) { report.push({ file, ...metadata, skipped: true, issues: [reason] }); lines.push(`${file} ⚠ 跳过:${reason}`); progress(file, "skipped"); } function retryItem(file, metadata, extra = "") { const group = groups.find((g) => g.id === (metadata.group || "") && !(evidence && file.endsWith(".png"))); const steps = group?.steps.length ? (group.id ? group.id + ": " : "") + group.steps.map((s) => s.raw).join("; ") : ""; const variant = file.endsWith("-masked.png") ? " --mask" : file.endsWith("-marked.png") ? ` --mark ${quoteArg(opt.mark)}` : file.endsWith("-compare.png") ? ` --compare ${quoteArg(refImage)}` : ""; const isSheet = file.endsWith(".png") && metadata.state === undefined && states.length > 1; return retryCommand + ` --out ${quoteArg(out)}${metadata.size ? ` --size ${quoteArg(metadata.size)}` : ""}${metadata.zoom ? ` --zoom ${metadata.zoom}` : ""}` + (metadata.state !== null && metadata.state !== undefined ? ` --states ${quoteArg(metadata.state)} --param ${quoteArg(opt.param)}` : "") + (isSheet ? ` --states ${quoteArg(states.join(","))} --param ${quoteArg(opt.param)} --sheet` : "") + variant + (opt.query ? ` --query ${quoteArg(opt.query)}` : "") + (steps ? ` --steps ${quoteArg(steps)}` : "") + (file.startsWith("motion-probe") ? " --motion" : extra) + (flags.has("full") && file.endsWith(".png") ? " --full" : ""); } async function item(file, metadata, work, extra = "") { currentRetryCommand = retryItem(file, metadata, extra); let failed = false; const firstResult = report.length; for (let attempt = 0; attempt < 2; attempt++) { const previousLines = lines.length; const reopened = browserNeedsRestart; if (reopened) await restartBrowser(); try { await during(file, () => work(attempt > 0 || reopened)); break; } catch (error) { if (transportFailure) throw error; if (error.code === "SCREENSHOT_TIMEOUT" && !attempt) { lines.length = previousLines; console.error(`shoot:${error.message};正在重开浏览器重试 ${file} 一次`); await restartBrowser(); continue; } report.push({ file, ...metadata, failed: true, issues: [...problems, error.message] }); lines.push(`${file} ⚠ 失败:${error.message}`); process.exitCode = 1; failed = true; // Ordinary one-item failures retain their old stderr diagnostic; batches can finish the rest. console.error(`shoot:${error.message}`); break; } } progress(file, failed || report.slice(firstResult).some((r) => r.failed)); } async function captureBatch(selectedStates, selectedZooms, selectedGroups, useMask, useSheet) { for (const { w, h } of sizes) for (const zoom of selectedZooms) for (const group of selectedGroups) { const shots = [], masked = []; const suffix = [group.id, sizes.length > 1 ? `${w}x${h}` : "", zoom !== 1 ? `@${zoom}x` : ""].filter(Boolean).join("-"); for (const stateIndex of selectedStates) { const state = states[stateIndex]; const preferred = [stateIds[stateIndex], suffix].filter(Boolean).join("-"); const name = reserveFamily(preferred, (stem) => [`${stem}.png`, ...(useMask ? [`${stem}-masked.png`] : []), ...(marks.length ? [`${stem}-marked.png`] : []), ...(refImage ? [`${stem}-compare.png`] : [])]); const meta = { state, size: `${w}x${h}`, zoom, ...(group.id ? { group: group.id } : {}) }; const prepare = async () => { problems = []; await setViewport(w, h, zoom); await open(withState(state)); let waited = await settleAnimations(2000); await runSteps(group.steps); waited += await settleAnimations(2000 - waited); return waited; }; let entry; const file = join(out, `${name}.png`); await item(`${name}.png`, meta, async () => { const animationWaitMs = await prepare(); await screenshot(file, flags.has("full")); const issues = await check(), hints = await lint(); entry = { file: basename(file), ...meta, animationWaitMs, issues, lint: hints }; lines.push(`${basename(file)}${issues.length ? " ⚠ " + issues.join(";") : ""}${hints.length ? " ◇ 默认做法提示:" + lintLine(hints) : ""}`); report.push(entry); shots.push({ path: file, label: state || "page" }); }); if (!shots.some((shot) => basename(shot.path) === `${name}.png`)) { for (const extra of [marks.length && "marked", useMask && "masked", refImage && "compare"].filter(Boolean)) { skipped(`${name}-${extra}.png`, meta, `原截图 ${name}.png 失败,无法生成这一项`); } continue; } if (marks.length) await item(`${name}-marked.png`, meta, async (retried) => { if (retried) await prepare(); try { await mark(); lines.push(basename(await screenshot(join(out, `${name}-marked.png`), flags.has("full")))); } finally { if (!browserNeedsRestart) await unmark(); } }); if (useMask) await item(`${name}-masked.png`, meta, async (retried) => { if (retried) await prepare(); try { await mask(); await sleep(60); masked.push({ path: await screenshot(join(out, `${name}-masked.png`), flags.has("full")), label: state || "page" }); lines.push(`${name}-masked.png`); } finally { if (!browserNeedsRestart) await evaluate(`(document.getElementById("oil-mask")?.remove(), true)`); } }); if (refImage) await item(`${name}-compare.png`, meta, async () => { const result = await compare(file, refImage, join(out, `${name}-compare.png`)); entry.compare = { file: result.file, overall: result.overall, cells: result.cells }; lines.push(result.message); }); } if (useSheet && selectedStates.length > 1) { const sheetSuffixParts = [group.id, sizes.length > 1 ? `${w}x${h}` : "", selectedZooms.length > 1 && zoom !== 1 ? `@${zoom}x` : ""].filter(Boolean); const preferred = `sheet${sheetSuffixParts.length ? "-" + sheetSuffixParts.join("-") : ""}`; const sheetName = reserveFamily(preferred, (stem) => [`${stem}.png`, ...(useMask ? [`${stem}-masked.png`] : [])]); const meta = { size: `${w}x${h}`, zoom, ...(group.id ? { group: group.id } : {}) }; if (shots.length > 1) await item(`${sheetName}.png`, meta, async () => { lines.push(basename(await sheet(shots, join(out, `${sheetName}.png`), w, h))); }); else skipped(`${sheetName}.png`, meta, `只收到 ${shots.length} 张成功截图,并排图至少需要 2 张`); if (useMask) { if (masked.length > 1) await item(`${sheetName}-masked.png`, meta, async () => { lines.push(basename(await sheet(masked, join(out, `${sheetName}-masked.png`), w, h))); }); else skipped(`${sheetName}-masked.png`, meta, `只收到 ${masked.length} 张成功遮字图,并排图至少需要 2 张`); } } } } async function motionBatch() { for (const group of groups.length ? groups : [{ id: "", steps: [] }]) { const file = `motion-probe${group.id ? "-" + group.id : ""}`; const meta = { state: states[0], size: `${sizes[0].w}x${sizes[0].h}`, zoom: 1, ...(group.id ? { group: group.id } : {}) }; await item(file, meta, async () => { const result = await probeMotion(withState(states[0]), sizes[0].w, sizes[0].h, group.steps); lines.push(result.message + (result.issues.length ? " ⚠ " + result.issues.join(";") : "")); report.push({ file, ...meta, motion: result.phases, issues: result.issues }); }); } } async function recording(group, entry, zoom, multipleZooms = false) { const preferred = [group.id, multipleZooms && zoom !== 1 ? `@${zoom}x` : ""].filter(Boolean).join("-"); const name = reserveFamily(preferred, (stem) => [`record${stem ? "-" + stem : ""}.mp4`, ...["start", "mid", "end"].map((frame) => `motion${stem ? "-" + stem : ""}-${frame}.jpg`)]); const file = `motion${name ? "-" + name : ""}-end.jpg`; const meta = { state: states[0], size: `${sizes[0].w}x${sizes[0].h}`, zoom, ...(group.id ? { group: group.id } : {}) }; await item(file, meta, async () => { problems = []; const result = await record(withState(states[0]), sizes[0].w, sizes[0].h, { name, steps: group.steps, entry, zoom }); lines.push(result.message); for (const warning of result.warnings || []) lines.push(`⚠ ${warning}`); report.push({ file, ...meta, issues: result.issues, frames: result.frames, sampling: result.sampling, durationMs: result.durationMs, ...(result.consistency ? { consistency: result.consistency } : {}), ...(result.warnings?.length ? { warnings: result.warnings } : {}), ...(result.failed ? { failed: true } : {}) }); if (evidence && result.failed) { report.at(-1).issues.push(result.message); process.exitCode = 1; } }, ` --record${entry ? " --entry" : ""}`); } const shotCount = (n, z, g, mask, sheet) => sizes.length * z * g * (n * (1 + Number(mask) + Number(marks.length > 0) + Number(!!refImage)) + (sheet && n > 1 ? 1 + Number(mask) : 0)); const batchMask = states.length > 1 || flags.has("mask"); progressTotal = evidence ? (suppliedOptions.has("zoom") ? shotCount(states.length, zooms.length, 1, batchMask, states.length > 1) : shotCount(states.length, 1, 1, batchMask, states.length > 1) + shotCount(1, 1, 1, flags.has("mask"), false)) + Math.max(1, groups.length) + 1 + groups.length : (flags.has("motion") ? Math.max(1, groups.length) : 0) + (flags.has("record") ? zooms.length * Math.max(1, groups.length) : shotCount(states.length, zooms.length, Math.max(1, groups.length), flags.has("mask"), flags.has("sheet"))); try { if (evidence) { const plain = [{ id: "", steps: [] }]; if (suppliedOptions.has("zoom")) { await captureBatch(states.map((_, i) => i), zooms, plain, states.length > 1 || flags.has("mask"), states.length > 1); } else { await captureBatch(states.map((_, i) => i), [1], plain, states.length > 1 || flags.has("mask"), states.length > 1); await captureBatch([0], [2], plain, flags.has("mask"), false); } await motionBatch(); // Avoid collisions with a user group named entry. let entryId = "entry"; while (groups.some((g) => g.id === entryId)) entryId += "-appearance"; await recording({ id: entryId, steps: [] }, true, 1); for (const group of groups) await recording(group, false, 1); } else { if (flags.has("motion")) await motionBatch(); if (flags.has("record")) { for (const zoom of zooms) for (const group of groups.length ? groups : [{ id: "", steps: [] }]) await recording(group, flags.has("entry"), zoom, zooms.length > 1); } else await captureBatch(states.map((_, i) => i), zooms, groups.length ? groups : [{ id: "", steps: [] }], flags.has("mask"), flags.has("sheet")); } } catch (error) { if (!transportFailure) console.error(`shoot:${error.message}`); process.exitCode = 1; } writeFileSync(join(out, "report.json"), JSON.stringify(report, null, 2)); console.log(`输出目录:${out}`); for (const notice of notices) console.log(`- ${notice}`); for (const l of lines) console.log(`- ${l}`); const total = report.reduce((n, r) => n + r.issues.length, 0); if (report.length) console.log(total ? `发现 ${total} 个问题,详见 report.json` : `检查通过:没有控制台错误、横向溢出或加载失败的图片${flags.has("motion") ? ",三段动效都检测到了" : ""}`); const hinted = [...new Set(report.flatMap((r) => (r.lint || []).map((i) => i.label)))]; if (hinted.length) console.log(`默认做法提示 ${hinted.length} 类:${hinted.join("、")}。可读性几项(对比度不达标、正文小于 13px、行高过紧、停在透明)要改;其余改掉,或在交付说明里写出它怎样服务方向,误报也写一句。详见 report.json 的 lint`); finishing = true; await cleanup(); process.exit(process.exitCode || 0);