dsh-browser-playwright-codex 0.6.0 → 0.6.5
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 +38 -0
- package/README.md +35 -2
- package/SECURITY.md +3 -3
- package/lib/challenge-signatures.d.ts +105 -0
- package/lib/challenge-signatures.js +154 -0
- package/lib/challenge.d.ts +48 -8
- package/lib/challenge.js +211 -26
- package/lib/injected.js +13 -0
- package/lib/page-actions.d.ts +7 -56
- package/lib/page-actions.js +1 -1
- package/lib/page-diagnostics.js +23 -5
- package/lib/page-snapshot.d.ts +14 -8
- package/lib/page-snapshot.js +48 -1
- package/lib/session.d.ts +21 -82
- package/lib/session.js +15 -3
- package/lib/snapshot-aria.js +16 -0
- package/lib/tool-render.js +21 -1
- package/lib/tool-types.d.ts +10 -0
- package/lib/types.d.ts +23 -0
- package/package.json +4 -3
- package/scripts/analysis/hl-compare.mjs +0 -149
- package/scripts/analysis/live-handoff-probe.mjs +0 -97
- package/scripts/analysis/needs-human-probe.mjs +0 -83
- package/scripts/analysis/repro-minimize.mjs +0 -112
- package/scripts/autocommit.ps1 +0 -23
- package/scripts/check-doc-links.py +0 -112
- package/scripts/measure-prompt-cost.mjs +0 -57
- package/scripts/preflight.mjs +0 -192
- package/scripts/prompt-cost.mjs +0 -73
- package/scripts/shipped-gates.mjs +0 -43
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,44 @@
|
|
|
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.6.5 — 2026-10-16
|
|
7
|
+
|
|
8
|
+
一句话摘要:**挑战页识别不再靠一份关键词表** —— 判定改成「厂商自有的机器信号优先,认不出来就问一句『页面变了吗』」,所以新出现的验证方式也能被接住;顺带清掉漏网的 4 处本机路径,并把 npm 包里 12 个用不上的开发脚本收成 2 个。
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **挑战页识别重做成分层判定**(新增 `src/challenge-signatures.ts`,重写 `src/challenge.ts`)。原来只有一份中英混杂的词表,而且词表里**根本没有「验证码」「安全验证」「拖动滑块」这些中文说法** —— 也就是说 README 承诺的「遇到验证码会停下来」,在中文站点上大半不成立:百度系的「安全验证」、滑块页的「拖动滑块完成拼图」一个都识别不到,插件会当作普通页面继续点。现在按**稳定性**排序,而不是按覆盖率:
|
|
13
|
+
1. **主文档响应头** —— 厂商对外的公开 API:`cf-mitigated: challenge`(Cloudflare 官方文档就是教用户读这个头)、`x-amzn-waf-action: challenge`(AWS WAF)、`x-iinfo`(Imperva)、`x-datadome*`(DataDome)。为此在 `src/page-diagnostics.ts` 已有的 `page.on('response')` 里认出**主文档导航响应**,把 status 与头透传进快照(`BrowserSnapshot.response`)。只认**产生当前文档的那次响应**:单页应用改了 URL 却没有文档加载时,旧响应不再复用 —— 否则会在一个好好的页面上报出墙;退回低层是安全的失败方向。
|
|
14
|
+
2. **厂商控件自己的无障碍名** —— reCAPTCHA / hCaptcha / Turnstile / Arkose / PerimeterX 的 iframe 标题就是他们自己的产品名;非 iframe 节点上的同名 token 必须**另有**「请你验证」字样才算数,而且那份旁证语汇**排除了它自己**(`reCAPTCHA` 里就含 `captcha`,不排除的话这条守卫是空的 —— 第一版就是这样,被测试当场抓出来)。
|
|
15
|
+
3. **文案(最后手段)** —— 标题里的 `安全验证` / `Just a moment...`,或控件上的命令式文案(`请输入验证码`、`拖动滑块完成拼图`);按「动词族」组合匹配而不是逐句枚举。**只有控件角色**(输入框 / 按钮 / 图片 / 滑块…)能凭「验证码」二字触发,`验证码说明` 这种帮助页链接不触发;`获取验证码` / `发送验证码` 这类正常登录控件也排除(另一个被测试抓出来的误判)。
|
|
16
|
+
4. **行为兜底(不认识任何厂商)** —— 动作之后 URL、标题、元素集合完全没变、且页面上几乎没有别的可点,就在这次结果里附一句「没有可见变化」,并**明说这是猜测**。这一层覆盖所有没有签名的厂商:新出现的验证方式靠它接住,代价只是多问用户一句,而不是拿账号去撞墙。
|
|
17
|
+
- **`interactiveOnly` 不再把 iframe 剪掉**(`src/injected.ts` 与 `src/snapshot-aria.ts` 两个引擎都改):iframe 的无障碍名就是内嵌控件自己的标题,是第 2 层唯一的证据。原来这个省 token 的开关一开,等于把验证码识别关掉 —— 一个**静默失效**。
|
|
18
|
+
- **已经认出是拦截面时,不再叠那句兜底提示**:「这是墙、原因是什么」才是可执行的信息,再叠一句更含糊的警告只是多花 prompt。
|
|
19
|
+
- **清掉仓库里漏网的 4 处本机绝对路径**(`README.md`、`PLUGIN-README.md`、`SNAPSHOT-RULES.md`、`scripts/autocommit.ps1`)。0.6.1 声称清干净了,实际漏了这四处;而它正是外部评审用来判断「这个项目离开作者的电脑就转不动」的证据。**这次改成脚本守**:`tests/doc-reality.test.ts` 新增守卫,扫全仓手写文本文件里「盘符 + 至少一段目录」的路径(已用探针验证:三条真实路径全部抓到,仓库里唯一命中是给读者的 `D:\path\to\…` 占位符,由白名单排除)。
|
|
20
|
+
- **npm 包从 85 个文件收到 75 个**:`files` 里那一整个 `scripts/` 收成逐个文件的白名单,只留 `doctor.mjs` 与 `dsh-browser-verify.mjs`(这两个只依赖 `lib/` 与 harness,装到 npm 的用户**能直接跑**)。顺带修掉一处假承诺:README 原来说 `measure-prompt-cost.mjs` 也随包可跑,但它依赖 `src/` 与 `shipped-gates.mjs`,在装好的包里**从来跑不起来**。`scripts/preflight.mjs` 的 `SHOULD_SHIP` 现在钉住这两个脚本,防止以后再被顺手丢掉。
|
|
21
|
+
- **新增上手一节「第一个任务」**(`README.md` / `PLUGIN-README.md` / `中文说明.md`):三条可以直接复制的话 + 你会看到什么 + 第一天两个习惯(先看再点、不可逆的那步说停)。四家外部评审独立提到「装完不知道第一步干什么、文档过载」,而 `examples/` 目录一直不存在。
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `tests/challenge.test.ts`:**35 条**用例,正例与**必须不触发**的反例各占一半 —— 讲 reCAPTCHA 的文章、`验证码说明` 帮助链接、`获取验证码` 按钮、只是提到「请输入验证码」的长段落、普通搜索页、只有「安全」二字的标题。另有一条守卫:旁证语汇不得被任何厂商自己的名字满足。
|
|
26
|
+
- `tests/scenarios.test.ts`:**4 条真浏览器端到端**。①真实 `cf-mitigated: challenge` 响应头一路走到渲染结果(钉住「监听 → 快照 → 判定 → 模型读到的文字」这条链,单测盖不住);②一个**完全没有任何签名**的拦截面(标题「请稍等」、一个点不动的按钮)由第 4 层兜底抓住,测试**先断言 `detectChallenge` 返回 null**,证明它不是靠签名抓到的;③`interactiveOnly` 下 iframe 名字仍在;④单页应用改 URL 后旧响应头不复用。
|
|
27
|
+
- `tests/snapshot-aria.test.ts`:`interactiveOnly` 保留 iframe 的用例。
|
|
28
|
+
- `tests/doc-reality.test.ts`:本机路径守卫(见上)。
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **`PlaywrightSession.blockedSnapshot` 从未声明返回类型**,于是 `navigate()` 的推断返回类型变成一个联合类型,新加的字段在类型上根本不存在。类型检查抓出来的,不是运行期 —— 这正是把 `typecheck` 扩到 `tests/` 的价值。
|
|
33
|
+
- **兜底指纹原来只读两层**:深层文字变化会被误判成「页面没变」,于是在一个正常页面上报出「没有可见变化」的假警告。现在读到 4 层。
|
|
34
|
+
|
|
35
|
+
## 0.6.1 — 2026-10-16
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **清掉仓库里的本机绝对路径**:`DIALOG-POLICY.md` 里那条 `D:\dsh-plugins\…` 与 `codex-rules.md` 里那条 `C:\Users\…\.codex\…` 换成通用说法;`ci.yml` 里写死的验收计数("(52 checks)",实际 57 项)改成由脚本自己打印,从此不会再漂移。三处都是外部模型评审当场读出来的。
|
|
39
|
+
- **SECURITY.md 的扫描数字更新为 2026-10-16 对 0.6.0 的实测**:dsh.so 的 `dsh-plugin-vet` 扫出
|
|
40
|
+
**1 Critical / 3 Warning / 25 Info**(80 个文件),此前记的是 v0.5.3 的 1 / 3 / 22(75 个文件)。
|
|
41
|
+
Critical 与 Warning 的**条目**没变(Critical 仍是 `browser_evaluate` 的 `new Function`),只有 Info 随代码增长增加。
|
|
42
|
+
(旧数字来自我们的上一次扫描;新数字来自 dsh.so 的独立扫描,用于核对。)
|
|
43
|
+
|
|
6
44
|
## 0.6.0 — 2026-10-16
|
|
7
45
|
一句话摘要:**`browser_fill` 现在也接受 `value` 这个参数名**(模型按语义猜也能一次成功),缺值时给的是指路错误,而不是"缺少属性 text"。
|
|
8
46
|
|
package/README.md
CHANGED
|
@@ -25,7 +25,11 @@ dsh plugin --profile <name> add github:miqian-nomad/dsh-browser-playwright-codex
|
|
|
25
25
|
keeps working while it is minimised ([FOCUS-STEALING.md](FOCUS-STEALING.md)).
|
|
26
26
|
- **遇事停下来叫人** — `alert` / `confirm` / `prompt` are parked and reported, answered only by an
|
|
27
27
|
explicit `browser_dialog` call; a login, 2FA or captcha is handed back to you
|
|
28
|
-
([DIALOG-POLICY.md](DIALOG-POLICY.md)).
|
|
28
|
+
([DIALOG-POLICY.md](DIALOG-POLICY.md)). A challenge page is recognised from the interceptor's own
|
|
29
|
+
response headers first (`cf-mitigated: challenge` and friends), then from its widget, and only then
|
|
30
|
+
from wording; if none of those match, a page that stops changing after an action is reported as a
|
|
31
|
+
possible wall instead of being retried
|
|
32
|
+
([docs/articles/dialogs-and-captcha.md](docs/articles/dialogs-and-captcha.md)).
|
|
29
33
|
- **ref 失效会明说** — per-document nonce refs; a stale ref fails fast with an explanation instead of
|
|
30
34
|
silently clicking a different element.
|
|
31
35
|
- **强力工具默认关闭** — `browser_evaluate` / `browser_cdp` are **not registered at all** unless your
|
|
@@ -64,6 +68,28 @@ without a build step — `npm run build` is only needed after editing `src/`.
|
|
|
64
68
|
|
|
65
69
|
The bundle mounts three rows: the `ctx.browser` seam (`service`), the Playwright provider (`playwright`), and the model-facing tool family (`tool`).
|
|
66
70
|
|
|
71
|
+
### Your first task
|
|
72
|
+
|
|
73
|
+
Install it and ask for something that leaves a visible result — there is nothing else to configure:
|
|
74
|
+
|
|
75
|
+
> Open `https://news.ycombinator.com` and list the titles of the first five stories with their links.
|
|
76
|
+
|
|
77
|
+
> Go to my orders page on `<site>` and tell me the delivery address on the most recent order.
|
|
78
|
+
|
|
79
|
+
> Fill in this contact form, but stop and show me the filled form before you submit it.
|
|
80
|
+
|
|
81
|
+
What you should see: a real browser window on your desktop doing the clicking, and the page coming back to
|
|
82
|
+
the model as a `ref`-numbered list. The first time it meets a sign-in, a 2FA prompt or a verification page
|
|
83
|
+
it **stops and tells you** — take the window over by hand and it continues from where it left off. The
|
|
84
|
+
logins already in that profile are what make the rest work.
|
|
85
|
+
|
|
86
|
+
Two habits worth having on day one:
|
|
87
|
+
|
|
88
|
+
- **Let it look before it clicks.** Start from `browser_snapshot`; a `ref` is only valid in the result that
|
|
89
|
+
produced it (a stale one fails fast instead of clicking something else).
|
|
90
|
+
- **Say "stop" for the irreversible step.** Submitting, paying, deleting and sending are the points where
|
|
91
|
+
you want to read the form first.
|
|
92
|
+
|
|
67
93
|
### Browser binary
|
|
68
94
|
|
|
69
95
|
The provider probes `chromium` → `chrome` → `msedge` → `edge` channels automatically; any installed Chromium-family browser works with **zero download**. To use Playwright-managed Chromium instead:
|
|
@@ -204,6 +230,9 @@ whose snapshots are long, and both are off by default because both change what t
|
|
|
204
230
|
anchor. Turn it on when a page re-renders often and you resent paying for the same tree twice.
|
|
205
231
|
- **`interactiveOnlyDefault`** (tool row) — makes `browser_snapshot` default to actionable elements
|
|
206
232
|
only; the model can still pass `interactiveOnly: false` per call when it needs the text around them.
|
|
233
|
+
Frames are kept either way: an iframe's accessible name is the embedded widget's own title, which is
|
|
234
|
+
how a challenge widget identifies itself, so pruning it would hide the wall the snapshot is meant to
|
|
235
|
+
reveal.
|
|
207
236
|
|
|
208
237
|
Both are honest trades, not free wins: a diff is smaller but requires the model to hold the previous
|
|
209
238
|
tree, and an interactive-only snapshot hides the prose the model may want to read. Measure before
|
|
@@ -254,7 +283,10 @@ npm run cost # resident prompt cost of the tool schemas, per surface
|
|
|
254
283
|
npm run build # tsc → lib/
|
|
255
284
|
```
|
|
256
285
|
|
|
257
|
-
|
|
286
|
+
Two of those scripts ship with the package and work against an installed copy, because they need nothing
|
|
287
|
+
but `lib/` and the harness: `node scripts/dsh-browser-verify.mjs` (the acceptance suite) and
|
|
288
|
+
`node scripts/doctor.mjs` (re-checks the harness exports after an upgrade). The rest are repository-only
|
|
289
|
+
and run from a checkout — `measure-prompt-cost.mjs` reads `src/`, so it cannot work from a tarball.
|
|
258
290
|
|
|
259
291
|
### Test suites
|
|
260
292
|
|
|
@@ -336,6 +368,7 @@ Written for people deciding whether to hand an agent their browser:
|
|
|
336
368
|
- **Extract needs a dedicated model route** — it does not reuse the main request's route; misconfiguration fails loudly at call time.
|
|
337
369
|
- **JS-driven navigations are not statically checkable** — `allowedDomains` covers `browser_navigate`, `browser_open_tab`, every click path (`browser_click`: ref, `precise`, raw-coordinate and `mode:"hover"` modes) and `browser_switch_tab`, but a button whose handler runs `location.href = …` can still leave the allowed hosts, and a tab that is already sitting on a disallowed host stays open (the agent simply refuses to drive it); deploy an external network guard for hard isolation.
|
|
338
370
|
- **Stability waits are bounded by design** — a perpetually animating element falls back to a forced click on its current rect rather than stalling; a page that never settles is snapshotted after the cap. This is intentional: the agent must never hang.
|
|
371
|
+
- **Challenge detection reads the responses it has actually seen** — the response-header tier applies to a document response this session observed after it attached to the tab. A tab that was already sitting on a challenge page when the session took it over falls through to the widget and wording tiers and, failing those, to the no-progress backstop: it reports a *possible* wall instead of guessing the vendor.
|
|
339
372
|
|
|
340
373
|
### Ref and lifecycle safety (fixed)
|
|
341
374
|
|
package/SECURITY.md
CHANGED
|
@@ -70,8 +70,8 @@ Nothing else.
|
|
|
70
70
|
|
|
71
71
|
## 2. What the scanner reports, and which gate contains it
|
|
72
72
|
|
|
73
|
-
dsh.so runs `dsh-plugin-vet` over the whole repository. Their scan of **
|
|
74
|
-
**1 Critical, 3 Warning,
|
|
73
|
+
dsh.so runs `dsh-plugin-vet` over the whole repository. Their scan of **0.6.0 (2026-10-16)** reports
|
|
74
|
+
**1 Critical, 3 Warning, 25 Info** across 80 files — and it is **right about the capability**. Here is
|
|
75
75
|
each one, with the gate that decides whether it is reachable at all.
|
|
76
76
|
|
|
77
77
|
> For the record: their v0.4.9 scan — before this plugin was split into responsibility-scoped modules —
|
|
@@ -83,7 +83,7 @@ each one, with the gate that decides whether it is reachable at all.
|
|
|
83
83
|
| Critical | `new Function('return (' + expression + ')')` — reported in `lib/session.js` (source: `src/session.ts`) | The body of **`browser_evaluate`**: it wraps your expression and runs it in the page. This is what "run JavaScript in the page" means; there is no way to implement it without dynamic evaluation. | Only when `allowEvaluate: true`. **The package ships `false`, so the tool is not registered.** |
|
|
84
84
|
| Warning | `fs.writeFileSync` of a JSON blob (`lib/login-state.js`, `lib/runtime-state.js`) | Exporting the login state (`cookies` + `localStorage`) so a closed window can be reopened still logged in, and the small runtime state file behind the Settings switches. | Always — it is how login persistence works. The files live under your DSH home. |
|
|
85
85
|
| Warning | HTTP request to a bare IP (`scripts/dsh-browser-verify.mjs`) | The acceptance suite starts a **local** `http.server` on `127.0.0.1` with a fixture page, so `npm run verify` needs no DSH and no internet. | Only when you run that script. |
|
|
86
|
-
| Info ×
|
|
86
|
+
| Info ×25 | `localStorage` reads, `process.env` reads, hardcoded `127.0.0.1`, dev-only probe scripts | Restoring login state; test hooks (`DSH_BROWSER_STATE_FILE`, `PLAYWRIGHT_DOWNLOAD_HOST`); and scripts under `scripts/analysis/` that the scanner itself marks *"Dev-only file — not executed at install/runtime"*. | — |
|
|
87
87
|
|
|
88
88
|
Two honest notes:
|
|
89
89
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挑战页签名表:把「这页是不是拦截页」的判据放在厂商自有信号上,而不是页面文案上。
|
|
3
|
+
*
|
|
4
|
+
* 为什么分层,以及为什么是这个顺序(稳定性递减):
|
|
5
|
+
*
|
|
6
|
+
* 1. 响应头是厂商对外的 API。Cloudflare 专门发文教用户读 `cf-mitigated: challenge`
|
|
7
|
+
* 来识别挑战页,AWS WAF 同理(`x-amzn-waf-action: challenge`)。厂商改一个字的代价
|
|
8
|
+
* 是打断所有客户的集成,不是打断我们一个插件。
|
|
9
|
+
* 2. 厂商控件的无障碍名是次稳的:reCAPTCHA / hCaptcha / Turnstile 的 iframe 标题就是
|
|
10
|
+
* 他们自己的产品名,出现在每一张用了他们的页面上。
|
|
11
|
+
* 3. 文案最脆。FlareSolverr 就是死在依赖页面 title 和元素 class 上(上游 issue #1734:
|
|
12
|
+
* 「元素的 class 和 id 被改过,所以现有的检测规则失效」)。
|
|
13
|
+
*
|
|
14
|
+
* 因此新增一家厂商 = 在这张表里加一条签名 + 在 tests/challenge.test.ts 加一条用例,
|
|
15
|
+
* 判定逻辑本身不用改。
|
|
16
|
+
* @module dsh-browser-playwright-codex/challenge-signatures
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* How a signature was proven. `response-header` is the vendor's own API,
|
|
20
|
+
* `widget` is the vendor's own widget, `wording` is the fallback we trust least.
|
|
21
|
+
*/
|
|
22
|
+
export type ChallengeTier = 'response-header' | 'widget' | 'wording';
|
|
23
|
+
/**
|
|
24
|
+
* One response-header rule. Matching is case-insensitive; a rule with neither
|
|
25
|
+
* `equals` nor `contains` fires on the header's presence alone.
|
|
26
|
+
*/
|
|
27
|
+
export interface HeaderRule {
|
|
28
|
+
/** Exact header name. Ignored when `namePrefix` is set. */
|
|
29
|
+
readonly name: string;
|
|
30
|
+
/** Exact value the header must carry. */
|
|
31
|
+
readonly equals?: string;
|
|
32
|
+
/** Substring the value must contain. */
|
|
33
|
+
readonly contains?: string;
|
|
34
|
+
}
|
|
35
|
+
/** One vendor's interception signature. */
|
|
36
|
+
export interface ChallengeSignature {
|
|
37
|
+
/** Stable id, used in tests and diagnostics. */
|
|
38
|
+
readonly id: string;
|
|
39
|
+
/** Engine label shown to the model. */
|
|
40
|
+
readonly engine: string;
|
|
41
|
+
/** One-clause explanation of what is happening. */
|
|
42
|
+
readonly hint: string;
|
|
43
|
+
/**
|
|
44
|
+
* Header rules proven against the MAIN document response. These are the
|
|
45
|
+
* vendor's published detection contract, so they are checked first.
|
|
46
|
+
*/
|
|
47
|
+
readonly headers?: readonly HeaderRule[];
|
|
48
|
+
/** Case-insensitive header-name prefixes (e.g. DataDome's `x-datadome*`). */
|
|
49
|
+
readonly headerPrefixes?: readonly string[];
|
|
50
|
+
/**
|
|
51
|
+
* Accessible-name tokens only this vendor's widget carries. On a non-iframe
|
|
52
|
+
* node a token must ALSO be corroborated by {@link WIDGET_CORROBORATION}.
|
|
53
|
+
*/
|
|
54
|
+
readonly widgetTokens?: readonly RegExp[];
|
|
55
|
+
/**
|
|
56
|
+
* Wording this vendor puts in the TITLE of its own interstitial. Vendor
|
|
57
|
+
* wording is stronger than generic wording, so it is checked first and keeps
|
|
58
|
+
* the vendor's name on the result.
|
|
59
|
+
*/
|
|
60
|
+
readonly titleWording?: readonly RegExp[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Wording that corroborates a widget token found on a non-iframe node.
|
|
64
|
+
*
|
|
65
|
+
* Shared by every vendor on purpose, and it contains no vendor token: three of
|
|
66
|
+
* the tokens below ("reCAPTCHA", "FunCaptcha", "px-captcha") literally contain
|
|
67
|
+
* the word "captcha", so a list that accepted "captcha" would let the token
|
|
68
|
+
* corroborate itself and the guard would be vacuous.
|
|
69
|
+
*/
|
|
70
|
+
export declare const WIDGET_CORROBORATION: readonly RegExp[];
|
|
71
|
+
/**
|
|
72
|
+
* Every vendor we can prove from machine signals. Adding a vendor here is the
|
|
73
|
+
* whole change: the detector walks the table, it does not know any of these names.
|
|
74
|
+
*/
|
|
75
|
+
export declare const CHALLENGE_SIGNATURES: readonly ChallengeSignature[];
|
|
76
|
+
/**
|
|
77
|
+
* Wording a challenge page puts in its TITLE, with no vendor to attribute it to.
|
|
78
|
+
* A title is short and authored by the interceptor, so a match here is safe on
|
|
79
|
+
* its own. Kept combinatorial ("verification" + "human/safety/…") rather than
|
|
80
|
+
* one phrase per language, so a reworded page still matches without a new
|
|
81
|
+
* signature.
|
|
82
|
+
*/
|
|
83
|
+
export declare const WORDING_TITLE: readonly RegExp[];
|
|
84
|
+
/**
|
|
85
|
+
* Wording a challenge page puts on a CONTROL (a button, a link, an input).
|
|
86
|
+
* Command forms only: a help page that explains captchas says "验证码说明",
|
|
87
|
+
* a challenge says "请输入验证码". The bare word for captcha is deliberately NOT
|
|
88
|
+
* here — it is gated on a control role in {@link CAPTCHA_ROLES} instead, so a
|
|
89
|
+
* link to the documentation cannot fire this tier.
|
|
90
|
+
*/
|
|
91
|
+
export declare const WORDING_COMMAND: readonly RegExp[];
|
|
92
|
+
/**
|
|
93
|
+
* Controls that mention verification but are NOT a bot wall: sending yourself a
|
|
94
|
+
* code is an ordinary sign-in step, and labelling it an anti-bot challenge
|
|
95
|
+
* would send the user looking for a wall that is not there.
|
|
96
|
+
*/
|
|
97
|
+
export declare const WORDING_EXCLUDE: readonly RegExp[];
|
|
98
|
+
/** The word for "captcha" itself; only counts on a {@link CAPTCHA_ROLES} node. */
|
|
99
|
+
export declare const CAPTCHA_WORD: RegExp;
|
|
100
|
+
/**
|
|
101
|
+
* Roles that make {@link CAPTCHA_WORD} count. A captcha input, image or button
|
|
102
|
+
* is a challenge; a link or heading named "验证码说明" is documentation, and
|
|
103
|
+
* firing on it would send the user chasing a wall that is not there.
|
|
104
|
+
*/
|
|
105
|
+
export declare const CAPTCHA_ROLES: ReadonlySet<string>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挑战页签名表:把「这页是不是拦截页」的判据放在厂商自有信号上,而不是页面文案上。
|
|
3
|
+
*
|
|
4
|
+
* 为什么分层,以及为什么是这个顺序(稳定性递减):
|
|
5
|
+
*
|
|
6
|
+
* 1. 响应头是厂商对外的 API。Cloudflare 专门发文教用户读 `cf-mitigated: challenge`
|
|
7
|
+
* 来识别挑战页,AWS WAF 同理(`x-amzn-waf-action: challenge`)。厂商改一个字的代价
|
|
8
|
+
* 是打断所有客户的集成,不是打断我们一个插件。
|
|
9
|
+
* 2. 厂商控件的无障碍名是次稳的:reCAPTCHA / hCaptcha / Turnstile 的 iframe 标题就是
|
|
10
|
+
* 他们自己的产品名,出现在每一张用了他们的页面上。
|
|
11
|
+
* 3. 文案最脆。FlareSolverr 就是死在依赖页面 title 和元素 class 上(上游 issue #1734:
|
|
12
|
+
* 「元素的 class 和 id 被改过,所以现有的检测规则失效」)。
|
|
13
|
+
*
|
|
14
|
+
* 因此新增一家厂商 = 在这张表里加一条签名 + 在 tests/challenge.test.ts 加一条用例,
|
|
15
|
+
* 判定逻辑本身不用改。
|
|
16
|
+
* @module dsh-browser-playwright-codex/challenge-signatures
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Wording that corroborates a widget token found on a non-iframe node.
|
|
20
|
+
*
|
|
21
|
+
* Shared by every vendor on purpose, and it contains no vendor token: three of
|
|
22
|
+
* the tokens below ("reCAPTCHA", "FunCaptcha", "px-captcha") literally contain
|
|
23
|
+
* the word "captcha", so a list that accepted "captcha" would let the token
|
|
24
|
+
* corroborate itself and the guard would be vacuous.
|
|
25
|
+
*/
|
|
26
|
+
export const WIDGET_CORROBORATION = Object.freeze([
|
|
27
|
+
/verify|challenge|robot|human|verification/i,
|
|
28
|
+
/验证|校验|人机|真人/,
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Every vendor we can prove from machine signals. Adding a vendor here is the
|
|
32
|
+
* whole change: the detector walks the table, it does not know any of these names.
|
|
33
|
+
*/
|
|
34
|
+
export const CHALLENGE_SIGNATURES = Object.freeze([
|
|
35
|
+
{
|
|
36
|
+
id: 'cloudflare-challenge',
|
|
37
|
+
engine: 'Cloudflare',
|
|
38
|
+
hint: 'Cloudflare answered with a challenge page instead of the page itself',
|
|
39
|
+
headers: [{ name: 'cf-mitigated', equals: 'challenge' }],
|
|
40
|
+
// The interstitial's own title, in both languages. Only reached when the
|
|
41
|
+
// header is missing (a cached page, a prefetch, a client-side fetch).
|
|
42
|
+
titleWording: [
|
|
43
|
+
/just a moment|attention required|checking (your )?browser|enable javascript and cookies|cf-chl-/i,
|
|
44
|
+
/请稍候|验证你的浏览器|正在进行安全验证/,
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'aws-waf',
|
|
49
|
+
engine: 'AWS WAF',
|
|
50
|
+
hint: 'AWS WAF answered with a challenge instead of the page itself',
|
|
51
|
+
headers: [{ name: 'x-amzn-waf-action', equals: 'challenge' }],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'imperva-incapsula',
|
|
55
|
+
engine: 'Imperva',
|
|
56
|
+
hint: 'Imperva (Incapsula) served this response from its edge, which is where its bot challenges live',
|
|
57
|
+
// Presence only: X-IInfo is an opaque per-response trace that Imperva adds
|
|
58
|
+
// to everything it fronts, so its value carries no comparable state.
|
|
59
|
+
headers: [{ name: 'x-iinfo' }],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'datadome',
|
|
63
|
+
engine: 'DataDome',
|
|
64
|
+
hint: 'DataDome attached its own metadata to this response',
|
|
65
|
+
headerPrefixes: ['x-datadome'],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'recaptcha',
|
|
69
|
+
engine: 'captcha',
|
|
70
|
+
hint: 'the page embedded a reCAPTCHA widget, which automation must not attempt to solve',
|
|
71
|
+
widgetTokens: [/recaptcha/i],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'hcaptcha',
|
|
75
|
+
engine: 'captcha',
|
|
76
|
+
hint: 'the page embedded an hCaptcha widget, which automation must not attempt to solve',
|
|
77
|
+
widgetTokens: [/hcaptcha/i],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'turnstile',
|
|
81
|
+
engine: 'Cloudflare',
|
|
82
|
+
hint: 'Cloudflare Turnstile is asking the visitor to prove they are human',
|
|
83
|
+
widgetTokens: [/turnstile/i, /widget containing a cloudflare security challenge/i],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'arkose-funcaptcha',
|
|
87
|
+
engine: 'captcha',
|
|
88
|
+
hint: 'the page embedded an Arkose / FunCaptcha challenge, which automation must not attempt to solve',
|
|
89
|
+
widgetTokens: [/arkoselabs/i, /funcaptcha/i, /arkose/i],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'perimeterx-human',
|
|
93
|
+
engine: 'PerimeterX',
|
|
94
|
+
hint: 'PerimeterX is asking the visitor to press and hold, which automation must not attempt',
|
|
95
|
+
widgetTokens: [/px-captcha/i, /perimeterx/i, /press (and|&) hold/i],
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
/**
|
|
99
|
+
* Wording a challenge page puts in its TITLE, with no vendor to attribute it to.
|
|
100
|
+
* A title is short and authored by the interceptor, so a match here is safe on
|
|
101
|
+
* its own. Kept combinatorial ("verification" + "human/safety/…") rather than
|
|
102
|
+
* one phrase per language, so a reworded page still matches without a new
|
|
103
|
+
* signature.
|
|
104
|
+
*/
|
|
105
|
+
export const WORDING_TITLE = Object.freeze([
|
|
106
|
+
/verify (you are|you're|that you are) (a )?human|are you a robot/i,
|
|
107
|
+
/人机验证|验证您(是|为)人类|真人验证|安全验证|身份验证|访客验证/,
|
|
108
|
+
/(滑块|拼图)(验证|校验)/,
|
|
109
|
+
]);
|
|
110
|
+
/**
|
|
111
|
+
* Wording a challenge page puts on a CONTROL (a button, a link, an input).
|
|
112
|
+
* Command forms only: a help page that explains captchas says "验证码说明",
|
|
113
|
+
* a challenge says "请输入验证码". The bare word for captcha is deliberately NOT
|
|
114
|
+
* here — it is gated on a control role in {@link CAPTCHA_ROLES} instead, so a
|
|
115
|
+
* link to the documentation cannot fire this tier.
|
|
116
|
+
*/
|
|
117
|
+
export const WORDING_COMMAND = Object.freeze([
|
|
118
|
+
/(请|点击|输入|拖动|按住|滑动|完成|进行|通过)[^。!?]{0,8}(验证|校验|拼图|滑块)/,
|
|
119
|
+
/(验证|校验)(您|你)(是|为)/,
|
|
120
|
+
/(是|为)(真人|人类)(吗|?|\?)?$/,
|
|
121
|
+
/(拖动|按住|滑动)[^。!?]{0,8}(滑块|拼图)/,
|
|
122
|
+
/(verify|click|press|complete|solve|check|confirm)[^.!?]{0,24}(human|robot|browser|captcha|challenge|you are)/i,
|
|
123
|
+
/i'?m not a robot|not a robot/i,
|
|
124
|
+
]);
|
|
125
|
+
/**
|
|
126
|
+
* Controls that mention verification but are NOT a bot wall: sending yourself a
|
|
127
|
+
* code is an ordinary sign-in step, and labelling it an anti-bot challenge
|
|
128
|
+
* would send the user looking for a wall that is not there.
|
|
129
|
+
*/
|
|
130
|
+
export const WORDING_EXCLUDE = Object.freeze([
|
|
131
|
+
/(获取|发送|重新获取|重新发送|接收)[^。!?]{0,4}(验证码|校验码|短信码)/,
|
|
132
|
+
/send (the )?(code|sms)|resend/i,
|
|
133
|
+
]);
|
|
134
|
+
/** The word for "captcha" itself; only counts on a {@link CAPTCHA_ROLES} node. */
|
|
135
|
+
export const CAPTCHA_WORD = /captcha|验证码/i;
|
|
136
|
+
/**
|
|
137
|
+
* Roles that make {@link CAPTCHA_WORD} count. A captcha input, image or button
|
|
138
|
+
* is a challenge; a link or heading named "验证码说明" is documentation, and
|
|
139
|
+
* firing on it would send the user chasing a wall that is not there.
|
|
140
|
+
*/
|
|
141
|
+
export const CAPTCHA_ROLES = new Set([
|
|
142
|
+
'textbox',
|
|
143
|
+
'searchbox',
|
|
144
|
+
'combobox',
|
|
145
|
+
'spinbutton',
|
|
146
|
+
'button',
|
|
147
|
+
'checkbox',
|
|
148
|
+
'radio',
|
|
149
|
+
'slider',
|
|
150
|
+
'img',
|
|
151
|
+
'image',
|
|
152
|
+
'iframe',
|
|
153
|
+
'figure',
|
|
154
|
+
]);
|
package/lib/challenge.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 挑战页识别与「无进展」兜底。
|
|
3
|
+
*
|
|
4
|
+
* 判定顺序 = 稳定性顺序:先读厂商自己的响应头(他们的公开 API),再看厂商控件的无障碍名,
|
|
5
|
+
* 最后才看文案。三层都不认识的时候不猜厂商,改问一个与厂商无关的问题——「这个动作之后
|
|
6
|
+
* 页面变了吗」。所以新出的验证方式没有签名也能被接住,代价只是多问用户一句。
|
|
3
7
|
* @module dsh-browser-playwright-codex/challenge
|
|
4
8
|
*/
|
|
5
|
-
import type { ChallengeMatch, ChallengeSnapshot } from './tool-types.ts';
|
|
9
|
+
import type { ChallengeMatch, ChallengeNode, ChallengeSnapshot } from './tool-types.ts';
|
|
6
10
|
/**
|
|
7
|
-
* Detect anti-bot / human-verification
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Detect an anti-bot / human-verification surface from the MAIN document
|
|
12
|
+
* response metadata, the page's accessible names and, as a last resort, its
|
|
13
|
+
* wording. We never try to solve one: the correct move for an agent is to hand
|
|
14
|
+
* it to the human who can see the open window.
|
|
15
|
+
* @param snapshot - title, url, accessible tree and (when available) the main document response.
|
|
16
|
+
* @returns the matched engine with its tier and the concrete evidence, else null.
|
|
13
17
|
*/
|
|
14
18
|
export declare function detectChallenge(snapshot: ChallengeSnapshot): ChallengeMatch | null;
|
|
15
19
|
/**
|
|
@@ -18,3 +22,39 @@ export declare function detectChallenge(snapshot: ChallengeSnapshot): ChallengeM
|
|
|
18
22
|
* should pause and ask the user to complete it by hand, then re-snapshot.
|
|
19
23
|
*/
|
|
20
24
|
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.";
|
|
25
|
+
/**
|
|
26
|
+
* Appended after an action that left the page byte-for-byte unchanged on a page
|
|
27
|
+
* with almost nothing else to interact with. Worded as a guess, because that is
|
|
28
|
+
* what it is: it is the shape an unrecognised wall has, and the whole point of
|
|
29
|
+
* this tier is to cover the vendors we have no signature for.
|
|
30
|
+
*/
|
|
31
|
+
export declare const NO_PROGRESS_NOTE = "[no visible change] that action left the page identical \u2014 same URL, same title, same elements \u2014 and the page offers almost nothing else to click. This is a guess, not a diagnosis: it is what an unrecognised anti-bot wall looks like. Take one browser_snapshot to confirm; if the page still looks unchanged, stop and tell the user what you were trying to do and ask them to look at the open window, instead of retrying the same action.";
|
|
32
|
+
/** A page with at most this many actionable elements counts as "nearly empty". */
|
|
33
|
+
export declare const NO_PROGRESS_MAX_REFS = 3;
|
|
34
|
+
/**
|
|
35
|
+
* Identity of one page state: URL, title, actionable-element count and the
|
|
36
|
+
* accessible labels. Two captures with the same fingerprint look the same to a
|
|
37
|
+
* human glancing at the window, which is exactly the question this tier asks.
|
|
38
|
+
* @param input - the slice of a snapshot the backstop compares.
|
|
39
|
+
* @returns an opaque fingerprint string.
|
|
40
|
+
*/
|
|
41
|
+
export declare function snapshotFingerprint(input: {
|
|
42
|
+
readonly url?: string | undefined;
|
|
43
|
+
readonly title?: string | undefined;
|
|
44
|
+
readonly refs?: number | undefined;
|
|
45
|
+
readonly nodes?: readonly ChallengeNode[] | undefined;
|
|
46
|
+
}): string;
|
|
47
|
+
/**
|
|
48
|
+
* The no-progress backstop: a vendor we have never seen leaves no signature, so
|
|
49
|
+
* we stop asking "which wall is this" and ask "did anything happen". Deliberately
|
|
50
|
+
* conservative — it needs an identical page AND (a nearly empty page, or a second
|
|
51
|
+
* identical result), so an ordinary click that is not meant to change anything
|
|
52
|
+
* stays quiet.
|
|
53
|
+
* @param input - whether an action just ran, the identical-capture streak, and the ref count.
|
|
54
|
+
* @returns the advisory note, or null when the page is still plausibly alive.
|
|
55
|
+
*/
|
|
56
|
+
export declare function detectNoProgress(input: {
|
|
57
|
+
readonly afterAction: boolean;
|
|
58
|
+
readonly unchangedStreak: number;
|
|
59
|
+
readonly refs: number;
|
|
60
|
+
}): string | null;
|