dsh-browser-playwright-codex 0.5.4 → 0.6.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.
- package/CHANGELOG.md +93 -2
- package/NOTICE.md +39 -0
- package/README.md +72 -11
- package/SECURITY.md +22 -0
- package/cordis.patch.yml +6 -0
- package/lib/compat.d.ts +9 -9
- package/lib/compat.js +0 -20
- package/lib/contract.d.ts +2 -2
- package/lib/contract.js +0 -4
- package/lib/login-sites.d.ts +136 -0
- package/lib/login-sites.js +303 -0
- package/lib/page-actions.d.ts +2 -2
- package/lib/page-actions.js +1 -1
- package/lib/provider.js +45 -1
- package/lib/runtime-state.d.ts +39 -0
- package/lib/runtime-state.js +20 -26
- package/lib/session.d.ts +40 -22
- package/lib/session.js +65 -1
- package/lib/site-aliases.d.ts +33 -0
- package/lib/site-aliases.js +132 -0
- package/lib/tool.js +95 -91
- package/lib/types.d.ts +13 -0
- package/package.json +5 -2
- package/scripts/preflight.mjs +192 -0
package/lib/tool.js
CHANGED
|
@@ -3,7 +3,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
3
3
|
import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
4
4
|
import { BrowserError } from "./errors.js";
|
|
5
5
|
import { registeredSuffixes } from "./contract.js";
|
|
6
|
-
import { getEnabled, publishGates } from "./runtime-state.js";
|
|
6
|
+
import { getEnabled, publishGates, publishLoginSites } from "./runtime-state.js";
|
|
7
|
+
import { summarizeLoginSites } from "./login-sites.js";
|
|
7
8
|
import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
|
|
8
9
|
import { SCREENSHOT_SCHEMA, SNAPSHOT_SCHEMA, TABS_SCHEMA } from "./tool-schemas.js";
|
|
9
10
|
import { parseJsonText, extractionPrompt } from "./extract.js";
|
|
@@ -68,11 +69,37 @@ export function apply(ctx, config) {
|
|
|
68
69
|
return id === undefined ? 'anonymous' : String(id);
|
|
69
70
|
};
|
|
70
71
|
/** Acquire the calling session's live browser session. */
|
|
72
|
+
/**
|
|
73
|
+
* Publish the "which sites look logged in" summary for the settings card. Throttled, and
|
|
74
|
+
* every failure is swallowed: this is a display nicety and must never slow down or break an
|
|
75
|
+
* action. Nothing here is model-visible — it travels only to the settings route.
|
|
76
|
+
*/
|
|
77
|
+
let lastLoginPublish = 0;
|
|
78
|
+
const publishLogins = (session) => {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (now - lastLoginPublish < 5000)
|
|
81
|
+
return;
|
|
82
|
+
lastLoginPublish = now;
|
|
83
|
+
void session
|
|
84
|
+
.cookieJar()
|
|
85
|
+
.then(async (rows) => {
|
|
86
|
+
publishLoginSites(summarizeLoginSites(rows, 'live', now, {
|
|
87
|
+
visited: session.visitedHosts(),
|
|
88
|
+
titles: await session.siteTitles(),
|
|
89
|
+
}));
|
|
90
|
+
})
|
|
91
|
+
.catch(() => {
|
|
92
|
+
/* display-only: a failed cookie read must not surface as a tool failure */
|
|
93
|
+
});
|
|
94
|
+
};
|
|
71
95
|
const acquire = (exec) => {
|
|
72
96
|
if (!getEnabled()) {
|
|
73
97
|
throw new BrowserError('DISABLED', 'dsh-browser-playwright-codex is disabled; open Settings → Plugins → 浏览器 and turn it on to use the browser');
|
|
74
98
|
}
|
|
75
|
-
return ctx.browser.acquire(ownerFor(exec), exec.signal)
|
|
99
|
+
return ctx.browser.acquire(ownerFor(exec), exec.signal).then((session) => {
|
|
100
|
+
publishLogins(session);
|
|
101
|
+
return session;
|
|
102
|
+
});
|
|
76
103
|
};
|
|
77
104
|
const services = () => {
|
|
78
105
|
const out = {};
|
|
@@ -137,46 +164,69 @@ export function apply(ctx, config) {
|
|
|
137
164
|
}));
|
|
138
165
|
ctx.tools.register(defineTool({
|
|
139
166
|
name: p('click'),
|
|
140
|
-
description: 'Click the
|
|
141
|
-
'Refs go stale after navigation or DOM changes
|
|
142
|
-
|
|
143
|
-
'
|
|
144
|
-
'
|
|
145
|
-
p('hover') +
|
|
146
|
-
' just revealed), pass text instead, copied exactly as rendered — a revealed item may need ' +
|
|
147
|
-
p('hover') +
|
|
148
|
-
' first to stay on screen. ' +
|
|
149
|
-
'IRON RULE: after the click, verify in the returned snapshot that the intended effect actually happened (URL, selection, toast, dialog, new content). ' +
|
|
150
|
-
'A click can also succeed without changing this page: when the snapshot shows no change, check ' +
|
|
167
|
+
description: 'Click the target from the latest snapshot — or reveal it without clicking (mode:"hover") — and return the new snapshot. ' +
|
|
168
|
+
'Best target first: `ref` when the snapshot has one; `text`, a visible label copied exactly as rendered, for a target shown WITHOUT a ref; `x`+`y` viewport CSS pixels only as a last resort (canvas-drawn hit areas). Refs go stale after navigation or DOM changes: take a fresh snapshot if a ref fails. ' +
|
|
169
|
+
'mode:"hover" only REVEALS content and never activates: use it when a control hides content until the pointer rests on it, especially when the snapshot lacks an element the page obviously should have. A hover returning only means the pointer moved: read the note comparing the actionable ref count before and after; new refs mean pre-hover refs are stale, so act from the NEW refs before an unrelated click closes the menu. If that note reports no new refs, do NOT repeat the hover and do NOT fall back to raw coordinates — hover the parent/container or the sibling that owns the menu, otherwise use a visible alternative or tell the user. ' +
|
|
170
|
+
'precise:true takes a ref click down the geometric path instead: the element is scrolled into view and clicked at its exact centre through native input, with no screenshot measuring, no devicePixelRatio guesswork. It is the FALLBACK, not a routine alternative — use it only after a ref click reports the element is not actionable, or that the click could not be delivered. ' +
|
|
171
|
+
'IRON RULE: verify the effect in the returned snapshot (URL, selection, toast, dialog, new content). No change may still be success: check ' +
|
|
151
172
|
p('tabs') +
|
|
152
|
-
' for a new tab (a target="_blank" link) before concluding
|
|
153
|
-
'The
|
|
154
|
-
'
|
|
155
|
-
p('click_at') +
|
|
156
|
-
' raw coordinates — inspect the visible state for the blocker or interception, resolve it, then retry the most direct semantic action. ' +
|
|
157
|
-
'If the click raises a native dialog, it stays PENDING and this call returns immediately with the dialog text instead of the page tree — answer it with ' +
|
|
173
|
+
' for a new tab (a target="_blank" link) before concluding, and treat a same-page overlay as success. ' +
|
|
174
|
+
'The landing note names what received the click: if that is not your target the click was intercepted — treat it as failed and retarget; do NOT blindly repeat it and do NOT throw raw coordinates at it, but inspect the visible state for the blocker or interception first, resolve that, then retry the most direct semantic action. ' +
|
|
175
|
+
'A native dialog stays PENDING and this returns immediately with the dialog text; this tool cannot accept it — ' +
|
|
158
176
|
p('dialog') +
|
|
159
|
-
'
|
|
177
|
+
' is a separate, deliberate call.',
|
|
160
178
|
parameters: {
|
|
161
179
|
ref: {
|
|
162
180
|
type: 'string',
|
|
163
|
-
description: 'Element ref (e.g. e12) from the latest snapshot.
|
|
181
|
+
description: 'Element ref (e.g. e12) from the latest snapshot. Preferred whenever the snapshot has one.',
|
|
164
182
|
},
|
|
165
183
|
text: {
|
|
166
184
|
type: 'string',
|
|
167
|
-
description: 'Visible label of a target shown WITHOUT a ref
|
|
185
|
+
description: 'Visible label of a target shown WITHOUT a ref, copied exactly as the snapshot renders it; the first visible element whose own text matches is used and the note names it.',
|
|
186
|
+
},
|
|
187
|
+
x: {
|
|
188
|
+
type: 'integer',
|
|
189
|
+
description: 'Viewport x in CSS pixels, with y: a raw click, mode "click" only, last resort.',
|
|
190
|
+
},
|
|
191
|
+
y: {
|
|
192
|
+
type: 'integer',
|
|
193
|
+
description: 'Viewport y in CSS pixels, with x: a raw click, mode "click" only, last resort.',
|
|
194
|
+
},
|
|
195
|
+
mode: {
|
|
196
|
+
type: 'string',
|
|
197
|
+
enum: ['click', 'hover'],
|
|
198
|
+
description: 'click (default) activates the target; hover only reveals it.',
|
|
199
|
+
},
|
|
200
|
+
precise: {
|
|
201
|
+
type: 'boolean',
|
|
202
|
+
description: 'Ref clicks only: click the geometric centre through native input instead of the standard click.',
|
|
168
203
|
},
|
|
169
204
|
},
|
|
170
205
|
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
171
206
|
async execute(args, exec) {
|
|
172
207
|
const session = await acquire(exec);
|
|
173
|
-
|
|
174
|
-
|
|
208
|
+
const mode = args.mode ?? 'click';
|
|
209
|
+
if (mode !== 'click' && mode !== 'hover') {
|
|
210
|
+
throw new BrowserError('PARAM_MISSING', 'browser_click takes mode "click" or "hover", got: ' + String(args.mode));
|
|
211
|
+
}
|
|
212
|
+
const ref = args.ref !== undefined && args.ref !== '' ? args.ref : undefined;
|
|
213
|
+
const text = args.text !== undefined && args.text !== '' ? args.text : undefined;
|
|
214
|
+
const point = typeof args.x === 'number' && typeof args.y === 'number' ? { x: args.x, y: args.y } : undefined;
|
|
215
|
+
if (mode === 'hover') {
|
|
216
|
+
if (ref !== undefined)
|
|
217
|
+
return snapshotValue(await session.hover(ref, exec.signal));
|
|
218
|
+
if (text !== undefined)
|
|
219
|
+
return snapshotValue(await session.hoverText(text, exec.signal));
|
|
220
|
+
throw new BrowserError('PARAM_MISSING', 'browser_click with mode "hover" needs a ref from the latest snapshot, or the visible text label of a trigger that has no ref');
|
|
175
221
|
}
|
|
176
|
-
if (
|
|
177
|
-
return snapshotValue(await session.
|
|
222
|
+
if (ref !== undefined) {
|
|
223
|
+
return snapshotValue(args.precise === true ? await session.clickAtRef(ref, exec.signal) : await session.click(ref, exec.signal));
|
|
178
224
|
}
|
|
179
|
-
|
|
225
|
+
if (text !== undefined)
|
|
226
|
+
return snapshotValue(await session.clickText(text, exec.signal));
|
|
227
|
+
if (point !== undefined)
|
|
228
|
+
return snapshotValue(await session.clickAt(point.x, point.y, exec.signal));
|
|
229
|
+
throw new BrowserError('PARAM_MISSING', 'browser_click needs a target: a ref from the latest snapshot, the visible text label of a target that has no ref, or both x and y viewport coordinates');
|
|
180
230
|
},
|
|
181
231
|
}));
|
|
182
232
|
ctx.tools.register(defineTool({
|
|
@@ -207,71 +257,10 @@ export function apply(ctx, config) {
|
|
|
207
257
|
return snapshotValue(await session.resolveDialog(args.accept, args.promptText, exec.signal));
|
|
208
258
|
},
|
|
209
259
|
}));
|
|
210
|
-
ctx.tools.register(defineTool({
|
|
211
|
-
name: p('click_at'),
|
|
212
|
-
description: 'Precise native click, two modes. Ref mode (prefer it): pass ref — the element is scrolled into view, awaited for geometry stability, and clicked at its exact geometric centre through native input: no screenshot measuring, no devicePixelRatio guesswork. ' +
|
|
213
|
-
'Raw mode: pass x/y viewport CSS pixels to click a raw point with no element involvement. ' +
|
|
214
|
-
'This is the FALLBACK, not a routine alternative: try ' +
|
|
215
|
-
p('click') +
|
|
216
|
-
' (including its text mode) first, and use ref mode only after a click reports the element is not actionable or the click could not be delivered. Raw x/y is a last resort for targets no ref resolves (canvas-drawn hit areas): raw clicks still return a landing note saying what they hit, so re-snapshot and confirm. ' +
|
|
217
|
-
'IRON RULE: verify the effect in the returned snapshot; if nothing changed, do NOT repeat blindly and do NOT throw more raw coordinates at it — find the blocker or interception first, then retry the most direct semantic action. ' +
|
|
218
|
-
'The snapshot may open with a landing note naming what received the click, or — when an intercepted ref-mode target had to fall back to a bare click at its centre — a CAUTION note: if the click was intercepted, treat it as failed and retarget.',
|
|
219
|
-
parameters: {
|
|
220
|
-
ref: {
|
|
221
|
-
type: 'string',
|
|
222
|
-
description: 'Element ref (e.g. e12) from the latest snapshot. If given, its geometric centre is clicked; x/y are ignored.',
|
|
223
|
-
},
|
|
224
|
-
x: { type: 'integer', description: 'Raw-mode viewport x in CSS pixels (required only when ref is omitted).' },
|
|
225
|
-
y: { type: 'integer', description: 'Raw-mode viewport y in CSS pixels (required only when ref is omitted).' },
|
|
226
|
-
},
|
|
227
|
-
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
228
|
-
async execute(args, exec) {
|
|
229
|
-
const session = await acquire(exec);
|
|
230
|
-
if (args.ref !== undefined && args.ref !== '') {
|
|
231
|
-
return snapshotValue(await session.clickAtRef(args.ref, exec.signal));
|
|
232
|
-
}
|
|
233
|
-
if (typeof args.x !== 'number' || typeof args.y !== 'number') {
|
|
234
|
-
throw new BrowserError('PARAM_MISSING', 'browser_click_at needs a ref, or both x and y viewport coordinates');
|
|
235
|
-
}
|
|
236
|
-
return snapshotValue(await session.clickAt(args.x, args.y, exec.signal));
|
|
237
|
-
},
|
|
238
|
-
}));
|
|
239
|
-
ctx.tools.register(defineTool({
|
|
240
|
-
name: p('hover'),
|
|
241
|
-
description: 'Hover the element with the given ref — or, for a trigger the snapshot gave no ref, the visible element whose own label equals text — and return the new snapshot. ' +
|
|
242
|
-
'Hover only REVEALS content and never activates anything: use ' +
|
|
243
|
-
p('click') +
|
|
244
|
-
' to open, select or submit. Use it when a control hides content until the pointer rests on it (submenus, dropdowns, account menus, tooltips, previews, chart values, player controls) — and especially when the snapshot lacks an element the page obviously should have. ' +
|
|
245
|
-
'Prefer ref when the snapshot has one; use text only for a label shown WITHOUT a ref, copied exactly as rendered. Do not hover plain links/buttons you mean to activate, and never hover "just in case". ' +
|
|
246
|
-
'A hover returning only means the pointer moved: the snapshot opens with a note comparing the actionable ref count before and after — read it. New refs mean the revealed content is in this snapshot and pre-hover refs are stale, so pick from the NEW refs and act before any unrelated click closes the menu. ' +
|
|
247
|
-
'IRON RULE: if the note reports no new refs and what you expected is still missing, do NOT repeat the hover and do NOT fall back to ' +
|
|
248
|
-
p('click_at') +
|
|
249
|
-
' raw coordinates — hover the parent/container or the sibling that owns the menu; if that also reveals nothing, hover is not implemented here, so use a visible alternative or tell the user.',
|
|
250
|
-
parameters: {
|
|
251
|
-
ref: {
|
|
252
|
-
type: 'string',
|
|
253
|
-
description: 'Element ref (e.g. e12) from the latest snapshot. Use this whenever the snapshot has a ref for the trigger.',
|
|
254
|
-
},
|
|
255
|
-
text: {
|
|
256
|
-
type: 'string',
|
|
257
|
-
description: 'Visible label of a trigger shown WITHOUT a ref (plain text, e.g. generic "设置"). Copied exactly as the snapshot renders it; the first visible element whose own text matches is hovered and the note names it.',
|
|
258
|
-
},
|
|
259
|
-
},
|
|
260
|
-
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
261
|
-
async execute(args, exec) {
|
|
262
|
-
const session = await acquire(exec);
|
|
263
|
-
if (args.ref !== undefined && args.ref !== '') {
|
|
264
|
-
return snapshotValue(await session.hover(args.ref, exec.signal));
|
|
265
|
-
}
|
|
266
|
-
if (args.text !== undefined && args.text !== '') {
|
|
267
|
-
return snapshotValue(await session.hoverText(args.text, exec.signal));
|
|
268
|
-
}
|
|
269
|
-
throw new BrowserError('PARAM_MISSING', 'browser_hover needs either a ref from the latest snapshot or the visible text label of a trigger that has no ref');
|
|
270
|
-
},
|
|
271
|
-
}));
|
|
272
260
|
ctx.tools.register(defineTool({
|
|
273
261
|
name: p('fill'),
|
|
274
262
|
description: 'Replace the value of the input or select referenced by the latest snapshot. ' +
|
|
263
|
+
'`value` is accepted as an alias of `text`. ' +
|
|
275
264
|
'For <select> elements the text selects the matching option label. ' +
|
|
276
265
|
'IRON RULE: after filling, verify in the returned snapshot that the field actually holds the value. ' +
|
|
277
266
|
'Frameworks can silently reject or transform input (controlled components, contenteditable editors, ' +
|
|
@@ -284,12 +273,27 @@ export function apply(ctx, config) {
|
|
|
284
273
|
'say so) — understand why it was ignored, then act.',
|
|
285
274
|
parameters: {
|
|
286
275
|
ref: { type: 'string', required: true, description: 'Input or select ref (e.g. e7) from the latest snapshot.' },
|
|
287
|
-
text: {
|
|
276
|
+
text: {
|
|
277
|
+
type: 'string',
|
|
278
|
+
description: 'Exact value to fill, or option label for selects. (In browser_click, `text` is a label to click instead.)',
|
|
279
|
+
},
|
|
280
|
+
value: { type: 'string', description: 'Alias of `text`; if both are given, `text` wins.' },
|
|
288
281
|
},
|
|
289
282
|
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
290
283
|
async execute(args, exec) {
|
|
284
|
+
// `text` deliberately is NOT `required: true` here, even though in practice you must give it
|
|
285
|
+
// (or `value`). The parameter DSL is a flat property map whose only requiredness is
|
|
286
|
+
// per-property, so it cannot express "exactly one of text/value" — the `oneOf` the compiled
|
|
287
|
+
// JSON Schema supports is value-level, not object-level. The requirement is therefore
|
|
288
|
+
// enforced below, before a session is acquired (a malformed call must not launch a browser),
|
|
289
|
+
// and the message names both spellings. 2026-10-16: a reviewing model passed `value` first
|
|
290
|
+
// and only got `invalid arguments: missing required property "text"` — no way forward.
|
|
291
|
+
const value = args.text ?? args.value;
|
|
292
|
+
if (value === undefined) {
|
|
293
|
+
throw new BrowserError('PARAM_MISSING', 'browser_fill needs the value to type: pass `text` (alias `value`). The `text` here is the value you type — for a visible label to click, use browser_click instead.');
|
|
294
|
+
}
|
|
291
295
|
const session = await acquire(exec);
|
|
292
|
-
return snapshotValue(await session.fill(args.ref,
|
|
296
|
+
return snapshotValue(await session.fill(args.ref, value, exec.signal));
|
|
293
297
|
},
|
|
294
298
|
}));
|
|
295
299
|
ctx.tools.register(defineTool({
|
package/lib/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Domain types for the browser capability: snapshots, sessions, providers.
|
|
3
3
|
* @module dsh-browser-playwright-codex/types
|
|
4
4
|
*/
|
|
5
|
+
import type { CookieRow } from './login-sites.ts';
|
|
5
6
|
/** Page load condition a navigation waits for. */
|
|
6
7
|
export type LoadState = 'load' | 'domcontentloaded' | 'networkidle';
|
|
7
8
|
/** One snapshot element. Refs identify actionable elements for later calls. */
|
|
@@ -191,6 +192,18 @@ export interface BrowserSession {
|
|
|
191
192
|
} | undefined, signal?: AbortSignal): Promise<ScreenshotCapture>;
|
|
192
193
|
/** Extract the page's bounded data: text, links, forms, frames. */
|
|
193
194
|
pageData(): Promise<unknown>;
|
|
195
|
+
/**
|
|
196
|
+
* Read this session's cookies for display-only login inference. Implementations must drop
|
|
197
|
+
* `value` before returning: the inference only needs names, domains and lifetime.
|
|
198
|
+
*/
|
|
199
|
+
cookieJar(): Promise<readonly CookieRow[]>;
|
|
200
|
+
/**
|
|
201
|
+
* Registrable domains of the pages this browser has open, used to attribute cookies to the site
|
|
202
|
+
* that was visited rather than to whoever set them.
|
|
203
|
+
*/
|
|
204
|
+
visitedHosts(): string[];
|
|
205
|
+
/** Page titles by registrable domain, for display only; empty when nothing is open. */
|
|
206
|
+
siteTitles(): Promise<Map<string, string>>;
|
|
194
207
|
/** Evaluate one JavaScript expression and return its JSON result. */
|
|
195
208
|
evaluate(expression: string, signal?: AbortSignal): Promise<unknown>;
|
|
196
209
|
/** Click the element whose visible text matches, by semantic locator. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-playwright-codex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Browser automation for DeepSeek Harness: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised, and stops for sign-ins, CAPTCHAs and native dialogs instead of confirming for you.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,10 +37,12 @@
|
|
|
37
37
|
"README.md",
|
|
38
38
|
"CHANGELOG.md",
|
|
39
39
|
"SECURITY.md",
|
|
40
|
+
"NOTICE.md",
|
|
40
41
|
"locale"
|
|
41
42
|
],
|
|
42
43
|
"scripts": {
|
|
43
44
|
"build": "node node_modules/typescript/bin/tsc -p tsconfig.json",
|
|
45
|
+
"typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.tests.json",
|
|
44
46
|
"test": "node --test \"tests/*.test.ts\"",
|
|
45
47
|
"test:scenarios": "node --test tests/scenarios.test.ts tests/tool-scenarios.test.ts",
|
|
46
48
|
"test:live": "node --test tests/live-smoke.test.ts",
|
|
@@ -50,7 +52,8 @@
|
|
|
50
52
|
"cost": "node node_modules/tsx/dist/cli.mjs scripts/measure-prompt-cost.mjs",
|
|
51
53
|
"format": "node node_modules/prettier/bin/prettier.cjs --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
|
|
52
54
|
"format:check": "node node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
|
|
53
|
-
"prepublishOnly": "npm run build && npm test"
|
|
55
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm test && npm run verify && npm run preflight",
|
|
56
|
+
"preflight": "node scripts/preflight.mjs ."
|
|
54
57
|
},
|
|
55
58
|
"dependencies": {
|
|
56
59
|
"@deepseek-ai/schemastery": "*",
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发版前置审计(preflight):把"文档对世界说的话"和"包里到底装了什么"一次查干净。
|
|
3
|
+
*
|
|
4
|
+
* 为什么有这个脚本(2026-10-11,用户第二次因此不满):
|
|
5
|
+
* 之前是"只改点不看面"—— 修了 README 的 npm 说法,却没查 SECURITY 的计数、NOTICE 的路径、
|
|
6
|
+
* 发帖草稿里的数字、以及"文档引用的文件/行号在重构后是否还存在"。用户的原话是
|
|
7
|
+
* 「你能不能检查清楚再发新版本啊,别给我检查出来又一堆 bug」。所以这份检查挂在 `prepublishOnly`
|
|
8
|
+
* 上:**不通过就发不出去**,而不是等用户事后发现。
|
|
9
|
+
*
|
|
10
|
+
* 三类输出,语义严格区分:
|
|
11
|
+
* ✗ 必须修 —— 硬错误(文件读不到、清单缺项、路径/行号/链接失效、测试失败、计数与实测不符)
|
|
12
|
+
* ? 待确认 —— 可能是历史、上游或有意为之(例如 "published since v0.5.1"、上游插件的旧包名)
|
|
13
|
+
* ✓ 已一致 —— 已经核对过的基线数字
|
|
14
|
+
*
|
|
15
|
+
* 规则刻意收窄避免噪声(第一版误报过:把 IP 当版本号、把上游文档的版本当我们的、把竞品的工具数
|
|
16
|
+
* 当我们的、把"三个闸门合计 127 token"这个子项当成总成本)。
|
|
17
|
+
*
|
|
18
|
+
* 用法: node preflight.mjs [项目根] 退出码: 0 通过 / 2 有硬错误
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from 'node:child_process'
|
|
21
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
22
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
|
|
25
|
+
const root = resolve(process.argv[2] ?? join(dirname(fileURLToPath(import.meta.url)), '..'))
|
|
26
|
+
const problems = []
|
|
27
|
+
const confirm = []
|
|
28
|
+
const ok = []
|
|
29
|
+
|
|
30
|
+
const readOrThrow = (rel) => {
|
|
31
|
+
const p = join(root, rel)
|
|
32
|
+
if (!existsSync(p)) throw new Error('读不到文件(硬错误,不许当通过):' + rel)
|
|
33
|
+
return readFileSync(p, 'utf8')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── A. 包里装了什么 ───────────────────────────────────────────────────────────
|
|
37
|
+
const packed = new Set(
|
|
38
|
+
JSON.parse(
|
|
39
|
+
execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: root, encoding: 'utf8', shell: true }),
|
|
40
|
+
)[0].files.map((f) => f.path),
|
|
41
|
+
)
|
|
42
|
+
// 这些是"用户从 npm 装到之后必须能看到"的文件:许可证与归属声明(NOTICE)、安全说明、变更记录、
|
|
43
|
+
// 以及让 Gradle 式的 loader 能挂载的 cordis.patch.yml。2026-10-11 的审计就是靠这份清单抓到
|
|
44
|
+
// NOTICE.md 一直没进包(npm 只会自动带上 LICENSE,不会带 NOTICE)。
|
|
45
|
+
const SHOULD_SHIP = [
|
|
46
|
+
'package.json',
|
|
47
|
+
'README.md',
|
|
48
|
+
'CHANGELOG.md',
|
|
49
|
+
'SECURITY.md',
|
|
50
|
+
'NOTICE.md',
|
|
51
|
+
'LICENSE',
|
|
52
|
+
'cordis.patch.yml',
|
|
53
|
+
'locale/en.json',
|
|
54
|
+
'locale/zh.json',
|
|
55
|
+
]
|
|
56
|
+
for (const f of SHOULD_SHIP) if (!packed.has(f)) problems.push('包里缺文件:' + f)
|
|
57
|
+
if (![...packed].some((p) => p.startsWith('lib/'))) problems.push('包里没有 lib/(插件实际加载的就是它)')
|
|
58
|
+
if ([...packed].some((p) => p.startsWith('src/') || p.startsWith('tests/'))) problems.push('包里不该有 src/ 或 tests/')
|
|
59
|
+
if ([...packed].some((p) => p.endsWith('.bak'))) problems.push('包里有 .bak 残渣')
|
|
60
|
+
ok.push('包内 ' + packed.size + ' 个文件;已确认含 lib/、locale/、cordis.patch.yml,且不含 src/tests/.bak')
|
|
61
|
+
|
|
62
|
+
// ── B. 基线数字(来自命令本身,不是文档自述) ─────────────────────────────────
|
|
63
|
+
const pkg = JSON.parse(readOrThrow('package.json'))
|
|
64
|
+
const cost = execFileSync('npm', ['run', 'cost'], { cwd: root, encoding: 'utf8', shell: true })
|
|
65
|
+
const toolCount = Number((cost.match(/工具数[::]\s*(\d+)/) ?? [])[1])
|
|
66
|
+
const chars = Number((cost.match(/字符总数[::]\s*(\d+)/) ?? [])[1])
|
|
67
|
+
const tokens = Number((cost.match(/粗估 token[::]\s*~?(\d+)/) ?? [])[1])
|
|
68
|
+
if (!toolCount || !chars || !tokens) problems.push('拿不到 npm run cost 的数字')
|
|
69
|
+
else ok.push('成本基线:' + toolCount + ' 工具 / ' + chars + ' 字符 / ~' + tokens + ' token/轮')
|
|
70
|
+
|
|
71
|
+
const suite = execFileSync('node', ['--test', 'tests/*.test.ts'], { cwd: root, encoding: 'utf8', shell: true })
|
|
72
|
+
const n = (re) => Number((suite.match(re) ?? [])[1])
|
|
73
|
+
if (n(/ℹ fail (\d+)/) > 0) problems.push('测试有失败:' + n(/ℹ fail (\d+)/) + ' 条')
|
|
74
|
+
else
|
|
75
|
+
ok.push(
|
|
76
|
+
'测试:' + n(/ℹ tests (\d+)/) + ' / pass ' + n(/ℹ pass (\d+)/) + ' / fail 0 / skipped ' + n(/ℹ skipped (\d+)/),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// ── C. 文档断言 ───────────────────────────────────────────────────────────────
|
|
80
|
+
const docFiles = []
|
|
81
|
+
const walk = (dir) => {
|
|
82
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
83
|
+
if (e.name === 'node_modules' || e.name === '.git') continue
|
|
84
|
+
const p = join(dir, e.name)
|
|
85
|
+
if (e.isDirectory()) walk(p)
|
|
86
|
+
else if (e.name.endsWith('.md')) docFiles.push(relative(root, p).replace(/\\/g, '/'))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
walk(root)
|
|
90
|
+
const HISTORY = new Set(['CHANGELOG.md'])
|
|
91
|
+
/** 上游项目(被借鉴的那个插件)自己的文档:里面出现旧包名/旧版本是对的。 */
|
|
92
|
+
const UPSTREAM_DOCS = new Set([
|
|
93
|
+
'PLUGIN-README.md',
|
|
94
|
+
'codex-rules.md',
|
|
95
|
+
'patches/README.md',
|
|
96
|
+
'docs/articles/focus-stealing.md',
|
|
97
|
+
])
|
|
98
|
+
const current = docFiles.filter((f) => !HISTORY.has(f))
|
|
99
|
+
|
|
100
|
+
const basenames = new Set()
|
|
101
|
+
for (const dir of ['', 'src', 'lib', 'scripts', 'tests', 'locale', 'patches', 'docs', 'docs/articles']) {
|
|
102
|
+
const abs = dir ? join(root, dir) : root
|
|
103
|
+
if (!existsSync(abs)) continue
|
|
104
|
+
for (const e of readdirSync(abs, { withFileTypes: true })) if (e.isFile()) basenames.add(e.name)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const f of docFiles) {
|
|
108
|
+
const text = readOrThrow(f)
|
|
109
|
+
const currentDoc = current.includes(f)
|
|
110
|
+
text.split('\n').forEach((line, i) => {
|
|
111
|
+
const where = f + ':' + (i + 1)
|
|
112
|
+
const historical = /(since|since v|起|之前|以后|published|发布|历史|历史值|for the record|v0[.]4)/i.test(line)
|
|
113
|
+
|
|
114
|
+
// C1. 看起来是"本包版本"的数字
|
|
115
|
+
if (currentDoc && !historical) {
|
|
116
|
+
const notOurs =
|
|
117
|
+
/(Node|node|playwright-core|upstream|上游|Chenyu|Codex|scan of|扫描|dsh-plugin-vet|electron)/i.test(line)
|
|
118
|
+
for (const m of line.matchAll(/\bv(\d+\.\d+\.\d+)\b/g)) {
|
|
119
|
+
if (notOurs) break
|
|
120
|
+
if (m[1] !== pkg.version)
|
|
121
|
+
confirm.push(where + ' 提到版本 v' + m[1] + '(当前 ' + pkg.version + ')→ ' + line.trim().slice(0, 80))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// C2. 写死的工具数:只有当这句在说"我们"时才核对(竞品对照说的是别人)
|
|
126
|
+
const tc = line.match(/\b(\d{2})\s*(?:个)?\s*(?:browser_\*?\s*)?(?:tools|工具)/i)
|
|
127
|
+
if (currentDoc && tc && Number(tc[1]) >= 10) {
|
|
128
|
+
const aboutUs = /(我们|this package|本包|registered|注册)/i.test(line)
|
|
129
|
+
const aboutThem = /(同类|竞品|其它|其他|competitor|alternative|更轻)/i.test(line)
|
|
130
|
+
if (aboutUs && !aboutThem && Number(tc[1]) !== toolCount)
|
|
131
|
+
problems.push(where + ' 写死工具数 ' + tc[1] + '(实测 ' + toolCount + ')')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// C3. 写死的总成本:只核对"总计/常驻"这类句子,且量级要像总数(≈127 这种子项不算)
|
|
135
|
+
const tk = line.match(/\b(\d[\d,]{2,6})\s*(?:tokens?|token)/i)
|
|
136
|
+
if (currentDoc && tk) {
|
|
137
|
+
const value = Number(tk[1].replace(/,/g, ''))
|
|
138
|
+
const aboutTotal = /(常驻|总计|合计|resident|per turn|每轮)/i.test(line)
|
|
139
|
+
if (aboutTotal && value >= 500 && Math.abs(value - tokens) > 200)
|
|
140
|
+
problems.push(where + ' 写死的总 token 数 ' + tk[1] + '(实测 ~' + tokens + ')')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// C4. 旧包名:.state.json 是刻意保留的;上游文档里出现是对的
|
|
144
|
+
if (
|
|
145
|
+
/dsh-browser-playwright(?!-codex)/.test(line) &&
|
|
146
|
+
!UPSTREAM_DOCS.has(f) &&
|
|
147
|
+
!/\.state\.json/.test(line) &&
|
|
148
|
+
!/(上游|upstream|Chenyu)/i.test(line)
|
|
149
|
+
) {
|
|
150
|
+
confirm.push(where + ' 出现旧包名(确认语境)→ ' + line.trim().slice(0, 80))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// C5. 反引号里的仓库路径
|
|
154
|
+
for (const m of line.matchAll(/`([A-Za-z0-9._/-]+\.(?:md|ts|js|mjs|cjs|json|yml|yaml))`/g)) {
|
|
155
|
+
const p = m[1]
|
|
156
|
+
if (!p.includes('/') || p.startsWith('~') || p.startsWith('http')) continue
|
|
157
|
+
const abs = p.startsWith('.') ? resolve(root, f, '..', p) : join(root, p)
|
|
158
|
+
if (existsSync(abs)) continue
|
|
159
|
+
if (basenames.has(p.slice(p.lastIndexOf('/') + 1)))
|
|
160
|
+
problems.push(where + ' 路径写错:`' + p + '`(文件在本仓库,但不在这个位置)')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// C6. 「文件:行号」引用
|
|
164
|
+
for (const m of line.matchAll(/([A-Za-z0-9_./-]+\.(?:ts|js|mjs|cjs|json|yml))[^\n]{0,12}?[:(]\s*~?:?(\d{3,5})/g)) {
|
|
165
|
+
const abs = join(root, m[1].replace(/^\.\//, ''))
|
|
166
|
+
if (!existsSync(abs)) continue
|
|
167
|
+
const lines = readFileSync(abs, 'utf8').split('\n').length
|
|
168
|
+
if (Number(m[2]) > lines)
|
|
169
|
+
problems.push(where + ' 行号越界:' + m[1] + ':' + m[2] + '(该文件现在 ' + lines + ' 行)')
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
// C7. markdown 相对链接
|
|
173
|
+
for (const m of readOrThrow(f).matchAll(/\]\(([^)#\s]+)(?:#[^)]*)?\)/g)) {
|
|
174
|
+
const target = m[1]
|
|
175
|
+
if (/^(https?:|mailto:)/.test(target) || target.startsWith('#')) continue
|
|
176
|
+
if (!existsSync(resolve(root, f, '..', target))) problems.push(f + ' markdown 链接失效:' + target)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── 输出 ─────────────────────────────────────────────────────────────────────
|
|
181
|
+
console.log('=== ✗ 必须修(硬错误)===')
|
|
182
|
+
console.log(problems.length ? problems.map((p) => ' ✗ ' + p).join('\n') : ' (无)')
|
|
183
|
+
console.log('')
|
|
184
|
+
console.log('=== ? 待确认(历史 / 上游 / 有意为之)===')
|
|
185
|
+
const uniqueConfirm = [...new Set(confirm)]
|
|
186
|
+
console.log(uniqueConfirm.length ? uniqueConfirm.map((c) => ' ? ' + c).join('\n') : ' (无)')
|
|
187
|
+
console.log('')
|
|
188
|
+
console.log('=== ✓ 已核对 ===')
|
|
189
|
+
console.log(ok.map((o) => ' ✓ ' + o).join('\n'))
|
|
190
|
+
console.log('')
|
|
191
|
+
console.log('结论:' + problems.length + ' 处硬错误、' + uniqueConfirm.length + ' 处待确认。')
|
|
192
|
+
process.exitCode = problems.length ? 2 : 0
|