agentcss 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/01-tokens/README.md +1 -1
  2. package/01-tokens/responsive-demo.html +1 -1
  3. package/01-tokens/tokens.core.css +1 -1
  4. package/01-tokens/tokens.css +2 -2
  5. package/02-primitives/date-range/date-range.js +1 -1
  6. package/02-primitives/timeline/demo.html +1 -1
  7. package/03-patterns/drawer/demo.html +1 -1
  8. package/03-patterns/overlay/overlay.css +1 -1
  9. package/03-patterns/states/states.css +1 -1
  10. package/04-recipes/app-shell/README.md +1 -1
  11. package/04-recipes/app-shell/demo.html +1 -1
  12. package/04-recipes/longform/content.html +6 -6
  13. package/04-recipes/report/demo.html +2 -2
  14. package/04-recipes/table/README.md +1 -1
  15. package/04-recipes/table/demo.html +1 -1
  16. package/09-assets/bar/bar.css +1 -1
  17. package/09-assets/model-viewer/model-viewer.js +1 -1
  18. package/AGENTS.md +3 -1
  19. package/CHANGELOG.md +103 -5
  20. package/CONTRIBUTING.md +44 -3
  21. package/README.md +2 -0
  22. package/README.zh-CN.md +2 -0
  23. package/START-HERE.md +12 -5
  24. package/ai/START-HERE.md +40 -6
  25. package/ai/check-html.js +658 -0
  26. package/ai/cli.js +557 -67
  27. package/ai/components.json +1 -1
  28. package/ai/pdf.js +2 -2
  29. package/ai/recipes.json +86 -0
  30. package/ai/scaffold.js +195 -0
  31. package/ai/shot.js +345 -0
  32. package/ai/validate.js +65 -6
  33. package/dist/COSTS.md +10 -10
  34. package/docs/ACCESSIBILITY.md +2 -2
  35. package/docs/ARCHITECTURE.md +116 -0
  36. package/docs/LAYER-BASELINE.md +2 -2
  37. package/docs/PRINT.md +3 -3
  38. package/docs/RELEASE-PROCESS.md +52 -2
  39. package/docs/STABILITY.md +1 -1
  40. package/docs/VERIFY-RELEASE.md +17 -0
  41. package/index.html +3 -3
  42. package/llms-full.txt +17 -3
  43. package/llms.txt +26 -5
  44. package/package.json +2 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
3
  "generatedBy": "05-audit/gen-ai-contract.py",
4
- "libraryVersion": "0.26.0",
4
+ "libraryVersion": "0.27.0",
5
5
  "$comment": "机器可读的组件契约。slots = BEM 元素名(在本库里元素名就是插槽名),states.enum = 组件会在 DOM 上写出的 data-* 取值,states.legacyClass = 仍未收敛到 data 属性的历史状态类(INVARIANT I-8 未达标)。printPolicy = 这个组件在纸上会发生什么(空数组 = 原样保留);pagination = 内容是否被分页裁过(server 不许与 expand 同时出现,否则展开的只是当前一页)。",
