agentcss 0.26.0 → 0.27.1
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.
- package/01-tokens/README.md +1 -1
- package/01-tokens/page.css +11 -0
- package/01-tokens/responsive-demo.html +1 -1
- package/01-tokens/tokens.core.css +1 -1
- package/01-tokens/tokens.css +2 -2
- package/02-primitives/date-range/date-range.js +1 -1
- package/02-primitives/timeline/demo.html +1 -1
- package/03-patterns/drawer/demo.html +1 -1
- package/03-patterns/overlay/overlay.css +1 -1
- package/03-patterns/states/states.css +1 -1
- package/03-patterns/tabs/tabs.css +9 -0
- package/04-recipes/app-shell/README.md +1 -1
- package/04-recipes/app-shell/demo.html +1 -1
- package/04-recipes/longform/content.html +6 -6
- package/04-recipes/report/demo.html +2 -2
- package/04-recipes/table/README.md +1 -1
- package/04-recipes/table/demo.html +1 -1
- package/09-assets/bar/bar.css +1 -1
- package/09-assets/model-viewer/model-viewer.js +1 -1
- package/AGENTS.md +4 -2
- package/CHANGELOG.md +126 -5
- package/CONTRIBUTING.md +44 -3
- package/README.md +7 -4
- package/README.zh-CN.md +18 -12
- package/START-HERE.md +12 -5
- package/ai/START-HERE.md +41 -7
- package/ai/cdp.js +152 -0
- package/ai/check-html.js +658 -0
- package/ai/cli.js +578 -70
- package/ai/components.json +1 -1
- package/ai/pdf-renderer.js +177 -0
- package/ai/pdf.js +125 -80
- package/ai/recipes.json +86 -0
- package/ai/scaffold.js +195 -0
- package/ai/shot.js +237 -0
- package/ai/validate.js +65 -6
- package/dist/01-tokens/page.css +1 -1
- package/dist/03-patterns/tabs/tabs.css +1 -1
- package/dist/COSTS.md +12 -12
- package/docs/ACCESSIBILITY.md +2 -2
- package/docs/ARCHITECTURE.md +116 -0
- package/docs/LAYER-BASELINE.md +2 -2
- package/docs/PRINT.md +41 -13
- package/docs/RELEASE-PROCESS.md +52 -2
- package/docs/STABILITY.md +1 -1
- package/docs/VERIFY-RELEASE.md +17 -0
- package/index.html +3 -3
- package/llms-full.txt +19 -4
- package/llms.txt +29 -6
- package/package.json +2 -1
package/ai/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "1.0.0",
|
|
3
3
|
"generatedBy": "05-audit/gen-ai-contract.py",
|
|
4
|
-
"libraryVersion": "0.
|
|
4
|
+
"libraryVersion": "0.27.1",
|
|
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 下的专属门禁 ⇒ 还没被机器验证过,不要用",
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* pdf-renderer.js — exportPdf 的异步子进程;不是公共 CLI。
|
|
4
|
+
* 从 stdin 接收一份 JSON 配置,从 stdout 返回一份 JSON 结论。
|
|
5
|
+
* 浏览器只写工作目录里的暂存 PDF,绝不接触调用方的最终输出路径。
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
var fs = require('fs');
|
|
10
|
+
var cdp = require('./cdp.js');
|
|
11
|
+
|
|
12
|
+
var DEFAULT_WAIT = 800;
|
|
13
|
+
var READY_INTERVAL = 50;
|
|
14
|
+
|
|
15
|
+
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
|
16
|
+
function timeoutError(message) {
|
|
17
|
+
var e = new Error(message);
|
|
18
|
+
e.code = 'timeout';
|
|
19
|
+
return e;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* 在 print 媒体下等待一个可见元素。元素只是存在但 display:none 不算就绪。 */
|
|
23
|
+
function readyExpression(selector) {
|
|
24
|
+
return '(function(){try{var e=document.querySelector(' + JSON.stringify(selector) + ');'
|
|
25
|
+
+ 'if(!e)return {ok:true,ready:false};var s=getComputedStyle(e);'
|
|
26
|
+
+ 'return {ok:true,ready:s.display!=="none"&&s.visibility!=="hidden"&&e.getClientRects().length>0};'
|
|
27
|
+
+ '}catch(e){return {ok:false,error:String(e)}}})()';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function render(o) {
|
|
31
|
+
var started = Date.now();
|
|
32
|
+
var budget = o.timeoutMs || 60000;
|
|
33
|
+
var deadline = started + budget;
|
|
34
|
+
function left() { return Math.max(1, deadline - Date.now()); }
|
|
35
|
+
function checkTime(what) {
|
|
36
|
+
if (Date.now() >= deadline) throw timeoutError(what + '超出时间预算 ' + budget + 'ms');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
var browser = cdp.connect(o.chrome, o.profile);
|
|
40
|
+
var jsErrors = [];
|
|
41
|
+
var timedOut = false;
|
|
42
|
+
var timer = setTimeout(function () {
|
|
43
|
+
timedOut = true;
|
|
44
|
+
browser.close();
|
|
45
|
+
}, budget);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
var target = await browser.send('Target.createTarget', { url: 'about:blank' }, null, left());
|
|
49
|
+
var attached = await browser.send('Target.attachToTarget', {
|
|
50
|
+
targetId: target.targetId, flatten: true
|
|
51
|
+
}, null, left());
|
|
52
|
+
var sid = attached.sessionId;
|
|
53
|
+
await browser.send('Page.enable', {}, sid, left());
|
|
54
|
+
await browser.send('Runtime.enable', {}, sid, left());
|
|
55
|
+
browser.on('Runtime.exceptionThrown', function (p) {
|
|
56
|
+
if (jsErrors.length >= 20) return;
|
|
57
|
+
var d = (p && p.exceptionDetails) || {};
|
|
58
|
+
jsErrors.push(String((d.exception && d.exception.description) || d.text || '未捕获异常').split('\n')[0]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
var loaded = browser.waitEvent('Page.loadEventFired', sid, left());
|
|
62
|
+
var nav = await browser.send('Page.navigate', { url: o.target }, sid, left());
|
|
63
|
+
if (nav && nav.errorText) {
|
|
64
|
+
var loadError = new Error('页面加载失败(' + nav.errorText + '):' + o.src);
|
|
65
|
+
loadError.code = 'render';
|
|
66
|
+
throw loadError;
|
|
67
|
+
}
|
|
68
|
+
await loaded;
|
|
69
|
+
checkTime('页面加载');
|
|
70
|
+
|
|
71
|
+
/* 先固定打印环境,再判断 ready:print-only 元素也能作为就绪标志。 */
|
|
72
|
+
await browser.send('Emulation.setEmulatedMedia', {
|
|
73
|
+
media: 'print',
|
|
74
|
+
features: [{ name: 'prefers-reduced-motion', value: 'reduce' }]
|
|
75
|
+
}, sid, left());
|
|
76
|
+
var fonts = await browser.send('Runtime.evaluate', {
|
|
77
|
+
expression: 'document.fonts&&document.fonts.ready?document.fonts.ready.then(function(){return true}):true',
|
|
78
|
+
awaitPromise: true, returnByValue: true
|
|
79
|
+
}, sid, left());
|
|
80
|
+
if (fonts.exceptionDetails) {
|
|
81
|
+
throw new Error('等待字体失败:' + (fonts.exceptionDetails.text || 'document.fonts.ready rejected'));
|
|
82
|
+
}
|
|
83
|
+
checkTime('字体加载');
|
|
84
|
+
|
|
85
|
+
if (o.readySelector !== null && o.readySelector !== undefined) {
|
|
86
|
+
for (;;) {
|
|
87
|
+
checkTime('等待 ready-selector ' + JSON.stringify(o.readySelector));
|
|
88
|
+
var ready = await browser.send('Runtime.evaluate', {
|
|
89
|
+
expression: readyExpression(o.readySelector), returnByValue: true
|
|
90
|
+
}, sid, left());
|
|
91
|
+
if (!ready.result || !ready.result.value || !ready.result.value.ok) {
|
|
92
|
+
var invalid = new Error('--ready-selector 不是合法 CSS 选择器:' + o.readySelector);
|
|
93
|
+
invalid.code = 'usage';
|
|
94
|
+
throw invalid;
|
|
95
|
+
}
|
|
96
|
+
if (ready.result.value.ready) break;
|
|
97
|
+
await sleep(Math.min(READY_INTERVAL, left()));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
var waitMs = o.waitMs === undefined ? DEFAULT_WAIT : o.waitMs;
|
|
102
|
+
if (waitMs) {
|
|
103
|
+
var room = left();
|
|
104
|
+
/* 剩余预算装不下这次等待时,先耗尽它再报 timeout —— 这样报错里的
|
|
105
|
+
"等的是什么 / 还剩多少"和实际发生的事一致,而不是只睡一半就下结论。 */
|
|
106
|
+
if (waitMs > room) {
|
|
107
|
+
await sleep(room);
|
|
108
|
+
throw timeoutError('额外等待 ' + waitMs + 'ms 超出剩余预算 ' + room + 'ms');
|
|
109
|
+
}
|
|
110
|
+
await sleep(waitMs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* 有限动画落到终态,无限动画取消;同时关掉打印过渡,避免首帧 opacity:0。 */
|
|
114
|
+
var animations = await browser.send('Runtime.evaluate', {
|
|
115
|
+
expression: '(function(){var n=0;document.getAnimations().forEach(function(a){try{'
|
|
116
|
+
+ 'var t=a.effect&&a.effect.getComputedTiming?a.effect.getComputedTiming():null;'
|
|
117
|
+
+ 'if(t&&isFinite(t.iterations)){a.finish()}else{a.cancel()}n++}catch(e){try{a.cancel()}catch(_){}}});'
|
|
118
|
+
+ 'var s=document.createElement("style");s.setAttribute("data-agentcss-print-motion","");'
|
|
119
|
+
+ 's.textContent="@media print{*,*::before,*::after{transition:none!important}}";'
|
|
120
|
+
+ 'document.head.appendChild(s);return n})()',
|
|
121
|
+
returnByValue: true
|
|
122
|
+
}, sid, left());
|
|
123
|
+
if (animations.exceptionDetails) throw new Error('固定打印动画失败');
|
|
124
|
+
|
|
125
|
+
var printed = await browser.send('Page.printToPDF', {
|
|
126
|
+
printBackground: true,
|
|
127
|
+
preferCSSPageSize: true,
|
|
128
|
+
displayHeaderFooter: false,
|
|
129
|
+
marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0
|
|
130
|
+
}, sid, left());
|
|
131
|
+
checkTime('PDF 生成');
|
|
132
|
+
var buf = Buffer.from(printed.data || '', 'base64');
|
|
133
|
+
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
|
134
|
+
throw new Error('浏览器返回的不是 PDF(' + buf.length + ' 字节)');
|
|
135
|
+
}
|
|
136
|
+
fs.writeFileSync(o.stagedPdf, buf);
|
|
137
|
+
return {
|
|
138
|
+
ok: true, stagedPdf: o.stagedPdf, bytes: buf.length, jsErrors: jsErrors,
|
|
139
|
+
waitMs: waitMs, readySelector: o.readySelector || null,
|
|
140
|
+
animations: animations.result ? animations.result.value : null,
|
|
141
|
+
ms: Date.now() - started
|
|
142
|
+
};
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false, code: timedOut ? 'timeout' : (e.code === 'usage' || e.code === 'timeout' ? e.code : 'render'),
|
|
146
|
+
message: timedOut ? 'PDF 导出超出时间预算 ' + budget + 'ms(页面加载、字体或 ready-selector 未完成)'
|
|
147
|
+
: String(e && e.message || e),
|
|
148
|
+
jsErrors: jsErrors, ms: Date.now() - started,
|
|
149
|
+
stderr: browser.stderr().slice(-800)
|
|
150
|
+
};
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
browser.close();
|
|
154
|
+
/* Windows 上 Chrome 刚退出时仍可能持有 profile 文件句柄。 */
|
|
155
|
+
await sleep(150);
|
|
156
|
+
try { fs.rmSync(o.profile, { recursive: true, force: true, maxRetries: 3 }); }
|
|
157
|
+
catch (e) { /* 父进程还会清理整个暂存目录 */ }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
var input = '';
|
|
162
|
+
process.stdin.setEncoding('utf8');
|
|
163
|
+
process.stdin.on('data', function (s) { input += s; });
|
|
164
|
+
process.stdin.on('end', function () {
|
|
165
|
+
var config;
|
|
166
|
+
try { config = JSON.parse(input); }
|
|
167
|
+
catch (e) {
|
|
168
|
+
process.stdout.write(JSON.stringify({ ok: false, code: 'render', message: '导出子进程收到非法配置' }));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
render(config).then(function (result) {
|
|
172
|
+
process.stdout.write(JSON.stringify(result));
|
|
173
|
+
}).catch(function (e) {
|
|
174
|
+
process.stdout.write(JSON.stringify({ ok: false, code: 'render',
|
|
175
|
+
message: String(e && e.message || e) }));
|
|
176
|
+
});
|
|
177
|
+
});
|
package/ai/pdf.js
CHANGED
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
* 所以它只能由"产出这份文件的人"给 —— 这正是本文件存在的理由。
|
|
17
17
|
*
|
|
18
18
|
* ============================================================================
|
|
19
|
-
* 🔴
|
|
19
|
+
* 🔴 为什么走共享 CDP 管道,而不是 Puppeteer 或 Chrome 一次性命令行
|
|
20
20
|
* ============================================================================
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* 本库保持**零运行时依赖**,使用机器上已装好的 Chrome/Chromium/Edge。
|
|
22
|
+
* `cdp.js` 复用截图已有的调试管道:能等加载、字体和指定元素,再 printToPDF。
|
|
23
|
+
* 一次性 `--print-to-pdf` 无法表达就绪条件,也无法可靠区分新旧产物。
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* 浏览器只写同目录暂存文件;确认本次成功后才原子替换最终 PDF。任何失败都
|
|
26
|
+
* 明确返回错误并保留旧文件,绝不把上一次产物当成本次成功。
|
|
27
27
|
*
|
|
28
28
|
* ============================================================================
|
|
29
29
|
* ⚠️ 一条必须写下来的观测差异
|
|
@@ -202,7 +202,27 @@ function paperSpec(name) {
|
|
|
202
202
|
return PAPERS[k] ? { key: k, ...PAPERS[k] } : null;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
/* 硬杀(Ctrl+C / 断电 / 被任务管理器结束)会留下 `.agentcss-print-*.html`:
|
|
206
|
+
finally 没机会跑。下次导出前顺手清掉**同目录**里超过 6 小时的残留。
|
|
207
|
+
门槛设长是为了不碰另一个正在导出的进程留下的文件(单次导出是秒级)。 */
|
|
208
|
+
const STALE_TMP_MS = 6 * 3600 * 1000;
|
|
209
|
+
|
|
210
|
+
function sweepStaleTemplates(dir) {
|
|
211
|
+
let names;
|
|
212
|
+
try { names = fs.readdirSync(dir); } catch (e) { return 0; }
|
|
213
|
+
var removed = 0;
|
|
214
|
+
const cutoff = Date.now() - STALE_TMP_MS;
|
|
215
|
+
for (const n of names) {
|
|
216
|
+
if (!/^\.agentcss-print-.*\.html$/.test(n)) continue;
|
|
217
|
+
const p = path.join(dir, n);
|
|
218
|
+
try {
|
|
219
|
+
if (fs.statSync(p).mtimeMs < cutoff) { fs.unlinkSync(p); removed += 1; }
|
|
220
|
+
} catch (e) { /* 别处正在用 / 已删 */ }
|
|
221
|
+
}
|
|
222
|
+
return removed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 注入到 `<head>` 末尾(**必须在使用者样式之后**,否则同特异性被覆盖)。 */
|
|
206
226
|
function injectSheet(html, css) {
|
|
207
227
|
const style = '<style id="agentcss-paper" data-agentcss="paper">' + css + '</style>';
|
|
208
228
|
if (/<\/head\s*>/i.test(html)) return html.replace(/<\/head\s*>/i, style + '</head>');
|
|
@@ -230,7 +250,9 @@ function isRemote(src) {
|
|
|
230
250
|
* @param {string} [o.margin] CSS margin 简写(默认 18mm 15mm)
|
|
231
251
|
* @param {number} [o.budget] 页数上限;超了返回 over-budget
|
|
232
252
|
* @param {string} [o.chrome] Chrome 可执行文件
|
|
233
|
-
* @param {number} [o.
|
|
253
|
+
* @param {number} [o.waitMs] 就绪后的额外等待,0–60000(默认 800)
|
|
254
|
+
* @param {string} [o.readySelector] 等待一个打印媒体下可见的 CSS 元素
|
|
255
|
+
* @param {number} [o.timeoutMs] 总预算,1000–600000(默认 60000)
|
|
234
256
|
* @returns {object} `{ok:true, ...}` 或 `{ok:false, code, message, ...}`
|
|
235
257
|
*/
|
|
236
258
|
function exportPdf(o) {
|
|
@@ -254,107 +276,129 @@ function exportPdf(o) {
|
|
|
254
276
|
if (o.budget !== undefined && o.budget !== null) {
|
|
255
277
|
const b = Number(o.budget);
|
|
256
278
|
if (!Number.isInteger(b) || b < 1) {
|
|
257
|
-
/* ⚠️ 用 String() 而不是 JSON.stringify:`JSON.stringify(NaN)` 是 `"null"`,
|
|
258
|
-
于是 `--max-pages=abc` 会报「收到 null」—— 使用者据这句话会去查一个
|
|
259
|
-
根本不存在的 null(实测踩过,见 E264)。 */
|
|
260
279
|
return { ok: false, code: 'usage',
|
|
261
280
|
message: '--max-pages 必须是 ≥1 的整数,收到 ' + String(o.budget) };
|
|
262
281
|
}
|
|
263
282
|
}
|
|
283
|
+
const waitMs = o.waitMs === undefined ? 800 : Number(o.waitMs);
|
|
284
|
+
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 60000) {
|
|
285
|
+
return { ok: false, code: 'usage',
|
|
286
|
+
message: '--wait-ms 必须是 0–60000 的整数,收到 ' + String(o.waitMs) };
|
|
287
|
+
}
|
|
288
|
+
if (o.readySelector !== undefined && o.readySelector !== null
|
|
289
|
+
&& (typeof o.readySelector !== 'string' || !o.readySelector.trim())) {
|
|
290
|
+
return { ok: false, code: 'usage', message: '--ready-selector 必须是非空 CSS 选择器' };
|
|
291
|
+
}
|
|
292
|
+
const timeoutMs = o.timeoutMs === undefined ? 60000 : Number(o.timeoutMs);
|
|
293
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) {
|
|
294
|
+
return { ok: false, code: 'usage', message: 'timeoutMs 必须是 1000–600000 的整数' };
|
|
295
|
+
}
|
|
264
296
|
|
|
265
297
|
const chrome = resolveChrome(o.chrome);
|
|
266
298
|
if (chrome.error) return { ok: false, code: 'no-chrome', message: chrome.error };
|
|
267
299
|
|
|
268
300
|
const outAbs = path.resolve(o.out);
|
|
269
301
|
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
|
|
302
|
+
const staging = fs.mkdtempSync(path.join(path.dirname(outAbs), '.agentcss-pdf-'));
|
|
303
|
+
const profile = path.join(staging, 'profile');
|
|
304
|
+
fs.mkdirSync(profile);
|
|
305
|
+
const stagedPdf = path.join(staging, 'output.pdf');
|
|
270
306
|
|
|
271
307
|
/* 本地文件才注入 @page:远程页面我们拿不到它的字节,硬注入等于假装改了它。 */
|
|
272
308
|
let target = o.src;
|
|
273
309
|
let tmpHtml = null;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
310
|
+
let rendered = null;
|
|
311
|
+
let buf = null;
|
|
312
|
+
let info = null;
|
|
313
|
+
try {
|
|
314
|
+
if (!isRemote(o.src)) {
|
|
315
|
+
const srcAbs = path.resolve(o.src);
|
|
316
|
+
if (!fs.existsSync(srcAbs)) {
|
|
317
|
+
return { ok: false, code: 'usage', message: '找不到输入文件:' + srcAbs };
|
|
318
|
+
}
|
|
319
|
+
const html = fs.readFileSync(srcAbs, 'utf8');
|
|
320
|
+
sweepStaleTemplates(path.dirname(srcAbs));
|
|
321
|
+
/* 与输入同目录,页面里的相对样式、字体和图片才不会失效。 */
|
|
322
|
+
tmpHtml = path.join(path.dirname(srcAbs),
|
|
323
|
+
'.agentcss-print-' + process.pid + '-' + Date.now().toString(36) + '.html');
|
|
324
|
+
fs.writeFileSync(tmpHtml, injectSheet(html, cssFor(spec, margin)), 'utf8');
|
|
325
|
+
target = pathToFileURL(tmpHtml).href;
|
|
278
326
|
}
|
|
279
|
-
const html = fs.readFileSync(srcAbs, 'utf8');
|
|
280
|
-
/* ⚠️ 临时文件必须落在**同一个目录**:换成系统临时目录,页面里的
|
|
281
|
-
`./tokens.css` / `/02-primitives/...` 会全部 404,而产物看起来"只是没样式"。 */
|
|
282
|
-
tmpHtml = path.join(path.dirname(srcAbs),
|
|
283
|
-
'.agentcss-print-' + process.pid + '-' + Date.now().toString(36) + '.html');
|
|
284
|
-
fs.writeFileSync(tmpHtml, injectSheet(html, cssFor(spec, margin)), 'utf8');
|
|
285
|
-
target = pathToFileURL(tmpHtml).href;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'agentcss-chrome-'));
|
|
289
|
-
const args = [
|
|
290
|
-
'--headless=new',
|
|
291
|
-
'--disable-gpu',
|
|
292
|
-
'--disable-extensions',
|
|
293
|
-
'--no-first-run',
|
|
294
|
-
'--no-default-browser-check',
|
|
295
|
-
/* 每次一个新 profile:使用者可能正开着 Chrome,共用 profile 会抢锁 */
|
|
296
|
-
'--user-data-dir=' + profile,
|
|
297
|
-
/* 页眉页脚(日期 / 标题 / 1/2)默认是开的,必须显式关掉 */
|
|
298
|
-
'--no-pdf-header-footer',
|
|
299
|
-
'--print-to-pdf-no-header',
|
|
300
|
-
'--print-to-pdf=' + outAbs,
|
|
301
|
-
target,
|
|
302
|
-
];
|
|
303
|
-
/* 容器里以 root 跑才需要关沙箱 —— 默认不开,也不替使用者决定 */
|
|
304
|
-
if (process.env.AGENTCSS_NO_SANDBOX === '1') args.unshift('--no-sandbox');
|
|
305
327
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
328
|
+
/* 公共 API 保持同步;异步 CDP 只在独立 Node 子进程中运行。 */
|
|
329
|
+
const res = spawnSync(process.execPath, [path.join(__dirname, 'pdf-renderer.js')], {
|
|
330
|
+
input: JSON.stringify({
|
|
331
|
+
src: o.src, target: target, chrome: chrome.path, profile: profile,
|
|
332
|
+
stagedPdf: stagedPdf, waitMs: waitMs,
|
|
333
|
+
readySelector: o.readySelector === undefined ? null : o.readySelector,
|
|
334
|
+
timeoutMs: timeoutMs,
|
|
335
|
+
}),
|
|
336
|
+
timeout: timeoutMs + 10000,
|
|
337
|
+
encoding: 'utf8', windowsHide: true, maxBuffer: 1024 * 1024,
|
|
338
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
313
339
|
});
|
|
340
|
+
if (res.error && res.error.code === 'ETIMEDOUT') {
|
|
341
|
+
return { ok: false, code: 'timeout', out: outAbs, ms: Date.now() - t0,
|
|
342
|
+
message: 'PDF 导出子进程没有按时退出;最终文件未被改写' };
|
|
343
|
+
}
|
|
344
|
+
if (res.error || res.status !== 0) {
|
|
345
|
+
return { ok: false, code: 'render', out: outAbs, ms: Date.now() - t0,
|
|
346
|
+
message: 'PDF 导出子进程失败:' + String(res.error || ('退出码 ' + res.status))
|
|
347
|
+
+ (res.stderr ? '\n ' + String(res.stderr).slice(-800) : '') };
|
|
348
|
+
}
|
|
349
|
+
try { rendered = JSON.parse(res.stdout); } catch (e) { /* 下方按工具失败处理 */ }
|
|
350
|
+
if (!rendered) {
|
|
351
|
+
return { ok: false, code: 'render', out: outAbs, ms: Date.now() - t0,
|
|
352
|
+
message: 'PDF 导出子进程没有返回合法结果;最终文件未被改写' };
|
|
353
|
+
}
|
|
354
|
+
if (!rendered.ok) {
|
|
355
|
+
/* 子进程的 stagedPdf 在 finally 里已被删掉 —— 不要把它交出去,
|
|
356
|
+
否则调用方会拿到一个"看着像产物、其实不存在"的路径。 */
|
|
357
|
+
const { stagedPdf: _staged, ...rest } = rendered;
|
|
358
|
+
return { ...rest, out: outAbs, chrome: chrome.path, ms: Date.now() - t0 };
|
|
359
|
+
}
|
|
360
|
+
if (!fs.existsSync(stagedPdf)) {
|
|
361
|
+
return { ok: false, code: 'render', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
|
|
362
|
+
message: '浏览器报告成功但没有生成本次暂存 PDF;最终文件未被改写' };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* 文件头与页树都先在暂存文件上验证,通过后才接触最终路径。 */
|
|
366
|
+
buf = fs.readFileSync(stagedPdf);
|
|
367
|
+
if (buf.length < 5 || buf.slice(0, 5).toString('latin1') !== '%PDF-') {
|
|
368
|
+
return { ok: false, code: 'render', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
|
|
369
|
+
message: '本次暂存产物不是 PDF(' + buf.length + ' 字节);最终文件未被改写' };
|
|
370
|
+
}
|
|
371
|
+
info = pages.inspect(buf);
|
|
372
|
+
if (info.pages < 1) {
|
|
373
|
+
return { ok: false, code: 'unreadable', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
|
|
374
|
+
message: '暂存 PDF 没有可读页对象;最终文件未被改写' };
|
|
375
|
+
}
|
|
376
|
+
try { fs.renameSync(stagedPdf, outAbs); } catch (e) {
|
|
377
|
+
return { ok: false, code: 'render', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
|
|
378
|
+
message: '新 PDF 已生成,但无法替换输出文件:' + e.message + '。旧文件未被主动改写' };
|
|
379
|
+
}
|
|
314
380
|
} finally {
|
|
315
|
-
/* 清理必须**同步**做完:CLI 随后就 process.exit,挂一个 setTimeout
|
|
316
|
-
等于每次导出都在系统临时目录里留一个 Chrome profile(实测漏了 6 个才发现)。
|
|
317
|
-
删不掉不算错(Windows 上偶发 EBUSY),但不能因为删不掉就不删。 */
|
|
318
381
|
if (tmpHtml) { try { fs.unlinkSync(tmpHtml); } catch (e) { /* 已删 */ } }
|
|
319
|
-
try { fs.rmSync(
|
|
320
|
-
catch (e) { /*
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const ms = Date.now() - t0;
|
|
324
|
-
const stderr = String((res && res.stderr) || '').slice(-800);
|
|
325
|
-
if (res && res.error && res.error.code === 'ETIMEDOUT') {
|
|
326
|
-
return { ok: false, code: 'timeout', message: 'Chrome 在 '
|
|
327
|
-
+ Math.round((o.timeoutMs || 60000) / 1000) + 's 内没有结束(页面可能有死循环或等待网络)', ms };
|
|
328
|
-
}
|
|
329
|
-
if (!fs.existsSync(outAbs)) {
|
|
330
|
-
return { ok: false, code: 'render', ms,
|
|
331
|
-
message: 'Chrome 没有产出文件(退出码 ' + (res ? res.status : '?') + ')'
|
|
332
|
-
+ (stderr ? '\n ' + stderr.trim().split('\n').slice(-4).join('\n ') : '') };
|
|
382
|
+
try { fs.rmSync(staging, { recursive: true, force: true, maxRetries: 3 }); }
|
|
383
|
+
catch (e) { /* Windows 偶发 EBUSY,交给系统清理 */ }
|
|
333
384
|
}
|
|
334
|
-
|
|
335
|
-
const buf = fs.readFileSync(outAbs);
|
|
336
|
-
if (buf.length < 5 || buf.slice(0, 5).toString('latin1') !== '%PDF-') {
|
|
337
|
-
return { ok: false, code: 'render',
|
|
338
|
-
message: '产出的文件不是 PDF(' + buf.length + ' 字节)—— 通常是页面加载失败', ms };
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const info = pages.inspect(buf);
|
|
342
385
|
const injected = !isRemote(o.src);
|
|
343
386
|
const base = {
|
|
344
|
-
out: outAbs, bytes: buf.length, pdf: buf, ms,
|
|
345
|
-
/* 远程页面没注入 @page ⇒ `paper` 只能是 null:报 "a4" 是在陈述一个
|
|
346
|
-
没有发生的事实(实测远程导出是 612×792)。真实纸幅看 `mediaBox`。 */
|
|
387
|
+
out: outAbs, bytes: buf.length, pdf: buf, ms: Date.now() - t0,
|
|
347
388
|
paper: injected ? spec.key : null,
|
|
348
389
|
margin: injected ? margin : null, chrome: chrome.path,
|
|
349
390
|
pages: info.pages, mediaBox: info.mediaBox,
|
|
350
|
-
injected: injected,
|
|
391
|
+
injected: injected, waitMs: waitMs,
|
|
392
|
+
readySelector: o.readySelector || null,
|
|
393
|
+
jsErrors: rendered.jsErrors || [],
|
|
351
394
|
};
|
|
352
395
|
|
|
353
|
-
/*
|
|
354
|
-
|
|
396
|
+
/* 两套读法不一致时,连"这份 PDF 有几页"都不可信 —— 无论有没有预算。
|
|
397
|
+
只在设了预算时才检查,会让"没设预算"的调用方拿到一个可能是错页数的 ok:true。 */
|
|
398
|
+
if (!info.agree) {
|
|
355
399
|
return { ...base, ok: false, code: 'unreadable',
|
|
356
400
|
message: 'PDF 里 `/Type /Page` 数出 ' + info.pages + ' 页、页树 `/Count` 说 '
|
|
357
|
-
+ info.countFromTree + ' 页 ——
|
|
401
|
+
+ info.countFromTree + ' 页 —— 两套说法不一致,页数不可信' };
|
|
358
402
|
}
|
|
359
403
|
|
|
360
404
|
if (o.budget !== undefined && o.budget !== null && info.pages > o.budget) {
|
|
@@ -393,4 +437,5 @@ module.exports = {
|
|
|
393
437
|
/* 只为可测性导出:浏览器发现逻辑必须在"没有系统 Chrome"的机器上也能被验证,
|
|
394
438
|
否则它只会在 CI 上坏、而开发机永远绿。 */
|
|
395
439
|
chromeCandidates,
|
|
440
|
+
sweepStaleTemplates,
|
|
396
441
|
};
|
package/ai/recipes.json
ADDED
|
@@ -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
|
+
}
|