dsh-browser-playwright-codex 0.5.2 → 0.5.3

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 CHANGED
@@ -3,6 +3,36 @@
3
3
  Bilingual by intent: the version headings and the summary of each release are given in both
4
4
  languages, the individual entries follow the language the surrounding code and docs use.
5
5
 
6
+ ## 0.5.3
7
+
8
+ 一句话摘要:**源码按职责拆开、可维护性大幅提升,并修掉拆分过程中暴露的三处隐患** —— 对用户可见的行为零变化:工具面、配置接口、出厂默认值全部不动。本版同时修掉中文说明里一处会让人误判权限状态的过期说法。
9
+
10
+ ### Changed
11
+
12
+ - 源码按职责拆开:`playwright.ts` **2,727 → 55 行**,只剩注册入口 `apply()` 与一张给来访者的模块地图;对外零变化(loader 三行、`package.json` exports、`lib/` 入口、工具面全都不动)。做法是"纯搬运、一次一个接缝":每步 `npm test` + `npm run verify` + 解析一致性脚本全绿才提交,任何一步不绿就当场回退(过程中真回退过一次:拆 `tool.ts` 时用启发式切错边界,回退后改用 TypeScript 解析器取精确范围)。
13
+ - `provider.ts`(370 行):profile/context 生命周期、会话注册、空闲回收、弹窗登记、标签记账。
14
+ - `session.ts`(668):会话骨架 —— 持有 page/context,处理弹窗、截图、页面数据、evaluate/CDP,并把动作/快照/诊断转发给对应模块。
15
+ - `page-actions.ts`(786):可点击性检查、点击(几何阶梯 + Playwright 兜底)、填充与写后核对、按键、悬停、滚动、前进后退、标签切换。
16
+ - `page-snapshot.ts`(335):等页面稳定、兼容/智能两种采集、ref 签名与差异、ref 解析与存活校验。
17
+ - `page-diagnostics.ts`(198):控制台消息、页面错误、网络请求的有界记录 + 失败原因归类。
18
+ - `page-probes.ts`(288):会被序列化进浏览器的页内函数(铁律:必须自包含)。
19
+ - `window-focus.ts`(56):承载窗口是否最小化、后台建标签 —— 只为了让窗口/标签不打扰用户。
20
+ - `browser-lifecycle.ts`(183):启动/复用持久化 context、清理 Chrome 锁、启动与 profile 参数。
21
+ - `login-state.ts`(90):登录态导出/恢复(窗口最小化读不到状态时只取 cookies)。
22
+ - `config.ts` / `url-policy.ts` / `challenge.ts` / `extract.ts` / `tool-types.ts` / `tool-schemas.ts` / `tool-render.ts`:配置与常量、URL 策略、人机验证识别、提取工具辅助、工具面类型/schema/渲染。
23
+ - 12 个模块的文件说明与内容对齐(此前 6 处内容变了没改、5 处还留着拆分当天的占位文案)。
24
+
25
+ ### Fixed
26
+
27
+ - **入口子路径恢复 `Config` 导出**(`./playwright`):拆分时 schema 被搬进 `config.ts` 而入口忘了转出 —— Cordis 因此不会补默认值,而配置块里没有 `snapshot`,provider 又直接读 `config.snapshot.engine`,**下次重启就起不来**。该回归只存在于 main、从未随版本发布。现已补上入口契约测试:从 `src` 与 `lib` 两条路检查导出、校验 schema 补出默认值、复现"不含 `snapshot` 的 profile 配置"这一故障场景,并断言 `lib/` 与 `src/` 行为一致(防漂移)。
28
+ - **页内探测守卫覆盖不到 `hitTest`**:它原本是 `resolveClickPoint` 里的嵌套命名函数(同样会被 `keepNames` 改写成 `__name`),现已搬进 `page-probes.ts`;守卫从"手写 4 个函数"改成**枚举该模块所有函数导出**(并断言至少 5 个,避免空跑)。
29
+ - **中文说明写明两个闸门出厂关闭**:原文写"本包自带的 bundle 层是… `allowEvaluate`/`allowCdp` 打开",与出厂 `false`/`false` 矛盾,读者会以为"页面任意 JS / 原始 CDP"默认可用。现在写明出厂关闭并指向 §4.6。
30
+ - **文档守卫补洞**:原规则要求行内出现 `: true|false`,而措辞检查写在这个提前返回**之后** → "…`allowCdp` 打开"这类写法永远走不进检查。现改为两条带邻近性的规则;负向自检三种坏写法全部被抓住,且 `SECURITY.md` 里那句合法表述不被误报。
31
+ - **新增两条守卫**:模块文件说明不得留下拆分脚本的占位文案;页内函数必须自包含(第 5 个函数 `hitTest` 因此归位)。
32
+ - **测试的导航预算 2500ms → 15s**:冷启动的 windows runner 连加载本机 fixture 页都超时(`page.goto: Timeout 2500ms exceeded`),一次挂掉 4 条与超时无关的用例;没有任何测试断言导航超时行为,journeys 另有 25s 兜底。
33
+ - **拆分过程中暴露的 3 处隐患**:`WeakMap<Request>` 实际落在浏览器内置的 DOM `Request`(两个类型身份)、`provider.ts` ⇄ `session.ts` 的**运行时**循环依赖(多个测试文件直接加载失败,用例从 119 掉到 46)、测试与脚本仍在旧路径引用搬走的符号。
34
+ - 清掉搬迁后残留的未使用 import/参数(以 `tsc --noUnusedLocals --noUnusedParameters` 为裁判,28 处 → 0)。
35
+
6
36
  ## 0.5.2