6
6
  "maturityCriteria": {
7
7
  "alpha": "缺 demo.html 或缺 05-audit 下的专属门禁 ⇒ 还没被机器验证过,不要用",
package/ai/pdf.js CHANGED
@@ -202,7 +202,7 @@ function paperSpec(name) {
202
202
  return PAPERS[k] ? { key: k, ...PAPERS[k] } : null;
203
203
  }
204
204
 
205
- /** 注入到 `<head>` 末尾(**必须在使用者样式之后**,否则同特异性被覆盖 —— E254)。 */
205
+ /** 注入到 `<head>` 末尾(**必须在使用者样式之后**,否则同特异性被覆盖)。 */
206
206
  function injectSheet(html, css) {
207
207
  const style = '<style id="agentcss-paper" data-agentcss="paper">' + css + '</style>';
208
208
  if (/<\/head\s*>/i.test(html)) return html.replace(/<\/head\s*>/i, style + '</head>');
@@ -256,7 +256,7 @@ function exportPdf(o) {
256
256
  if (!Number.isInteger(b) || b < 1) {
257
257
  /* ⚠️ 用 String() 而不是 JSON.stringify:`JSON.stringify(NaN)` 是 `"null"`,
258
258
  于是 `--max-pages=abc` 会报「收到 null」—— 使用者据这句话会去查一个
259
- 根本不存在的 null(实测踩过,见 E264)。 */
259
+ 根本不存在的 null(实测踩过)。 */
260
260
  return { ok: false, code: 'usage',
261
261
  message: '--max-pages 必须是 ≥1 的整数,收到 ' + String(o.budget) };
262
262
  }
@@ -0,0 +1,86 @@
1
+ {
2
+ "schemaVersion": "1.0.0",
3
+ "$comment": [
4
+ "版式的「必有件」—— 只收**能从标记本身判定**的那几条。",
5
+ "每一条都对应 04-recipes/report/README.md 里「三种版式的取舍」表中的一格;",
6
+ "那张表是这套系统存在的理由:同一份内容换一个阅读目的,该留什么、该放弃什么。",
7
+ "",
8
+ "🔴 为什么有的写在 requires、有的写在 unchecked:",
9
+ " requires = 有标记可查(少一个类就是确定的缺件),因此可以直接判红;",
10
+ " unchecked = 说得清但判不了(「方法要写明」是内容判断),因此**只打印、不判过**。",
11
+ " 把后者塞进 requires 只能靠关键词匹配,那会造出一种「看起来在检查」的假绿 ——",
12
+ " 这套库宁可写「这条没查」,也不写「查过了」。"
13
+ ],
14
+ "recipes": {
15
+ "report/finance": {
16
+ "label": "report · 金融研究版",
17
+ "root": ".rep.rep--finance",
18
+ "css": "04-recipes/report/report.css",
19
+ "doc": "04-recipes/report/README.md",
20
+ "why": "给要快速比数字的人看:行长最短、字号小一档,靠关键数字条先给结论。",
21
+ "requires": [
22
+ {
23
+ "selector": ".rep__keyline",
24
+ "label": "关键数字条",
25
+ "why": "这一版的读者先看数、后看论证;没有数字条,标题下面就是一大段文字,等于放弃了它唯一的长处。"
26
+ },
27
+ {
28
+ "selector": ".rep__risk",
29
+ "label": "风险披露",
30
+ "why": "金融口径的结论必须带约束条件。少了它,页面读起来像建议,而不是分析。"
31
+ }
32
+ ],
33
+ "unchecked": [
34
+ {
35
+ "label": "长论证不超过两屏",
36
+ "why": "篇幅是内容判断,没有可查的标记。超过两屏的推理在这一版里会被跳过 —— 那是作者该拆的,不是工具能判的。"
37
+ }
38
+ ]
39
+ },
40
+ "report/technical": {
41
+ "label": "report · 科研技术版",
42
+ "root": ".rep.rep--technical",
43
+ "css": "04-recipes/report/report.css",
44
+ "doc": "04-recipes/report/README.md",
45
+ "why": "给要逐条核对的人看:行长最窄、行距最松,图与表自动编号。",
46
+ "requires": [
47
+ {
48
+ "selector": ".rep__source",
49
+ "label": "来源脚注",
50
+ "why": "这一版的读者会顺着结论回查原始材料;没有来源,逐条核对就无从开始。"
51
+ }
52
+ ],
53
+ "unchecked": [
54
+ {
55
+ "label": "方法写明",
56
+ "why": "「写了方法」没有可查的标记:它可能是一节、也可能是一段。判它需要读懂内容,工具不假装能判。"
57
+ }
58
+ ]
59
+ },
60
+ "report/magazine": {
61
+ "label": "report · 杂志编辑版",
62
+ "root": ".rep.rep--magazine",
63
+ "css": "04-recipes/report/report.css",
64
+ "doc": "04-recipes/report/README.md",
65
+ "why": "给通读一遍的人看:行长最宽、不编号,靠版面节奏而不是编号导航。",
66
+ "requires": [
67
+ {
68
+ "selector": ".rep__pull",
69
+ "label": "引语块",
70
+ "why": "这一版靠「停顿」制造节奏;没有引语块,长文会变成一整片同样密度的灰。"
71
+ },
72
+ {
73
+ "selector": ".rep__fig--wide",
74
+ "label": "出血宽图",
75
+ "why": "宽图是这一版唯一的视觉重音。全篇只有正文列宽的图,读起来和别的版式没有区别。"
76
+ }
77
+ ],
78
+ "unchecked": [
79
+ {
80
+ "label": "审计级追溯",
81
+ "why": "这一版**有意放弃**了图/表编号(没有编号就引用不了「第几图」)。它不是缺件,是取舍;不作判据。"
82
+ }
83
+ ]
84
+ }
85
+ }
86
+ }
package/ai/scaffold.js ADDED
@@ -0,0 +1,195 @@
1
+ /*
2
+ * scaffold.js — 三个「最小壳」:正确的骨架,不含内容
3
+ *
4
+ * ============================================================================
5
+ * 🔴 为什么给壳,而不是给模板引擎
6
+ * ----------------------------------------------------------------------------
7
+ * 这一层最容易做歪的方向是"喂一段内容,吐一页报告" —— 那需要解析输入、
8
+ * 决定结构、再选版式,最后产出的东西**没法被断言**(对不对取决于读的人)。
9
+ *
10
+ * 这里只做一件事:把**前置条件**给对。漏一条 `<link>`、少一层 `.rep__grid`、
11
+ * 把文章标题写成 h3,页面不会报错,只会静默地长得不对 —— 而这些都是
12
+ * "知道就不会错、不知道就一定会错"的东西。
13
+ *
14
+ * ⇒ 壳只保证三件事:
15
+ * ① 引对了样式表(少一个 = 那部分完全没样式)
16
+ * ② 骨架层级对(.rep > header > .rep__grid > .rep__body/.rep__aside)
17
+ * ③ 每一版**必有件**的位置留出来了(ai/recipes.json 里那几条)
18
+ *
19
+ * ⚠️ 三个壳都必须能通过 `ai check --html --recipe=<自己>`。
20
+ * 这不是口号:05-audit/html-check-gate.js 每次都会把三个壳跑一遍,
21
+ * 壳和判据一旦对不上就红。壳里少写一个必有件,门禁当场抓到。
22
+ */
23
+ 'use strict';
24
+
25
+ var fs = require('fs');
26
+ var path = require('path');
27
+
28
+ var AI = __dirname;
29
+
30
+ /** 壳里出现的 CSS 清单(相对库根)。只列真正用到的,不多引。 */
31
+ var SHEETS = [
32
+ '01-tokens/tokens.css',
33
+ '01-tokens/typography.css',
34
+ '01-tokens/page.css',
35
+ '04-recipes/table/table.css',
36
+ '04-recipes/report/report.css'
37
+ ];
38
+
39
+ function head(title, cssPrefix) {
40
+ var p = String(cssPrefix || 'node_modules/agentcss').replace(/\/+$/, '');
41
+ var links = SHEETS.map(function (s) {
42
+ return '<link rel="stylesheet" href="' + p + '/' + s + '">';
43
+ }).join('\n');
44
+ return '<!doctype html>\n'
45
+ + '<html lang="zh-CN">\n'
46
+ + '<head>\n'
47
+ + '<meta charset="utf-8">\n'
48
+ + '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
49
+ + '<title>' + title + '</title>\n'
50
+ + links + '\n'
51
+ + '</head>\n';
52
+ }
53
+
54
+ /* 表格:三个壳共用。caption + th[scope] 是硬要求,壳里就写对,
55
+ 免得使用者照着抄的时候把这两条一起漏掉。 */
56
+ function table() {
57
+ return ' <div class="table">\n'
58
+ + ' <table>\n'
59
+ + ' <caption>表 1 · 三组对比(写"这张表比的是什么",不是"这是数据")</caption>\n'
60
+ + ' <thead>\n'
61
+ + ' <tr><th scope="col">项目</th><th scope="col">A 组</th>'
62
+ + '<th scope="col">B 组</th><th scope="col">C 组</th></tr>\n'
63
+ + ' </thead>\n'
64
+ + ' <tbody>\n'
65
+ + ' <tr><th scope="row">指标一</th><td>—</td><td>—</td><td>—</td></tr>\n'
66
+ + ' <tr><th scope="row">指标二</th><td>—</td><td>—</td><td>—</td></tr>\n'
67
+ + ' </tbody>\n'
68
+ + ' </table>\n'
69
+ + ' </div>\n';
70
+ }
71
+
72
+ function figure(wide) {
73
+ return ' <figure class="rep__fig' + (wide ? ' rep__fig--wide' : '') + '">\n'
74
+ + ' <!-- 放图:09-assets/scientific-plot 的容器 id,或直接 <img src alt> -->\n'
75
+ + ' <div id="chart"></div>\n'
76
+ + ' <figcaption class="rep__cap">图 1 · 图说(说清"看什么",不是"这是什么")</figcaption>\n'
77
+ + ' </figure>\n';
78
+ }
79
+
80
+ function section(title, body) {
81
+ return ' <section class="rep__sec">\n'
82
+ + ' <h3>' + title + '</h3>\n'
83
+ + body
84
+ + ' </section>\n';
85
+ }
86
+
87
+ /* 三种版式各自的那一段。顺序刻意与 README 的取舍表一致。 */
88
+ var VARIANTS = {
89
+ 'report/finance': {
90
+ label: 'report · 金融研究版',
91
+ rootClass: 'rep rep--finance',
92
+ title: '标题:把结论写在标题里',
93
+ kicker: '行业跟踪 · 金融研究版',
94
+ standfirst: '导语:先说结论与它成立的条件,再说依据。读者读完这一段就该知道要不要往下看。',
95
+ before: ' <!-- 关键数字条:这一版的读者先看数、后看论证(ai/recipes.json: rep__keyline) -->\n'
96
+ + ' <div class="rep__keyline">\n'
97
+ + ' <div><b>读数一</b><span>—</span></div>\n'
98
+ + ' <div><b>读数二</b><span>—</span></div>\n'
99
+ + ' <div><b>读数三</b><span>—</span></div>\n'
100
+ + ' </div>\n',
101
+ body: '',
102
+ after: ' <!-- 风险披露:这一版必须有(ai/recipes.json: rep__risk) -->\n'
103
+ + ' <p class="rep__risk">风险披露:把结论的适用条件、数据口径与不确定性写在这里。'
104
+ + '没有这一段的金融结论读起来像建议,而不是分析。</p>\n',
105
+ aside: '次要读数放这里。放不下的大图不要塞边注 —— 边注只有 14em。'
106
+ },
107
+ 'report/technical': {
108
+ label: 'report · 科研技术版',
109
+ rootClass: 'rep rep--technical',
110
+ title: '标题:说清对象与方法',
111
+ kicker: '实验报告 · 科研技术版',
112
+ standfirst: '导语:一句话说清做了什么、得到什么、结论的置信边界在哪。',
113
+ before: '',
114
+ body: '',
115
+ after: ' <!-- 来源脚注:这一版必须有(ai/recipes.json: rep__source) -->\n'
116
+ + ' <p class="rep__source">来源:数据出处、采集时间与口径。逐条核对的人会顺着这里回查原始材料。</p>\n',
117
+ aside: '术语与口径写这里。这一版的读者会逐条核对,边注用来放"不影响主线但需要说明"的东西。'
118
+ },
119
+ 'report/magazine': {
120
+ label: 'report · 杂志编辑版',
121
+ rootClass: 'rep rep--magazine',
122
+ title: '标题:用一个具体的画面开头',
123
+ kicker: '长报道 · 杂志编辑版',
124
+ standfirst: '导语:把读者带进场景,而不是先给结论。这一版是通读的,不是查数的。',
125
+ before: ' <!-- 引语块:这一版靠停顿制造节奏(ai/recipes.json: rep__pull) -->\n'
126
+ + ' <blockquote class="rep__pull">一句能被单独引用的话。它应该是全文的转折点,不是摘要。</blockquote>\n',
127
+ body: '',
128
+ after: '',
129
+ aside: '短提醒放这里。这一版没有编号,边注是唯一的"旁白"通道。'
130
+ }
131
+ };
132
+
133
+ /**
134
+ * @param {string} id 版式名(见 ai/recipes.json)
135
+ * @param {object} [opts] `{cssPrefix}` —— 样式表前缀,默认 `node_modules/agentcss`
136
+ * @returns {{ok:true, html:string, id:string, requires:string[]}|{ok:false, message:string}}
137
+ */
138
+ function render(id, opts) {
139
+ opts = opts || {};
140
+ var v = VARIANTS[id];
141
+ if (!v) {
142
+ return { ok: false, message: '未知版式:' + id + '(可用:' + Object.keys(VARIANTS).join(' / ') + ')' };
143
+ }
144
+ var wide = id === 'report/magazine';
145
+
146
+ var body = ' <section class="rep__sec">\n'
147
+ + ' <h3>第一节:先给判断,再给依据</h3>\n'
148
+ + ' <p>正文。这一段的每句话都应该能被上一段的数字或下一段的图支撑;'
149
+ + '撑不住的删掉。</p>\n'
150
+ + figure(wide)
151
+ + table()
152
+ + ' </section>\n'
153
+ + section('第二节:把限制写清楚', ' <p>适用的边界、数据口径、以及"这一版有意不做什么"。</p>\n');
154
+
155
+ var html = head(v.title, opts.cssPrefix)
156
+ + '<body class="page">\n'
157
+ + '<main class="wrap">\n'
158
+ + ' <!-- 由 `agentcss ai scaffold` 生成:只给壳,不给内容。\n'
159
+ + ' 写完跑:agentcss ai check 本文件 --recipe=' + id + ' -->\n'
160
+ + ' <article class="' + v.rootClass + '">\n'
161
+ + ' <header>\n'
162
+ + ' <p class="rep__kicker">' + v.kicker + '</p>\n'
163
+ + ' <h2 class="rep__title">' + v.title + '</h2>\n'
164
+ + ' <p class="rep__standfirst">' + v.standfirst + '</p>\n'
165
+ + ' <ul class="rep__meta"><li>作者</li><li>日期</li><li>版本 v1</li></ul>\n'
166
+ + ' </header>\n'
167
+ + v.before
168
+ + ' <div class="rep__grid">\n'
169
+ + ' <div class="rep__body">\n'
170
+ + body
171
+ + ' </div>\n'
172
+ + ' <aside class="rep__aside">' + v.aside + '</aside>\n'
173
+ + ' </div>\n'
174
+ + v.after
175
+ + ' </article>\n'
176
+ + '</main>\n'
177
+ + '</body>\n'
178
+ + '</html>\n';
179
+
180
+ var requires = [];
181
+ try {
182
+ var all = JSON.parse(fs.readFileSync(path.join(AI, 'recipes.json'), 'utf8'));
183
+ requires = ((all.recipes[id] || {}).requires || []).map(function (r) { return r.selector; });
184
+ } catch (e) { /* 读不到就不报;门禁会盯 recipes.json */ }
185
+
186
+ return { ok: true, html: html, id: id, requires: requires };
187
+ }
188
+
189
+ function list() {
190
+ return Object.keys(VARIANTS).map(function (k) {
191
+ return { id: k, label: VARIANTS[k].label };
192
+ });
193
+ }
194
+
195
+ module.exports = { render: render, list: list, VARIANTS: VARIANTS, SHEETS: SHEETS };
package/ai/shot.js ADDED
@@ -0,0 +1,345 @@
1
+ /*
2
+ * shot.js — 把页面截成 PNG(给定宽度、可选整页)
3
+ *
4
+ * ============================================================================
5
+ * 🔴 为什么不用 `chrome --screenshot --window-size=390,844`
6
+ * ----------------------------------------------------------------------------
7
+ * 因为它**会撒谎**。Windows 上窗口有最小宽度,`--window-size=390` 拿到的
8
+ * 实际视口是 **500px**(实测 innerWidth=500),而 `--screenshot` 只按 390 裁一刀。
9
+ * 结果是一张"看起来是 390 宽"的图:文字在右边缘被切掉、媒体查询按 500 生效 ——
10
+ * 你据此判断"窄屏没问题",其实从没看过 390。
11
+ *
12
+ * 这类失败最贵的地方在于**它不报错**:图是好的、能打开、只是内容不对。
13
+ * 所以这里走 Chrome 的调试管道(`--remote-debugging-pipe`,零依赖、不开端口),
14
+ * 用 `Emulation.setDeviceMetricsOverride` 把视口**设成**要的那个数,再截。
15
+ *
16
+ * ============================================================================
17
+ * 为什么是管道而不是 `--remote-debugging-port`
18
+ * ----------------------------------------------------------------------------
19
+ * 端口版要处理:端口占用、防火墙弹窗(Windows 首次监听会弹)、启动竞态
20
+ * (等调试端口文件出现的时机)、以及同一个 profile 被复用时的锁。
21
+ * 管道版把这些全绕开:读写走子进程的 fd 3/4,消息以 `\0` 分隔的 JSON。
22
+ * 代价是自己拼十几行帧处理 —— 比处理上面四件事便宜得多。
23
+ *
24
+ * ============================================================================
25
+ * 它**不是**视觉基线
26
+ * ----------------------------------------------------------------------------
27
+ * 基线(逐字节比对)由 `05-audit/shot-baseline.py` 管,那套要考虑字体、
28
+ * 双平台产物与指纹。这里的产物是给人/给 Agent **看一眼**的:
29
+ * 同一台机器、同样参数,两次逐字节一致(门禁验的就是这条);
30
+ * 换一台机器不保证 —— 字体光栅化不一样,别拿它当跨机器判据。
31
+ */
32
+ 'use strict';
33
+
34
+ var fs = require('fs');
35
+ var os = require('os');
36
+ var path = require('path');
37
+ var { spawn } = require('child_process');
38
+ var { pathToFileURL } = require('url');
39
+ var pdf = require('./pdf.js');
40
+
41
+ var DEFAULT_WIDTHS = [1440];
42
+ var DEFAULT_HEIGHT = 900;
43
+ var DEFAULT_WAIT = 800;
44
+ /* 整页截图的高度上限。PNG 面积是 W×H×scale²,超了内存与耗时都会失控;
45
+ 截断**不静默**:结果里带 truncatedAt,人/Agent 能看见。 */
46
+ var MAX_FULL_HEIGHT = 10000;
47
+
48
+ /** 极简 CDP 客户端:一个子进程、两个 fd、以 \0 分隔的 JSON。 */
49
+ function connect(chromePath, profile) {
50
+ var child = spawn(chromePath, [
51
+ '--headless=new',
52
+ '--disable-gpu',
53
+ '--disable-extensions',
54
+ '--no-first-run',
55
+ '--no-default-browser-check',
56
+ /* 滚动条会占掉 15px 视口,且它本身不是页面内容 */
57
+ '--hide-scrollbars',
58
+ '--remote-debugging-pipe',
59
+ '--user-data-dir=' + profile,
60
+ 'about:blank'
61
+ ], {
62
+ /* fd 0/1/2 给普通输出,3 = 命令(Chrome 读),4 = 事件(Chrome 写) */
63
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
64
+ windowsHide: true
65
+ });
66
+
67
+ var buf = Buffer.alloc(0);
68
+ var pending = Object.create(null);
69
+ var waiters = [];
70
+ var listeners = [];
71
+ var nextId = 0;
72
+ var dead = null;
73
+
74
+ function dispatch(msg) {
75
+ if (msg.id !== undefined && pending[msg.id]) {
76
+ var p = pending[msg.id];
77
+ delete pending[msg.id];
78
+ clearTimeout(p.timer);
79
+ if (msg.error) p.reject(new Error(p.method + ':' + (msg.error.message || JSON.stringify(msg.error))));
80
+ else p.resolve(msg.result);
81
+ return;
82
+ }
83
+ if (!msg.method) return;
84
+ for (var L = 0; L < listeners.length; L++) {
85
+ if (listeners[L].method === msg.method) listeners[L].fn(msg.params, msg.sessionId);
86
+ }
87
+ for (var i = waiters.length - 1; i >= 0; i--) {
88
+ var w = waiters[i];
89
+ if (w.method === msg.method && (!w.sessionId || w.sessionId === msg.sessionId)) {
90
+ waiters.splice(i, 1);
91
+ clearTimeout(w.timer);
92
+ w.resolve(msg.params);
93
+ }
94
+ }
95
+ }
96
+
97
+ child.stdio[4].on('data', function (chunk) {
98
+ buf = Buffer.concat([buf, chunk]);
99
+ var i;
100
+ while ((i = buf.indexOf(0)) >= 0) {
101
+ var raw = buf.subarray(0, i).toString('utf8');
102
+ buf = buf.subarray(i + 1);
103
+ var msg = null;
104
+ try { msg = JSON.parse(raw); } catch (e) { continue; }
105
+ dispatch(msg);
106
+ }
107
+ });
108
+ child.stdio[1].on('data', function () { /* 吞掉:Chrome 的正常输出没有信息量 */ });
109
+ child.stdio[2].on('data', function () { /* 同上;错误通过 CDP 的 error 字段回来 */ });
110
+ child.on('error', function (e) { dead = e; });
111
+ child.on('exit', function (code) {
112
+ dead = dead || new Error('Chrome 提前退出(退出码 ' + code + ')');
113
+ Object.keys(pending).forEach(function (k) {
114
+ clearTimeout(pending[k].timer);
115
+ pending[k].reject(dead);
116
+ });
117
+ pending = Object.create(null);
118
+ waiters.splice(0).forEach(function (w) { clearTimeout(w.timer); w.reject(dead); });
119
+ });
120
+
121
+ function send(method, params, sessionId, timeoutMs) {
122
+ return new Promise(function (resolve, reject) {
123
+ if (dead) return reject(dead);
124
+ var id = ++nextId;
125
+ var timer = setTimeout(function () {
126
+ delete pending[id];
127
+ reject(new Error(method + ' 超时(' + (timeoutMs || 15000) + 'ms 没有回应)'));
128
+ }, timeoutMs || 15000);
129
+ pending[id] = { resolve: resolve, reject: reject, timer: timer, method: method };
130
+ child.stdio[3].write(JSON.stringify({
131
+ id: id, method: method, params: params || {}, sessionId: sessionId
132
+ }) + '\0');
133
+ });
134
+ }
135
+
136
+ function waitEvent(method, sessionId, timeoutMs) {
137
+ return new Promise(function (resolve, reject) {
138
+ var timer = setTimeout(function () {
139
+ var at = waiters.findIndex(function (w) { return w.method === method && w.sessionId === sessionId; });
140
+ if (at >= 0) waiters.splice(at, 1);
141
+ reject(new Error('等 ' + method + ' 超时(' + (timeoutMs || 15000) + 'ms)'));
142
+ }, timeoutMs || 15000);
143
+ waiters.push({ method: method, sessionId: sessionId, resolve: resolve, reject: reject, timer: timer });
144
+ });
145
+ }
146
+
147
+ return {
148
+ send: send, waitEvent: waitEvent,
149
+ /* 常驻监听:异常/控制台消息可能在任意时刻到达,用一次性 waiter 会漏掉第二条起的所有 */
150
+ on: function (method, fn) { listeners.push({ method: method, fn: fn }); },
151
+ close: function () { try { child.kill(); } catch (e) { /* 已退出 */ } },
152
+ child: child
153
+ };
154
+ }
155
+
156
+ /** 页面在某个宽度下的量:溢出多少、谁溢出、有多高。 */
157
+ var PROBE_JS = [
158
+ '(function(){',
159
+ ' var d = document.documentElement;',
160
+ ' var vw = d.clientWidth;',
161
+ ' var over = [];',
162
+ ' var all = document.querySelectorAll("body *");',
163
+ ' for (var i = 0; i < all.length && over.length < 40; i++) {',
164
+ ' var e = all[i], r = e.getBoundingClientRect();',
165
+ ' if (r.width === 0 && r.height === 0) continue;',
166
+ ' if (r.right > vw + 1 || r.left < -1) {',
167
+ ' var cls = typeof e.className === "string" ? e.className : "";',
168
+ ' over.push(e.tagName.toLowerCase() + (cls ? "." + cls.trim().split(/\\s+/).join(".") : "")',
169
+ ' + " @" + Math.round(r.right));',
170
+ ' }',
171
+ ' }',
172
+ ' return JSON.stringify({',
173
+ ' viewport: vw, scrollWidth: d.scrollWidth, scrollHeight: d.scrollHeight,',
174
+ ' overflow: over.slice(0, 6), overflowCount: over.length',
175
+ ' });',
176
+ '})()'
177
+ ].join('\n');
178
+
179
+ function slugWidth(w) { return String(w); }
180
+
181
+ /**
182
+ * @param {object} o
183
+ * @param {string} o.src 本地 HTML 路径或 http(s) URL
184
+ * @param {string} o.out 输出文件;多宽度时须含 `{width}`
185
+ * @param {number[]} [o.widths] 视口宽度,默认 [1440]
186
+ * @param {number} [o.height] 视口高度,默认 900
187
+ * @param {boolean} [o.full] 整页(否则只截首屏)
188
+ * @param {number} [o.scale] 设备像素比,默认 1
189
+ * @param {number} [o.wait] 加载后额外等待(毫秒),默认 800
190
+ * @param {string} [o.chrome]
191
+ * @param {number} [o.timeoutMs]
192
+ * @returns {Promise<object>}
193
+ */
194
+ async function shoot(o) {
195
+ o = o || {};
196
+ var t0 = Date.now();
197
+ if (!o.src) return { ok: false, code: 'usage', message: '缺少 src' };
198
+ if (!o.out) return { ok: false, code: 'usage', message: '缺少 out' };
199
+
200
+ var widths = o.widths && o.widths.length ? o.widths : DEFAULT_WIDTHS;
201
+ for (var i = 0; i < widths.length; i++) {
202
+ if (!Number.isInteger(widths[i]) || widths[i] < 200 || widths[i] > 4000) {
203
+ return { ok: false, code: 'usage',
204
+ message: '宽度必须是 200–4000 的整数,收到 ' + JSON.stringify(widths[i]) };
205
+ }
206
+ }
207
+ var height = o.height || DEFAULT_HEIGHT;
208
+ if (!Number.isInteger(height) || height < 200 || height > 4000) {
209
+ return { ok: false, code: 'usage',
210
+ message: '高度必须是 200–4000 的整数,收到 ' + JSON.stringify(o.height) };
211
+ }
212
+ var scale = o.scale === undefined ? 1 : Number(o.scale);
213
+ if (!(scale > 0 && scale <= 3)) {
214
+ return { ok: false, code: 'usage', message: '--scale 必须在 (0, 3],收到 ' + JSON.stringify(o.scale) };
215
+ }
216
+
217
+ var remote = /^https?:\/\//i.test(o.src);
218
+ if (!remote) {
219
+ var srcAbs = path.resolve(o.src);
220
+ if (!fs.existsSync(srcAbs)) return { ok: false, code: 'usage', message: '找不到输入文件:' + srcAbs };
221
+ o.src = srcAbs;
222
+ }
223
+
224
+ var outTpl = String(o.out);
225
+ if (widths.length > 1 && outTpl.indexOf('{width}') < 0) {
226
+ return { ok: false, code: 'usage',
227
+ message: '截多个宽度时 --out 必须含 {width}(例:shot-{width}.png),'
228
+ + '否则 ' + widths.length + ' 张图会写进同一个文件,最后只剩一张' };
229
+ }
230
+
231
+ var chrome = pdf.resolveChrome(o.chrome);
232
+ if (chrome.error) return { ok: false, code: 'no-chrome', message: chrome.error };
233
+
234
+ var profile = fs.mkdtempSync(path.join(os.tmpdir(), 'agentcss-shot-'));
235
+ var cdp = connect(chrome.path, profile);
236
+ var shots = [];
237
+ var consoleErrors = [];
238
+ var budget = o.timeoutMs || 90000;
239
+ var deadline = Date.now() + budget;
240
+
241
+ try {
242
+ var target = await cdp.send('Target.createTarget', { url: 'about:blank' });
243
+ var attached = await cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: true });
244
+ var sid = attached.sessionId;
245
+
246
+ await cdp.send('Page.enable', {}, sid);
247
+ await cdp.send('Runtime.enable', {}, sid);
248
+ /* JS 异常要收集:截出一张"布局正确但内容没渲染"的图是最坏的产物,
249
+ 而它通常就是一行 TypeError 造成的。 */
250
+ cdp.on('Runtime.exceptionThrown', function (p) {
251
+ if (consoleErrors.length >= 20) return;
252
+ var d = (p && p.exceptionDetails) || {};
253
+ var txt = (d.exception && d.exception.description) || d.text || '未捕获异常';
254
+ consoleErrors.push(String(txt).split('\n')[0]);
255
+ });
256
+ cdp.on('Runtime.consoleAPICalled', function (p) {
257
+ if (consoleErrors.length >= 20) return;
258
+ if (!p || p.type !== 'error') return;
259
+ var parts = (p.args || []).map(function (a) {
260
+ return a.value === undefined ? (a.description || a.type) : String(a.value);
261
+ });
262
+ consoleErrors.push('console.error: ' + parts.join(' '));
263
+ });
264
+
265
+ for (var w = 0; w < widths.length; w++) {
266
+ if (Date.now() > deadline) {
267
+ return { ok: false, code: 'timeout', message: '超出时间预算 ' + budget + 'ms', chrome: chrome.path, shots: shots };
268
+ }
269
+ var width = widths[w];
270
+ var w0 = Date.now();
271
+
272
+ await cdp.send('Emulation.setDeviceMetricsOverride', {
273
+ width: width, height: height, deviceScaleFactor: scale, mobile: false
274
+ }, sid);
275
+
276
+ var loaded = cdp.waitEvent('Page.loadEventFired', sid, 30000);
277
+ var nav = await cdp.send('Page.navigate', { url: remote ? o.src : pathToFileURL(o.src).href }, sid);
278
+ if (nav && nav.errorText) {
279
+ return { ok: false, code: 'render',
280
+ message: '页面加载失败(' + nav.errorText + '):' + o.src, chrome: chrome.path, shots: shots };
281
+ }
282
+ await loaded;
283
+ if (o.wait) await new Promise(function (r) { setTimeout(r, o.wait); });
284
+
285
+ var probe = await cdp.send('Runtime.evaluate', {
286
+ expression: PROBE_JS, returnByValue: true
287
+ }, sid);
288
+ var info = {};
289
+ try { info = JSON.parse(probe.result.value); } catch (e) { info = {}; }
290
+
291
+ var clipH = height;
292
+ var truncated = null;
293
+ if (o.full) {
294
+ var want = info.scrollHeight || height;
295
+ clipH = Math.min(want, MAX_FULL_HEIGHT);
296
+ if (want > MAX_FULL_HEIGHT) truncated = want;
297
+ }
298
+
299
+ var shot = await cdp.send('Page.captureScreenshot', {
300
+ format: 'png',
301
+ captureBeyondViewport: !!o.full,
302
+ clip: { x: 0, y: 0, width: width, height: clipH, scale: 1 }
303
+ }, sid);
304
+ var png = Buffer.from(shot.data, 'base64');
305
+ if (png.length < 8 || png.readUInt32BE(0) !== 0x89504e47) {
306
+ return { ok: false, code: 'render', message: '产出的不是 PNG(' + png.length + ' 字节)',
307
+ chrome: chrome.path, shots: shots };
308
+ }
309
+
310
+ var outFile = path.resolve(outTpl.replace(/\{width\}/g, slugWidth(width)));
311
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
312
+ fs.writeFileSync(outFile, png);
313
+
314
+ shots.push({
315
+ width: width, out: outFile, bytes: png.length, ms: Date.now() - w0,
316
+ png: { width: png.readUInt32BE(16), height: png.readUInt32BE(20) },
317
+ full: !!o.full,
318
+ truncatedAt: truncated,
319
+ viewport: info.viewport === undefined ? null : info.viewport,
320
+ scrollWidth: info.scrollWidth === undefined ? null : info.scrollWidth,
321
+ scrollHeight: info.scrollHeight === undefined ? null : info.scrollHeight,
322
+ overflow: info.overflow || [],
323
+ overflowCount: info.overflowCount || 0
324
+ });
325
+ }
326
+
327
+ return {
328
+ ok: true, code: null, message: null, chrome: chrome.path,
329
+ scale: scale, wait: o.wait === undefined ? DEFAULT_WAIT : o.wait,
330
+ shots: shots, consoleErrors: consoleErrors, ms: Date.now() - t0
331
+ };
332
+ } catch (e) {
333
+ return { ok: false, code: 'render', message: String(e && e.message || e), chrome: chrome.path, shots: shots };
334
+ } finally {
335
+ cdp.close();
336
+ /* 清理必须**在进程退出前**做完:CLI 随后就 exit,挂 setTimeout 等于每截一张图
337
+ 就在系统临时目录里留一个 Chrome profile(pdf.js 那边实测漏过 6 个)。
338
+ 先等一下再删:Windows 上子进程刚 kill 时还握着文件句柄。 */
339
+ await new Promise(function (r) { setTimeout(r, 120); });
340
+ try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 }); }
341
+ catch (e) { /* 偶发 EBUSY,交给系统清理 */ }
342
+ }
343
+ }
344
+
345
+ module.exports = { shoot: shoot, DEFAULT_WIDTHS: DEFAULT_WIDTHS, DEFAULT_HEIGHT: DEFAULT_HEIGHT, DEFAULT_WAIT: DEFAULT_WAIT };