Files
workbuddy_skills/oil-ui-pro/scripts/shoot.mjs
T
admin d9445b5888 oil-ui-pro 0.15.4 → 0.22.2(上游 ui.oiloil.org 正式包)+ 记 P0-98 跨盘 rename 坑
- oil-ui-pro 升到 0.22.2:references 13→28 份(新增 16、删 imagery.md)、assets 增 cards/(22 组)与 fonts/、SKILL.md 顶层五步结构未改名。
- 升级路径:官方 `npx github:oil-oil/oil-cli update` 稳定失败(error=replace、报「检查目录权限」)。
  真身为 EXDEV —— 解压在 os.tmpdir()(C 盘)、技能目录在 E 盘,Windows 上 rename 不能跨盘;
  CLI 的 catch 把非 CliError 原生异常一律兜底成 replaceFailed,真错误一个字不吐。
  做法=借其 prepare() 只做下载+校验,再 cp 到同盘 stage、rename 交换;官方命令复验 up_to_date。
- 回归:product-planning 按名引用的 visual-review.md/tools.md/design-direction.md/style-explorer.md 全部健在;命名闸门 2061|0|27。
- session-mechanism/references/pitfalls.md 新增 P0-98(含通则:更新器把原生错误兜底成指错方向的人话)。
2026-10-14 10:21:46 +08:00

1773 lines
113 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <a,b,...> 依次用 ?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 <选择器> <dx> <dy>,例如 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 <dy> | 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const figures = items.map(({ path, label }) =>
`<figure><img src="data:image/png;base64,${readFileSync(path).toString("base64")}"><figcaption>${escapeHtml(label)}</figcaption></figure>`).join("");
const html = `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><style>body{margin:0;padding:32px;background:#ececea;font:13px -apple-system,"PingFang SC",sans-serif;color:#555}
main{display:flex;gap:24px;align-items:flex-start}figure{margin:0;width:${cell}px}img{width:100%;display:block;border-radius:12px;box-shadow:0 1px 3px #0002}
figcaption{margin-top:10px}</style><main>${figures}</main>`;
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 = `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<style>body{margin:0;padding:28px;background:#ececea;font:13px -apple-system,"PingFang SC",sans-serif;color:#555}
main{display:grid;grid-template-columns:repeat(2,720px);gap:24px 20px;align-items:start}figure{margin:0}
canvas{width:100%;display:block;border-radius:8px;box-shadow:0 1px 3px #0002;background:#fff}figcaption{margin-top:8px}</style>
<main><figure><canvas id="ref"></canvas><figcaption>参考</figcaption></figure><figure><canvas id="build"></canvas><figcaption>当前</figcaption></figure>
<figure><canvas id="overlay"></canvas><figcaption>叠加(当前 50% 盖在参考上)</figcaption></figure><figure><canvas id="diff"></canvas><figcaption id="note">差异</figcaption></figure></main>
<script>(async () => {
const load = (src) => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = () => no(new Error("参考图打不开")); i.src = src; });
const [ref, build] = await Promise.all([load(${JSON.stringify(ref)}), load(${JSON.stringify(build)})]);
const W = build.naturalWidth, refH = Math.round(ref.naturalHeight * W / ref.naturalWidth), H = Math.min(refH, build.naturalHeight);
const paint = (id, fn) => { const c = document.getElementById(id); c.width = W; c.height = H; const x = c.getContext("2d"); fn(x); return x; };
paint("ref", (x) => x.drawImage(ref, 0, 0, W, refH));
paint("build", (x) => x.drawImage(build, 0, 0));
paint("overlay", (x) => { x.drawImage(ref, 0, 0, W, refH); x.globalAlpha = 0.5; x.drawImage(build, 0, 0); });
// 缩到 480 宽再逐像素比颜色:任一通道差超过 0.1 算“有差异”,忽略抗锯齿和一两像素的错位
const sw = 480, sh = Math.max(1, Math.round(H * sw / W));
const sample = (img) => { const c = document.createElement("canvas"); c.width = sw; c.height = sh; const x = c.getContext("2d", { willReadFrequently: true });
const k = img.naturalWidth / W; x.drawImage(img, 0, 0, img.naturalWidth, H * k, 0, 0, sw, sh); return x.getImageData(0, 0, sw, sh).data; };
const a = sample(ref), b = sample(build);
const lum = (d, i) => (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
const heat = new ImageData(sw, sh), sums = Array(9).fill(0), counts = Array(9).fill(0);
let total = 0;
for (let y = 0; y < sh; y++) for (let x = 0; x < sw; x++) {
const i = (y * sw + x) * 4, d = Math.max(Math.abs(a[i] - b[i]), Math.abs(a[i + 1] - b[i + 1]), Math.abs(a[i + 2] - b[i + 2])) / 255, cell = Math.min(2, Math.floor(y * 3 / sh)) * 3 + Math.min(2, Math.floor(x * 3 / sw));
const hit = d > 0.1 ? 1 : 0;
total += hit; sums[cell] += hit; counts[cell]++;
const g = lum(b, i) * 255 * 0.35 + 150;
heat.data[i] = hit ? 225 : g; heat.data[i + 1] = hit ? 40 : g; heat.data[i + 2] = hit ? 60 : g; heat.data[i + 3] = 255;
}
const small = document.createElement("canvas"); small.width = sw; small.height = sh; small.getContext("2d").putImageData(heat, 0, 0);
const cells = sums.map((s, i) => +(100 * s / Math.max(1, counts[i])).toFixed(1));
paint("diff", (x) => {
x.drawImage(small, 0, 0, W, H);
x.strokeStyle = "#0006"; x.lineWidth = Math.max(1, W / 420); x.fillStyle = "#111"; x.font = "600 " + Math.round(W / 22) + "px -apple-system,sans-serif";
for (let i = 0; i < 9; i++) { const cx = (i % 3) * W / 3, cy = Math.floor(i / 3) * H / 3; x.strokeRect(cx, cy, W / 3, H / 3); x.fillText(cells[i] + "%", cx + W / 60, cy + W / 18); }
});
window.__compare = { overall: +(100 * total / (sw * sh)).toFixed(1), cells, refHeight: refH, buildHeight: build.naturalHeight };
})().catch((e) => { window.__compare = { error: e.message }; });</script>`;
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,<html style='background:%23fff'>", 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);