7
37
 
8
38
  一句话摘要:**查出 Windows CI 抖动的真正根因并修掉**—— 测试用的转译器 tsx/esbuild 会开启 `keepNames`,把"要送进浏览器执行"的页内函数里的**命名内部函数**改写成 `__name(fn, "名字")`,而 `__name` 只存在于模块作用域、页面里没有 → 探测每次抛 `ReferenceError`,且被 `catch` 吞成无声的 `unreachable`。后果是**几何点击路径从来没工作过**:所有点击都退化成 Playwright 兜底点击(上限 3 秒),慢速 Windows runner 一超时,就把几何路径那句**误导性**的错误抛了出来。
@@ -0,0 +1,12 @@
1
+ import type { PlaywrightProvider } from './provider.ts';
2
+ /**
3
+ * Ensure the shared persistent context is alive (persistent mode). A
4
+ * profile-backed window is launched once and reused until closed by the
5
+ * last owner, idle disposal, or an external close; login state lives in
6
+ * the profile plus the exported state file, so reopening is seamless.
7
+ */
8
+ export declare function ensureContext(provider: PlaywrightProvider): Promise<import("playwright-core").BrowserContext>;
9
+ /** Remove stale Chrome singleton lock files inside the profile directory. */
10
+ export declare function clearChromeLocks(provider: PlaywrightProvider): Promise<void>;
11
+ /** Launch the browser once, probing the configured or auto-detected channel. */
12
+ export declare function ensureBrowser(provider: PlaywrightProvider): Promise<import("playwright-core").Browser>;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 浏览器的生命:启动/复用持久化 context、清理 Chrome 锁、以及启动与 profile 相关参数。
3
+ * 窗口**焦点**行为(承载窗口是否最小化、要不要在后台建标签)在 window-focus.ts。
4
+ * @module dsh-browser-playwright-codex/browser-lifecycle
5
+ */
6
+ import { chromium } from 'playwright-core';
7
+ import path from 'node:path';
8
+ import fs from 'node:fs';
9
+ import { AUTO_CHANNELS, HUMANIZED_LAUNCH } from "./config.js";
10
+ import { SNAPSHOT_SCRIPT } from "./injected.js";
11
+ import { BrowserError, launchFailed } from "./errors.js";
12
+ /**
13
+ * Ensure the shared persistent context is alive (persistent mode). A
14
+ * profile-backed window is launched once and reused until closed by the
15
+ * last owner, idle disposal, or an external close; login state lives in
16
+ * the profile plus the exported state file, so reopening is seamless.
17
+ */
18
+ export async function ensureContext(provider) {
19
+ if (provider.context !== undefined) {
20
+ try {
21
+ const browser = provider.context.browser();
22
+ if (browser !== null && browser.isConnected())
23
+ return provider.context;
24
+ }
25
+ catch {
26
+ /* context died underneath us: relaunch */
27
+ }
28
+ provider.context = undefined;
29
+ }
30
+ fs.mkdirSync(provider.profileDir, { recursive: true });
31
+ const { executablePath, channel, headless } = provider.config.launch;
32
+ const headful = !headless;
33
+ const attempts = [];
34
+ const baseOptions = {
35
+ headless,
36
+ // Headful windows must render like a normal Chrome the user opened
37
+ // by hand: the page viewport equals the real window size and the
38
+ // system DPI scaling (e.g. 150%) is honoured. Playwright's default
39
+ // is the opposite — it pins every page to a fixed CSS viewport at
40
+ // deviceScaleFactor 1 via Emulation.setDeviceMetricsOverride, which
41
+ // on scaled displays overflows the window: content looks zoomed in
42
+ // and clipped. viewport: null disables that override entirely.
43
+ // Headless mode keeps the fixed viewport (no window to track).
44
+ ...(headless ? { viewport: provider.config.launch.viewport } : { viewport: null }),
45
+ ignoreHTTPSErrors: provider.config.launch.ignoreHTTPSErrors,
46
+ ...HUMANIZED_LAUNCH,
47
+ args: [
48
+ '--no-first-run',
49
+ '--no-default-browser-check',
50
+ // Open maximised like a human would, so the first paint fills
51
+ // the screen instead of a small default window.
52
+ ...(headful ? ['--start-maximized'] : []),
53
+ ...HUMANIZED_LAUNCH.args,
54
+ ],
55
+ };
56
+ const start = async (extra) => {
57
+ try {
58
+ provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
59
+ }
60
+ catch (error) {
61
+ // A stale Chrome singleton lock from a dirty shutdown breaks a
62
+ // relaunch into the same profile; clear the lock files and
63
+ // retry once before surfacing the failure.
64
+ if (error instanceof Error && /user data directory|SingletonLock|ProcessSingleton/i.test(error.message)) {
65
+ await provider.clearChromeLocks();
66
+ provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
67
+ }
68
+ else {
69
+ throw error;
70
+ }
71
+ }
72
+ const ctx = provider.context;
73
+ // External close (user closed the window, crash): forget every
74
+ // session handle bound to this context; the next acquire relaunches.
75
+ void ctx.on('close', () => {
76
+ if (provider.context !== ctx)
77
+ return;
78
+ provider.context = undefined;
79
+ for (const [key, entry] of [...provider.entries]) {
80
+ if (entry.context !== ctx)
81
+ continue;
82
+ if (entry.timer !== undefined)
83
+ clearTimeout(entry.timer);
84
+ provider.entries.delete(key);
85
+ }
86
+ });
87
+ ctx.on('page', (page) => provider.armDialogGuard(page));
88
+ try {
89
+ await provider.restoreState(ctx);
90
+ }
91
+ catch {
92
+ /* malformed state file must not block startup */
93
+ }
94
+ await ctx.addInitScript(SNAPSHOT_SCRIPT);
95
+ // The initial blank page predates addInitScript: reload so the
96
+ // snapshot engine and restored localStorage apply on first use.
97
+ for (const page of ctx.pages()) {
98
+ await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => { });
99
+ }
100
+ return ctx;
101
+ };
102
+ if (executablePath !== undefined) {
103
+ try {
104
+ return await start({ executablePath });
105
+ }
106
+ catch (error) {
107
+ provider.launchError = error;
108
+ attempts.push('executablePath ' + executablePath);
109
+ }
110
+ }
111
+ else {
112
+ const channels = channel !== undefined && channel !== '' ? [channel] : [...AUTO_CHANNELS];
113
+ for (const candidate of channels) {
114
+ try {
115
+ return await start({ channel: candidate });
116
+ }
117
+ catch (error) {
118
+ provider.launchError = error;
119
+ attempts.push('channel ' + candidate);
120
+ }
121
+ }
122
+ }
123
+ const hint = 'Tried: ' +
124
+ attempts.join(', ') +
125
+ '. ' +
126
+ 'Install a Chromium-family browser, or run: npx playwright-core install chromium ' +
127
+ '(in restricted networks set PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright), ' +
128
+ 'or configure launch.channel / launch.executablePath.';
129
+ if (provider.launchError instanceof Error &&
130
+ /executable doesn't exist|not found/i.test(provider.launchError.message)) {
131
+ throw new BrowserError('NO_BROWSER', hint);
132
+ }
133
+ throw launchFailed(provider.launchError);
134
+ }
135
+ /** Remove stale Chrome singleton lock files inside the profile directory. */
136
+ export async function clearChromeLocks(provider) {
137
+ for (const lock of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
138
+ try {
139
+ fs.rmSync(path.join(provider.profileDir, lock), { force: true });
140
+ }
141
+ catch {
142
+ /* best-effort */
143
+ }
144
+ }
145
+ }
146
+ /** Launch the browser once, probing the configured or auto-detected channel. */
147
+ export async function ensureBrowser(provider) {
148
+ if (provider.browser !== undefined)
149
+ return provider.browser;
150
+ const { executablePath, channel, headless } = provider.config.launch;
151
+ const attempts = [];
152
+ if (executablePath !== undefined) {
153
+ try {
154
+ provider.browser = await chromium.launch({ executablePath, headless, ...HUMANIZED_LAUNCH });
155
+ return provider.browser;
156
+ }
157
+ catch (error) {
158
+ provider.launchError = error;
159
+ attempts.push('executablePath ' + executablePath);
160
+ }
161
+ }
162
+ else {
163
+ const channels = channel !== undefined && channel !== '' ? [channel] : [...AUTO_CHANNELS];
164
+ for (const candidate of channels) {
165
+ try {
166
+ provider.browser = await chromium.launch({ channel: candidate, headless, ...HUMANIZED_LAUNCH });
167
+ return provider.browser;
168
+ }
169
+ catch (error) {
170
+ provider.launchError = error;
171
+ attempts.push('channel ' + candidate);
172
+ }
173
+ }
174
+ }
175
+ const hint = 'Tried: ' +
176
+ attempts.join(', ') +
177
+ '. ' +
178
+ 'Install a Chromium-family browser, or run: npx playwright-core install chromium ' +
179
+ '(in restricted networks set PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright), ' +
180
+ 'or configure launch.channel / launch.executablePath.';
181
+ if (provider.launchError instanceof Error &&
182
+ /executable doesn't exist|not found/i.test(provider.launchError.message)) {
183
+ throw new BrowserError('NO_BROWSER', hint);
184
+ }
185
+ throw launchFailed(provider.launchError);
186
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 人机验证页识别:从快照里判断是否撞上 Cloudflare 之类的挑战页,并给出对应的提示文案。
3
+ * @module dsh-browser-playwright-codex/challenge
4
+ */
5
+ import type { ChallengeMatch, ChallengeSnapshot } from './tool-types.ts';
6
+ /**
7
+ * Detect anti-bot / human-verification challenge pages (Cloudflare
8
+ * interstitial, Turnstile, reCAPTCHA-style) from a snapshot's title, url and
9
+ * visible text. We do NOT try to solve them: the correct move for an agent is
10
+ * to hand the challenge to the human who can see the open window. Returns the
11
+ * engine name when matched, else null. Matching is conservative to avoid
12
+ * false positives on ordinary pages.
13
+ */
14
+ export declare function detectChallenge(snapshot: ChallengeSnapshot): ChallengeMatch | null;
15
+ /**
16
+ * Instruction appended above a snapshot when the page is an anti-bot
17
+ * challenge. The browser window is visible on the user's desktop: the agent
18
+ * should pause and ask the user to complete it by hand, then re-snapshot.
19
+ */
20
+ export declare const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop \u2014 tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Detect anti-bot / human-verification challenge pages (Cloudflare
3
+ * interstitial, Turnstile, reCAPTCHA-style) from a snapshot's title, url and
4
+ * visible text. We do NOT try to solve them: the correct move for an agent is
5
+ * to hand the challenge to the human who can see the open window. Returns the
6
+ * engine name when matched, else null. Matching is conservative to avoid
7
+ * false positives on ordinary pages.
8
+ */
9
+ export function detectChallenge(snapshot) {
10
+ const title = snapshot.title ?? '';
11
+ const url = snapshot.url ?? '';
12
+ const parts = [];
13
+ const walk = (nodes, depth) => {
14
+ for (const node of nodes) {
15
+ if (parts.length >= 60)
16
+ return;
17
+ if (node.name !== undefined && node.name !== '')
18
+ parts.push(node.name);
19
+ if (depth < 2 && node.children !== undefined && node.children.length > 0)
20
+ walk(node.children, depth + 1);
21
+ }
22
+ };
23
+ walk(snapshot.nodes ?? [], 0);
24
+ const text = [title, url, ...parts].join(' | ').slice(0, 900);
25
+ const saidCaptcha = parts.some((name) => /(captcha|turnstile|recaptcha|hcaptcha)/i.test(name));
26
+ if (/just a moment|attention required|checking (your )?browser|cf-chl-|请稍候|验证你的浏览器/i.test(text))
27
+ return { engine: 'Cloudflare', hint: 'Cloudflare is challenging the browser before letting the page load' };
28
+ if (/verify (you are|you're|that you are) (a )?human|人机验证|验证您(是|为)人类/i.test(text))
29
+ return { engine: 'human-verification', hint: 'the page is asking to verify a human visitor' };
30
+ if (/(captcha|turnstile|recaptcha|hcaptcha)/i.test(text) &&
31
+ (saidCaptcha || /(captcha|turnstile|recaptcha)/i.test(title)))
32
+ return { engine: 'captcha', hint: 'the page embedded a captcha-style challenge' };
33
+ return null;
34
+ }
35
+ /**
36
+ * Instruction appended above a snapshot when the page is an anti-bot
37
+ * challenge. The browser window is visible on the user's desktop: the agent
38
+ * should pause and ask the user to complete it by hand, then re-snapshot.
39
+ */
40
+ export const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop — tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 插件配置与常量:schema、启动参数、通道白名单、ref 形态。
3
+ * @module dsh-browser-playwright-codex/config
4
+ */
5
+ import z from '@deepseek-ai/schemastery';
6
+ /** Schemastery validation for {@link PlaywrightConfig}. */
7
+ /** Launch configuration for the Playwright provider. */
8
+ export interface PlaywrightConfig {
9
+ launch: {
10
+ /** Absolute path to a Chromium-family binary; takes precedence over channel. */
11
+ executablePath?: string;
12
+ /** Browser channel: chromium, chrome, msedge. Omitted = auto-detect in that order. */
13
+ channel?: string;
14
+ /** Run headful so the user can watch and rescue the browser by hand. */
15
+ headless: boolean;
16
+ /** Persistent mode: one shared profile-backed window whose login survives. */
17
+ persistent: boolean;
18
+ /** Profile directory for persistent mode; empty means ~/.dsh/browser-profiles/playwright. */
19
+ profileDir: string;
20
+ viewport: {
21
+ width: number;
22
+ height: number;
23
+ };
24
+ /** Per-action navigation/click timeout in milliseconds. */
25
+ navigationTimeoutMs: number;
26
+ /** Ignore HTTPS certificate errors. */
27
+ ignoreHTTPSErrors: boolean;
28
+ };
29
+ /** Host suffixes the browser may visit. Empty = any http(s) host. */
30
+ allowedDomains?: string[];
31
+ /** Close an idle session's browser context after this many milliseconds. 0 disables. */
32
+ idleTimeoutMs: number;
33
+ /** Maximum concurrent browser contexts; acquiring beyond it evicts the least recently used. */
34
+ maxSessions: number;
35
+ snapshot: {
36
+ /** 'legacy' keeps the injected DOM walker; 'aria' uses the official ariaSnapshot(mode:'ai') engine. */
37
+ engine: 'legacy' | 'aria';
38
+ maxNodes: number;
39
+ maxNameLength: number;
40
+ maxTextLength: number;
41
+ /** Incremental ref-diff mode; false = always full snapshots (zero behavioral change). */
42
+ diff: boolean;
43
+ };
44
+ }
45
+ /** Bounded page data the extraction consumer feeds to a model. */
46
+ export interface PageData {
47
+ readonly url: string;
48
+ readonly title: string;
49
+ readonly text: string;
50
+ readonly truncated: boolean;
51
+ readonly links: readonly {
52
+ readonly text: string;
53
+ readonly href: string;
54
+ }[];
55
+ readonly inputs: readonly {
56
+ readonly tag: string;
57
+ readonly type: string;
58
+ readonly name: string;
59
+ readonly value: string;
60
+ readonly label: string;
61
+ readonly checked: boolean | null;
62
+ }[];
63
+ }
64
+ /** Schemastery validation for {@link PlaywrightConfig}. */
65
+ export declare const Config: z<PlaywrightConfig>;
66
+ /** Channels probed in order when none is configured. */
67
+ export declare const AUTO_CHANNELS: string[];
68
+ /**
69
+ * Launch flags that strip Playwright's obvious automation markers (anti-bot
70
+ * detection layer L1). --enable-automation is what sets navigator.webdriver
71
+ * and the "controlled by automated test software" infobar; dropping it plus
72
+ * --disable-blink-features=AutomationControlled makes the browser present as
73
+ * a plain real Chrome, which it is. No fingerprint spoofing is added: real
74
+ * Chrome's own values are consistent by construction (spoofing would invent
75
+ * a device that never existed and draw MORE suspicion).
76
+ */
77
+ export declare const HUMANIZED_LAUNCH: {
78
+ ignoreDefaultArgs: string[];
79
+ args: string[];
80
+ };
81
+ export declare const REF_PATTERN: RegExp;
package/lib/config.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 插件配置与常量:schema、启动参数、通道白名单、ref 形态。
3
+ * @module dsh-browser-playwright-codex/config
4
+ */
5
+ import z from '@deepseek-ai/schemastery';
6
+ /** Schemastery validation for {@link PlaywrightConfig}. */
7
+ export const Config = z.object({
8
+ launch: z.object({
9
+ executablePath: z.string(),
10
+ channel: z.string(),
11
+ // Visible window by default: the agent drives a browser the user can
12
+ // watch and rescue by hand (login, captcha, dialogs).
13
+ headless: z.boolean().default(false),
14
+ // Persistent context mode: one shared profile-backed window whose
15
+ // login state survives close/reopen. false = legacy launch() mode.
16
+ persistent: z.boolean().default(true),
17
+ // Profile directory for persistent mode. Empty = ~/.dsh/browser-profiles/playwright.
18
+ profileDir: z.string().default(''),
19
+ // Headless only: a headful window must behave like a normal Chrome the
20
+ // user opened by hand, so the fixed CSS viewport is ignored there (see
21
+ // ensureContext) and the page tracks the real window size instead.
22
+ viewport: z
23
+ .object({ width: z.number().default(1280), height: z.number().default(800) })
24
+ .default({ width: 1280, height: 800 }),
25
+ navigationTimeoutMs: z.number().default(30000),
26
+ ignoreHTTPSErrors: z.boolean().default(false),
27
+ }),
28
+ allowedDomains: z.array(z.string()),
29
+ idleTimeoutMs: z.number().default(600000),
30
+ maxSessions: z.number().default(8),
31
+ snapshot: z
32
+ .object({
33
+ // 'legacy' keeps the injected DOM walker as the default: existing
34
+ // tests, verify and every consumer see byte-identical snapshots until
35
+ // a caller opts into the aria engine.
36
+ engine: z.union([z.const('legacy'), z.const('aria')]).default('legacy'),
37
+ maxNodes: z.number().default(500),
38
+ maxNameLength: z.number().default(120),
39
+ maxTextLength: z.number().default(300),
40
+ // diff=false keeps every return path a full snapshot: zero behavioral
41
+ // change unless a caller explicitly turns the incremental mode on.
42
+ diff: z.boolean().default(false),
43
+ })
44
+ .default({ engine: 'legacy', maxNodes: 500, maxNameLength: 120, maxTextLength: 300, diff: false }),
45
+ });
46
+ /** Channels probed in order when none is configured. */
47
+ export const AUTO_CHANNELS = ['chromium', 'chrome', 'msedge', 'edge'];
48
+ /**
49
+ * Launch flags that strip Playwright's obvious automation markers (anti-bot
50
+ * detection layer L1). --enable-automation is what sets navigator.webdriver
51
+ * and the "controlled by automated test software" infobar; dropping it plus
52
+ * --disable-blink-features=AutomationControlled makes the browser present as
53
+ * a plain real Chrome, which it is. No fingerprint spoofing is added: real
54
+ * Chrome's own values are consistent by construction (spoofing would invent
55
+ * a device that never existed and draw MORE suspicion).
56
+ */
57
+ export const HUMANIZED_LAUNCH = {
58
+ ignoreDefaultArgs: ['--enable-automation'],
59
+ args: ['--disable-blink-features=AutomationControlled'],
60
+ };
61
+ export const REF_PATTERN = /^e[0-9]+$/;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 提取工具的辅助:容错地解析模型返回的 JSON,以及为提取请求拼装提示词。
3
+ * @module dsh-browser-playwright-codex/extract
4
+ */
5
+ import type { JsonValue } from './tool-types.ts';
6
+ /** Strip markdown fences and whitespace, then parse one JSON document. */
7
+ export declare function parseJsonText(raw: string): JsonValue;
8
+ /**
9
+ * Prompt template for structured page extraction. `maxInputChars` bounds the page
10
+ * data handed to the auxiliary model — that model is billed on this prompt, so an
11
+ * unbounded page is a tax on every extraction.
12
+ */
13
+ export declare function extractionPrompt(instruction: string, data: unknown, maxInputChars: number): string;
package/lib/extract.js ADDED
@@ -0,0 +1,34 @@
1
+ /** Strip markdown fences and whitespace, then parse one JSON document. */
2
+ export function parseJsonText(raw) {
3
+ let text = raw.trim();
4
+ const fenced = /^\s*```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text);
5
+ if (fenced !== null) {
6
+ const inner = fenced[1];
7
+ if (inner !== undefined)
8
+ text = inner.trim();
9
+ }
10
+ try {
11
+ return JSON.parse(text);
12
+ }
13
+ catch {
14
+ throw new Error('browser_extract: the model output is not valid JSON: ' + text.slice(0, 500));
15
+ }
16
+ }
17
+ /**
18
+ * Prompt template for structured page extraction. `maxInputChars` bounds the page
19
+ * data handed to the auxiliary model — that model is billed on this prompt, so an
20
+ * unbounded page is a tax on every extraction.
21
+ */
22
+ export function extractionPrompt(instruction, data, maxInputChars) {
23
+ const page = JSON.stringify(data);
24
+ const bounded = page.length > maxInputChars
25
+ ? page.slice(0, maxInputChars) + ' [page data truncated at ' + String(maxInputChars) + ' characters]'
26
+ : page;
27
+ return ('Extract the following from the page data according to the instruction. ' +
28
+ 'Answer with ONLY one valid JSON value (object, array, or scalar), no prose, no markdown fences.\n\n' +
29
+ 'Instruction: ' +
30
+ instruction +
31
+ '\n\n' +
32
+ 'Page data (JSON):\n' +
33
+ bounded);
34
+ }
package/lib/index.d.ts CHANGED
@@ -14,7 +14,9 @@ export { SNAPSHOT_SCRIPT } from './injected.ts';
14
14
  export type { SnapshotOptions, PageDataOptions } from './injected.ts';
15
15
  export { default as BrowserRuntime } from './service.ts';
16
16
  export type { BrowserRuntimeConfig } from './service.ts';
17
- export { PlaywrightProvider, assertAllowedUrl, Config as PlaywrightConfigSchema } from './playwright.ts';
18
- export type { PlaywrightConfig, PageData } from './playwright.ts';
17
+ export { PlaywrightProvider } from './provider.ts';
18
+ export { assertAllowedUrl } from './url-policy.ts';
19
+ export { Config as PlaywrightConfigSchema } from './config.ts';
20
+ export type { PlaywrightConfig, PageData } from './config.ts';
19
21
  export type { BrowserNode, BrowserProvider, BrowserSession, BrowserSnapshot, LoadState, ScreenshotCapture, TabInfo, } from './types.ts';
20
22
  export type { ToolConfig } from './tool.ts';
package/lib/index.js CHANGED
@@ -9,4 +9,6 @@ export { ALWAYS_ON_SUFFIXES, ERROR_CODES, GATED_SUFFIXES, TOOL_SUFFIXES, registe
9
9
  export { CallId, toolCallId } from "./compat.js";
10
10
  export { SNAPSHOT_SCRIPT } from "./injected.js";
11
11
  export { default as BrowserRuntime } from "./service.js";
12
- export { PlaywrightProvider, assertAllowedUrl, Config as PlaywrightConfigSchema } from "./playwright.js";
12
+ export { PlaywrightProvider } from "./provider.js";
13
+ export { assertAllowedUrl } from "./url-policy.js";
14
+ export { Config as PlaywrightConfigSchema } from "./config.js";
@@ -0,0 +1,10 @@
1
+ import type { BrowserContext } from 'playwright-core';
2
+ import type { PlaywrightProvider } from './provider.ts';
3
+ /** Export the login-state fallback to the state file (session-cookie fallback). */
4
+ export declare function persistState(provider: PlaywrightProvider, context: BrowserContext): Promise<void>;
5
+ /**
6
+ * Re-inject a previously exported state. Durable cookies already live in
7
+ * the profile; this covers session cookies the browser drops on close and
8
+ * localStorage entries, so logins survive window close/reopen.
9
+ */
10
+ export declare function restoreState(provider: PlaywrightProvider, context: BrowserContext): Promise<void>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 登录态持久化:把 profile 的 cookies+localStorage 导出到状态文件、并在重开时恢复;
3
+ * 窗口最小化或读不到窗口状态时只取 cookies。
4
+ * @module dsh-browser-playwright-codex/login-state
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ /** Export the login-state fallback to the state file (session-cookie fallback). */
9
+ export async function persistState(provider, context) {
10
+ if (provider.stateFile === undefined || context === undefined)
11
+ return;
12
+ try {
13
+ const state = await provider.collectLoginState(context);
14
+ const tmp = provider.stateFile + '.tmp';
15
+ fs.mkdirSync(path.dirname(provider.stateFile), { recursive: true });
16
+ fs.writeFileSync(tmp, JSON.stringify(state));
17
+ fs.renameSync(tmp, provider.stateFile);
18
+ provider.lastStatePersist = Date.now();
19
+ }
20
+ catch {
21
+ /* best-effort: the profile itself already holds durable cookies */
22
+ }
23
+ }
24
+ /**
25
+ * Re-inject a previously exported state. Durable cookies already live in
26
+ * the profile; this covers session cookies the browser drops on close and
27
+ * localStorage entries, so logins survive window close/reopen.
28
+ */
29
+ export async function restoreState(provider, context) {
30
+ if (provider.stateFile === undefined)
31
+ return;
32
+ let state;
33
+ try {
34
+ state = JSON.parse(fs.readFileSync(provider.stateFile, 'utf8'));
35
+ }
36
+ catch {
37
+ return;
38
+ }
39
+ if (state === null || typeof state !== 'object')
40
+ return;
41
+ const cookies = Array.isArray(state.cookies)
42
+ ? state.cookies.filter((c) => c !== null &&
43
+ typeof c === 'object' &&
44
+ typeof c.name === 'string' &&
45
+ c.value !== undefined &&
46
+ typeof c.domain === 'string')
47
+ : [];
48
+ if (cookies.length > 0) {
49
+ await context.addCookies(cookies.map((c) => ({
50
+ name: c.name,
51
+ value: c.value,
52
+ domain: c.domain,
53
+ path: typeof c.path === 'string' && c.path !== '' ? c.path : '/',
54
+ ...(typeof c.expires === 'number' && c.expires > 0 ? { expires: c.expires } : {}),
55
+ ...(typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {}),
56
+ ...(typeof c.secure === 'boolean' ? { secure: c.secure } : {}),
57
+ ...(typeof c.sameSite === 'string' ? { sameSite: c.sameSite } : {}),
58
+ })));
59
+ }
60
+ const origins = Array.isArray(state.origins)
61
+ ? state.origins.filter((o) => o !== null &&
62
+ typeof o === 'object' &&
63
+ typeof o.origin === 'string' &&
64
+ Array.isArray(o.localStorage) &&
65
+ o.localStorage.length > 0)
66
+ : [];
67
+ if (origins.length > 0) {
68
+ await context.addInitScript(({ rows }) => {
69
+ for (const o of rows) {
70
+ if (location.origin !== o.origin)
71
+ continue;
72
+ for (const kv of o.localStorage) {
73
+ try {
74
+ localStorage.setItem(kv.name, kv.value);
75
+ }
76
+ catch {
77
+ /* quota / privacy mode */
78
+ }
79
+ }
80
+ }
81
+ }, { rows: origins });
82
+ }
83
+ }