@optima-chat/dev-skills 0.16.7 → 0.16.9
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/.claude/commands/logs.md +1 -1
- package/.claude/commands/query-db.md +6 -1
- package/.claude/skills/discount-codes/SKILL.md +4 -4
- package/.claude/skills/generate-test-token/SKILL.md +8 -4
- package/.claude/skills/logs/SKILL.md +5 -3
- package/.claude/skills/show-env/SKILL.md +1 -1
- package/.claude/skills/yzsgo-e2e/SKILL.md +24 -0
- package/.claude/skills/yzsgo-e2e/SYNC.md +64 -2
- package/.claude/skills/yzsgo-e2e/chat_driver.py +1070 -98
- package/.claude/skills/yzsgo-e2e/pull_wire.py +39 -8
- package/.claude/skills/yzsgo-e2e/run_e2e.py +24 -3
- package/.codex/skills/generate-test-token/SKILL.md +3 -2
- package/.codex/skills/logs/SKILL.md +4 -2
- package/.codex/skills/show-env/SKILL.md +4 -2
- package/.codex/skills/yzsgo-e2e/SKILL.md +24 -0
- package/.codex/skills/yzsgo-e2e/SYNC.md +64 -2
- package/.codex/skills/yzsgo-e2e/chat_driver.py +1070 -98
- package/.codex/skills/yzsgo-e2e/pull_wire.py +39 -8
- package/.codex/skills/yzsgo-e2e/run_e2e.py +24 -3
- package/AGENTS.md +1 -1
- package/bin/cli.js +3 -3
- package/bin/helpers/billing-http.ts +12 -3
- package/bin/helpers/cn-deploy.ts +2 -1
- package/bin/helpers/db-utils.ts +8 -5
- package/bin/helpers/generate-test-token.ts +40 -3
- package/bin/helpers/verify-health.ts +77 -29
- package/dist/bin/helpers/billing-http.js +9 -1
- package/dist/bin/helpers/cn-deploy.js +2 -1
- package/dist/bin/helpers/db-utils.js +8 -2
- package/dist/bin/helpers/generate-test-token.js +40 -3
- package/dist/bin/helpers/verify-health.js +83 -27
- package/package.json +1 -1
|
@@ -11,21 +11,540 @@
|
|
|
11
11
|
|
|
12
12
|
用法:
|
|
13
13
|
from chat_driver import ChatDriver
|
|
14
|
-
d = ChatDriver().attach()
|
|
14
|
+
d = ChatDriver().attach(ziniao="27884995544032") # **声明**这一轮驱动哪个 profile(#611;锁已撤,见下)
|
|
15
|
+
d = ChatDriver().attach(ziniao=None, reason="不碰任何店") # 显式声明;**理由必填**
|
|
16
|
+
d = ChatDriver().attach(own_tab=True, ziniao=None, reason="profile 在逐条用例那层声明") # 并行:自己开 tab
|
|
15
17
|
d.ensure_installed(["briefing-store-status"])
|
|
16
18
|
d.new_conversation()
|
|
17
19
|
r = d.send_and_wait("跑一下老赵店的运营简报", timeout=300)
|
|
18
20
|
print(r["text"], r["elapsed"], r["tool_trace"])
|
|
19
21
|
d.close()
|
|
22
|
+
|
|
23
|
+
🔴 并发形态(2026-09-09 真机坐实,别再按「同一时间只能一个对话」写代码):
|
|
24
|
+
鸭嘴兽已支持并发任务 —— **一个浏览器 tab = 一个独立 gateway session**(agentic-chat ac#957,
|
|
25
|
+
sessionId 存 sessionStorage `optima:gw:sid`,天然 per-tab;F5 靠 sessionAttachProvider attach 回同一个)。
|
|
26
|
+
· 跨 tab = 可并行:实测两 tab 的 LLM 调用在服务端 wire 上真重叠约 3.0s。
|
|
27
|
+
· 同 tab 内 = 仍串行:一个 session 里另一个对话在跑会被 CONCURRENT_CONVERSATION_BLOCKED 拒。
|
|
28
|
+
· 并发上限按 plan 分档(free 1 / starter 2 / pro 4 / enterprise 20),超了 CONCURRENCY_LIMIT_EXCEEDED。
|
|
29
|
+
⇒ 想并行跑 N 个用例,就开 N 个 tab、每 tab 一个 ChatDriver,**不是**在一个 tab 里开 N 个对话。
|
|
30
|
+
|
|
31
|
+
⚠️ 两个 driver 落到**同一个 tab** 会静默串台(2026-09-09 负向对照实证):两个线程往同一个
|
|
32
|
+
textarea 写字,后写的覆盖先写的,**只有一条消息真到服务端**(wire 里那个 session 只有 1 个对话),
|
|
33
|
+
但两边 send() 都返回 True、无 toast、无 console 报错,双方都抓到同一份回复 ——
|
|
34
|
+
A 用例被判在 B 的回复上,harness 任何信号都拦不住。所以并行时必须 `attach(own_tab=True)`。
|
|
20
35
|
"""
|
|
21
36
|
from __future__ import annotations
|
|
22
37
|
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import subprocess
|
|
23
42
|
import time
|
|
24
43
|
|
|
25
44
|
from playwright.sync_api import sync_playwright
|
|
26
45
|
|
|
27
|
-
|
|
46
|
+
# cn-stage 前端是 app.stage.optima.chat(同一套页面),用 YZSGO_CHAT_URL 覆盖;缺省 cn-prod。
|
|
47
|
+
CHAT_URL = os.environ.get("YZSGO_CHAT_URL", "https://www.yzsgo.com/zh-HK/chat")
|
|
28
48
|
SEARCH_PH = "搜尋技能..."
|
|
49
|
+
# 本 tab 认领的 gateway sessionId 存这里(agentic-chat src/lib/session/tabSessionClaim.ts)。
|
|
50
|
+
# sessionStorage 天然 per-tab —— 这是「一 tab 一 session」的物理依据,也是并行隔离的校验口。
|
|
51
|
+
TAB_SID_KEY = "optima:gw:sid"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TabSessionUnavailable(RuntimeError):
|
|
55
|
+
"""新开的 tab 没能拿到**独立**的 gateway session —— 并行不安全,调用方必须退回串行。
|
|
56
|
+
|
|
57
|
+
两种触发(都实证过):
|
|
58
|
+
① claim 超时:页面没在 timeout 内写 sessionStorage(没登录 / 连不上 gateway);
|
|
59
|
+
② sid 与已有 tab 撞车:该环境 `NEXT_PUBLIC_MULTI_TAB_SESSION` 没开(build-time flag,
|
|
60
|
+
默认关,见 agentic-chat src/lib/feature-flags.ts),新 tab 会被 gateway 的
|
|
61
|
+
createOrResolveSession 并回同一个 session。cn-prod 已开、cn-stage 未验。
|
|
62
|
+
绝不能降级成「那就共用一个 tab 吧」——那正是静默串台的成因。"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# preflight 只问一件事、只认一种答案:让 agent 原样贴 `browser-cli ziniao doctor` 的输出。
|
|
66
|
+
# **别再让它用自然语言回答「连没连上」** —— 2026-09-12 两种翻车都出在解析自由文本上:
|
|
67
|
+
# · 假阴性:服务端回复完整(11 个 profile 都在),driver 侧解析到 0 个 → 整轮 0s blocked;
|
|
68
|
+
# · 假阳性:抓到 5 万字页面噪声,从里面正则捞出 4 个「27 开头的数字」就判连着,
|
|
69
|
+
# 而同一时刻 doctor 明写 `✗ 桌面应用: 鸭嘴兽助手桌面应用未连接`。
|
|
70
|
+
_PREFLIGHT_PROMPT = (
|
|
71
|
+
"只跑这两条命令,别加载任何 skill、别开浏览器、别做任何店铺操作,把**原始输出原样贴出来**"
|
|
72
|
+
"(不要解读、不要改写、不要转成表格):\n"
|
|
73
|
+
"1) `browser-cli ziniao doctor`\n"
|
|
74
|
+
"2) `browser-cli ziniao profiles`")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_preflight(text: str) -> dict:
|
|
79
|
+
"""从 preflight 回复里判桌面端连没连上。**纯函数,单测覆盖。**
|
|
80
|
+
|
|
81
|
+
返回 {ok, stores, shop_count},其中 `ok` 是**三态**:
|
|
82
|
+
· True —— 看到 `✓ 桌面应用`(权威肯定)
|
|
83
|
+
· False —— 看到 `✗ 桌面应用`(权威否定)
|
|
84
|
+
· None —— 两个都没看到 = **没抓到**,不是「没连上」
|
|
85
|
+
|
|
86
|
+
三态是关键:旧实现把「没抓到」和「没连上」压成同一个 False,于是一次抓取抖动就让整轮
|
|
87
|
+
e2e 一个用例都不驱动(2026-09-12 实测:服务端回复里 11 个 profile 齐全,driver 判「桌面未连」)。
|
|
88
|
+
现在只有**权威否定**才拦;抓不到交给调用方决定(run.py 选择照跑并打警告)。
|
|
89
|
+
|
|
90
|
+
店铺清单只从 `ziniao profiles` 的行首格式取(`<id> <名字>`),**不再全文捞 27 开头的数字**
|
|
91
|
+
——页面噪声里遍地是这种数字,那正是假阳性的来源。
|
|
92
|
+
"""
|
|
93
|
+
t = text or ""
|
|
94
|
+
ok = True if "✓ 桌面应用" in t else (False if "✗ 桌面应用" in t else None)
|
|
95
|
+
# ` 27884995544032 跨境-马来西亚-鸭嘴兽 [TikTok Shop-...]`:id 后至少两个空白再跟店名
|
|
96
|
+
stores = re.findall(r"(\d{14,15})\s{2,}([^\s\[][^\[\n]{1,38})", t)
|
|
97
|
+
m = re.search(r"店铺列表[::]\s*(\d+)\s*个", t)
|
|
98
|
+
return {"ok": ok, "stores": [(i, n.strip()) for i, n in stores],
|
|
99
|
+
"shop_count": int(m.group(1)) if m else None}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── 平台侧风控证据(#331)────────────────────────────────────────────────
|
|
103
|
+
# 判「撞到风控」**只能**拿平台/脚本自己产出的东西当证据。面板文本不行:它含着被回显的
|
|
104
|
+
# 用户 prompt,而按**停手纪律**写的 prompt 几乎一定带「遇到风控/验证码就停下」这句话
|
|
105
|
+
# ⇒ 越守纪律的用例越容易被判成假 blocked(#331 实证:同一条用例一天内误导两次,
|
|
106
|
+
# 真相是脚本级 hard failure `✗ Script failed: 变体名 Warna 未设上`,一次风控都没撞到)。
|
|
107
|
+
#
|
|
108
|
+
# 这里扫的是**工具结果体**(脚本 stdout),不是面板文本。
|
|
109
|
+
#
|
|
110
|
+
# 🔴 为什么必须挨着 `✗ Script failed` 才算:下面这些串**在 skill 源码里也是字面量**
|
|
111
|
+
# (`listing-product-on-tiktok/script.py` 的 `_CAPTCHA_PRESENT` 探针正则、
|
|
112
|
+
# `managing-affiliate-program/script.py` 的 `gate_err` 文案)。Agent 中途 `read` 一下
|
|
113
|
+
# 脚本源码,结果体里就带上了这些词 —— 那是**源码**不是**回执**。只认「脚本失败回执里
|
|
114
|
+
# 紧跟着说自己撞了风控」这一种形态,宁可漏也不误报(漏 → unknown → uncertain,仍要人核)。
|
|
115
|
+
#
|
|
116
|
+
# ⚠️ **本信号未经真机验证**:272 份历史日志里一次真风控停手都没记到(两次判成风控的都是假的)。
|
|
117
|
+
# 它在真机上到底会不会亮,要等下一次真撞到风控才知道。所以判定方**不许**把
|
|
118
|
+
# 「risk_hits==0」当成「没撞风控」—— 见 run.py `classify_risk_control` 的三态。
|
|
119
|
+
_RISK_RECEIPT_MARKS = (
|
|
120
|
+
"平台风控拼图验证", "风控铁律", "探到风控/验证码",
|
|
121
|
+
"请完成下列验证后继续", "請完成下列驗證後繼續", "拖动完成上方拼图", "拖動完成上方拼圖",
|
|
122
|
+
"完成安全验证", "滑动验证", "点选验证", "Slide to verify", "Verify to continue",
|
|
123
|
+
)
|
|
124
|
+
_RISK_RECEIPT_WINDOW = 300 # 拍的,无实测支撑:`✗ Script failed: <msg>` 的 msg 长度量级
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def count_platform_risk_receipts(res_text: str) -> int:
|
|
128
|
+
"""工具结果体里有几处**平台侧**风控停手回执(`✗ Script failed:` 后紧跟风控措辞)。
|
|
129
|
+
|
|
130
|
+
只回整数,不回原文 —— 结果体可能含 env 明文密钥/JWT(gw#2350),同 `_scrape_tool_trace`。
|
|
131
|
+
"""
|
|
132
|
+
t = res_text or ""
|
|
133
|
+
n = 0
|
|
134
|
+
for m in re.finditer(r"✗ Script failed", t):
|
|
135
|
+
seg = t[m.end():m.end() + _RISK_RECEIPT_WINDOW]
|
|
136
|
+
if any(k in seg for k in _RISK_RECEIPT_MARKS):
|
|
137
|
+
n += 1
|
|
138
|
+
return n
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── 平台侧「每账号一个活动会话」回执(#449 第四档)──────────────────────
|
|
143
|
+
# 那条绊线(`session_limit_regressed`)原来扫**整轮面板文本**,而面板里含被回显的 prompt、
|
|
144
|
+
# agent 的否定句和推测。它已经做了 strip_echo(#390,挡回显),**但挡不住否定句和推测**。
|
|
145
|
+
#
|
|
146
|
+
# 🔴 这一档比风控那档更该严 —— 代价不对称:
|
|
147
|
+
# · 风控误报 = 白叫店主看一眼;
|
|
148
|
+
# · **这一档误报 = 让人把已经拆掉的按店互斥改回去**,
|
|
149
|
+
# 等于拿一次误判去撤销一个有 wire 实证的修复(`optima-browser-use#345` / #383)。
|
|
150
|
+
#
|
|
151
|
+
# ⚠️ **而这两个标记本身没有任何真实样本**:2026-09-13 自己数过 **277 份** e2e 日志,
|
|
152
|
+
# 两个标记**各 0 命中**(标记是照旧文档字符串抄的)。⇒ 即使只认工具结果体,
|
|
153
|
+
# 它**仍然是在等一个没人见过的字符串**。判定方必须把这句写进判词,
|
|
154
|
+
# 别让下一个人把「没响」读成「没回归」。
|
|
155
|
+
_SESSION_LIMIT_MARKS = (
|
|
156
|
+
"already has an active session", # 英文(含带 "User " 前缀的服务端原文)
|
|
157
|
+
"已有一个活动会话", # 中文回执
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# ── #671:上面那条「只认工具结果体」的收窄**不够**,它误吃了两轮 ──────────────
|
|
161
|
+
#
|
|
162
|
+
# 🔴 **隔壁 30 行处的风控计数早就写清了为什么必须挨着失败回执**(见 `count_platform_risk_receipts`
|
|
163
|
+
# 上方注释):「这些串**在 skill 源码里也是字面量** …… Agent 中途 `read` 一下脚本源码,
|
|
164
|
+
# 结果体里就带上了这些词 —— 那是**源码**不是**回执**。」
|
|
165
|
+
# **而这一档当时只写了 `sum(t.count(m))`,那道收窄一个字都没抄过来。**
|
|
166
|
+
#
|
|
167
|
+
# 本仓自己就是那个反例(2026-09-13 数):
|
|
168
|
+
# `tiktok/skills/publishing-shoppable-video/SKILL.md` **3 处**
|
|
169
|
+
# `tiktok/skills/exporting-ad-reports/script.py` **1 处**
|
|
170
|
+
# —— 都是**文档在引用这句报错**,没有一处是回执。
|
|
171
|
+
#
|
|
172
|
+
# ⇒ 两轮误判的现场(#671,`e2e/logs/2026-09-13-1709-*` 与 `-1732-*`):
|
|
173
|
+
#
|
|
174
|
+
# | | 第一轮 | 第二轮 |
|
|
175
|
+
# |---|---|---|
|
|
176
|
+
# | 真实原因 | 登录墙 / 账号停用(#670) | 风控滑块拼图 |
|
|
177
|
+
# | 判成 | 单会话约束回归 | 单会话约束回归 |
|
|
178
|
+
# | `session_limit_hits` | **2** | **2** |
|
|
179
|
+
# | **同一份日志里的工具轨迹** | **34 个工具,失败 0** | **21 个工具,失败 0** |
|
|
180
|
+
#
|
|
181
|
+
# 🔴 **一个「平台侧回执」,出现在一轮里每个工具都成功的会话中。**
|
|
182
|
+
# 平台因为会话上限**拒绝**了你,这件事不可能不表现为某个调用失败。
|
|
183
|
+
# **这个矛盾就印在判词下面两行**,两轮都是,没人看。
|
|
184
|
+
# 而两轮的计数**同为 2** —— 恒定值,最像文档里的固定出现次数,最不像随机发生的故障。
|
|
185
|
+
#
|
|
186
|
+
# ⇒ 收窄成和风控同一种形状:**只认挨着失败回执的那一种**。
|
|
187
|
+
#
|
|
188
|
+
# ⚠️ **锚点本身未经真机验证**(这两个标记在 277 份日志 / 1485 份 wire 里从没以回执形态出现过,
|
|
189
|
+
# 唯一一次是一句否定句)。所以**收窄之后不许直接落到「没有」**:
|
|
190
|
+
# 数到了、但没挨着任何失败回执 → 判定方必须回 `unknown`,让人去 Wire 取原文。
|
|
191
|
+
# **宁可让它一直停在 unknown,也不许它再自信地指向一个不存在的方向。**
|
|
192
|
+
#
|
|
193
|
+
# 🔴 **锚点必须是机器吐出来的那种串,不能是普通词。**
|
|
194
|
+
# 第一版我把 `失败` / `报错` / `错误` / 裸 `Error` 也列成锚点 —— **当场被真实文档打红**:
|
|
195
|
+
# `publishing-shoppable-video/SKILL.md` 那段写的是「门控**失败**时浏览器会话故意留着…
|
|
196
|
+
# 紧接着重跑可能撞 `User already has an active session`」
|
|
197
|
+
# ⇒ **散文里的「失败」离标记只有几十个字**,锚点照样点着。
|
|
198
|
+
# **这就是拿一份已知为真的样本先校准判据的价值**(L11):它在我写下的那一刻就是错的。
|
|
199
|
+
_SESSION_LIMIT_ANCHORS = (
|
|
200
|
+
"✗ Script failed", # 脚本任务级失败回执(机器吐的)
|
|
201
|
+
"Error:", "error:", "ERROR:", # 带冒号 —— 命令/gateway 的错误串,散文里不会这么写
|
|
202
|
+
"Traceback (most recent call last)",
|
|
203
|
+
)
|
|
204
|
+
_SESSION_LIMIT_WINDOW = 300 # 同 `_RISK_RECEIPT_WINDOW`:拍的,无实测支撑
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def count_session_limit_mentions(res_text: str) -> int:
|
|
208
|
+
"""结果体里这两个标记**总共**出现几处 —— 含文档、源码、复述、否定句。
|
|
209
|
+
|
|
210
|
+
🔴 **这不是证据**,它是「有没有必要去 Wire 看一眼」的线索。
|
|
211
|
+
判定方只许拿它走 `unknown`,**不许拿它判 `hit`**(那是 `count_session_limit_receipts`)。
|
|
212
|
+
"""
|
|
213
|
+
t = res_text or ""
|
|
214
|
+
return sum(t.count(m) for m in _SESSION_LIMIT_MARKS)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def count_session_limit_receipts(res_text: str) -> int:
|
|
218
|
+
"""工具结果体里有几处**挨着失败回执**的「每账号一个活动会话」标记。
|
|
219
|
+
|
|
220
|
+
收窄形状与 `count_platform_risk_receipts` 一致:标记必须落在某个失败回执**之后**
|
|
221
|
+
`_SESSION_LIMIT_WINDOW` 个字符内,才算一次平台侧回执。**源码/文档里的字面量不算。**
|
|
222
|
+
|
|
223
|
+
只回整数、不回原文 —— 结果体可能含 env 明文密钥/JWT(gw#2350),同 `_scrape_tool_trace`。
|
|
224
|
+
"""
|
|
225
|
+
t = res_text or ""
|
|
226
|
+
n = 0
|
|
227
|
+
for mark in _SESSION_LIMIT_MARKS:
|
|
228
|
+
start = 0
|
|
229
|
+
while True:
|
|
230
|
+
i = t.find(mark, start)
|
|
231
|
+
if i < 0:
|
|
232
|
+
break
|
|
233
|
+
start = i + 1
|
|
234
|
+
seg = t[max(0, i - _SESSION_LIMIT_WINDOW):i]
|
|
235
|
+
if any(a in seg for a in _SESSION_LIMIT_ANCHORS):
|
|
236
|
+
n += 1
|
|
237
|
+
return n
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ── 平台侧登录态(#449)─────────────────────────────────────────────────
|
|
241
|
+
# 登录那一档的判据原来是**扫整轮面板文本里 agent 说的一句话**(「登录态过期」「尚未登录」…),
|
|
242
|
+
# 而 agent 那句话本身是它看一张截图推出来的,**零机读证据**。
|
|
243
|
+
# 2026-09-13 01:55 那轮的后果很具体:一个跳到本土域的杂散标签被报成「店掉线」,
|
|
244
|
+
# 判词写「非 skill 缺陷,登录后重跑」,**差点让店主白做一次登录 + 二步验证**。
|
|
245
|
+
#
|
|
246
|
+
# 平台侧的东西**已经有了**:`briefing-store-status` 的 `login_stop_output()` 吐结构化的
|
|
247
|
+
# `login_state` ∈ {logged_in, logged_out, unknown} + 带落地 URL 的 `login_why`。
|
|
248
|
+
# 而且那个字段自己就守着两条(#51):**落地 host 与预期不符 → unknown;读不到 URL → unknown**,
|
|
249
|
+
# 都**绝不**落到 `logged_out`。所以判定方只要认这个字段,就自动继承了那两条纪律。
|
|
250
|
+
#
|
|
251
|
+
# ⚠️ **目前只有 `briefing-store-status` 吐它**(#51 的 fast-fail 只做了 1/19,见 #435)。
|
|
252
|
+
# 所以绝大多数用例这里会是「没有平台侧证据」→ 判定方必须回 unknown,**不许回 blocked**。
|
|
253
|
+
_LOGIN_STATE_RE = re.compile(r"login_state[\"'\s:=]+([a-z_]+)")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def scrape_login_states(res_text: str) -> list:
|
|
257
|
+
"""工具结果体里**脚本自报**的 login_state 取值(按出现顺序)。
|
|
258
|
+
|
|
259
|
+
只回取值列表,不回原文 —— 结果体可能含 env 明文密钥/JWT(gw#2350),同 `_scrape_tool_trace`。
|
|
260
|
+
取值不在白名单里的一概丢掉:宁可漏(→ unknown → uncertain,仍要人核),不误报。
|
|
261
|
+
"""
|
|
262
|
+
ok = ("logged_in", "logged_out", "unknown")
|
|
263
|
+
return [m.group(1) for m in _LOGIN_STATE_RE.finditer(res_text or "") if m.group(1) in ok]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ── 紫鸟 profile 的**声明**闸(#611;锁那一半已撤)────────────────────
|
|
267
|
+
#
|
|
268
|
+
# 🔴 **`ziniao-<profile_id>` 这把锁撤销了**(`working-as-worker` 的锁那一节 / 禁令 `ziniao-lock-withdrawn`)。
|
|
269
|
+
# 撤销理由三条:① 它防的不是并发而是「同店操作互相打断」,**而那个判断从来没有真机验证过**;
|
|
270
|
+
# ② 覆盖面只有本机调用方(云端 Agent 走 `browser-cli --ziniao-profile`,够不着 `~/.optima-locks/`);
|
|
271
|
+
# ③ 它在 `status` 里和 `skill-*` 并排显示,**而两者强度完全不同**(L43)。
|
|
272
|
+
#
|
|
273
|
+
# ## 🔴 撤条文和撤执法是两个动作 —— 2026-09-13 只做了第一个
|
|
274
|
+
#
|
|
275
|
+
# 23:xx 宣布撤销时,这里只把「取不到 ⇒ 抛」改成「取不到 ⇒ 印一行照常走」,
|
|
276
|
+
# **`skill-lock acquire` 那一句留着了**。后果是**它继续往共享锁目录落 `skill-ziniao-*.lock`**,
|
|
277
|
+
# 于是同一把锁**两条路给出相反的答案**:
|
|
278
|
+
# · harness 这条路:取不到也不拦你(`lock_busy_not_blocking` 24 次)
|
|
279
|
+
# · **CLI 那条路:直接拒**(`-4` 2026-09-13 17:19Z 实测被拒;`-6` 17:17、`-10` 17:22 取到)
|
|
280
|
+
# 🔴 **而条文说这把锁根本不存在。** —— L43:混在一起的状态比没有状态更坏。
|
|
281
|
+
#
|
|
282
|
+
# ⇒ 2026-09-14:**这里一句 `skill-lock` 都不再调**,`skill-lock` 自己也在 `acquire ziniao-*`
|
|
283
|
+
# 上直接回 `RESULT=WITHDRAWN`(不落文件)。**两条路这才说同一句话。**
|
|
284
|
+
#
|
|
285
|
+
# ## ⚠️ 撤的是锁,**留下来的是这三样**
|
|
286
|
+
#
|
|
287
|
+
# 1. **`ziniao=` 这个必填关键字参数** —— 漏传 **直接 `TypeError`**(L111:坏了就拼不出来)。
|
|
288
|
+
# 它答的是「这一轮要动哪个 profile」,`send()` 要拿它和正文对账。
|
|
289
|
+
# 2. **`ziniao=None` 必须写理由** —— 「我确实不需要」和「我忘了」不许共用取值(L42)。
|
|
290
|
+
# 3. **各开各的 tab(`own_tab=True`),不要接管别人正在用的那个** ——
|
|
291
|
+
# 🔴 **这条纪律没有随锁一起撤**,而它现在只能靠文档和参数说明活着(#662)。
|
|
292
|
+
#
|
|
293
|
+
# ⚠️ **别把「有这个参数」读成「profile 不会被同时开」**:它从来就管不着云端那条路,
|
|
294
|
+
# 而现在连本机这条路也不再互斥 —— **它只是一份声明**。
|
|
295
|
+
#
|
|
296
|
+
# ## ⚠️ 它保证什么 —— **一句都别多说**
|
|
297
|
+
#
|
|
298
|
+
# 🔴 **它连「不同时开」都不保证了**(锁撤之前保证的正是这一条)。
|
|
299
|
+
# 它更**不保证**「一次只读导航对相邻轮次零影响」—— **那条至今没有证据**。
|
|
300
|
+
# 出处是 `-6` 的 #315 轮次账本(2026-09-13),原话:
|
|
301
|
+
# > **我现在没法证明「导航走开又回来」对下一轮零影响** ——
|
|
302
|
+
# > 我只能说这两轮之间没有我的观测被你覆盖。
|
|
303
|
+
#
|
|
304
|
+
# 🔴 **判词里不许出现「只读所以安全」。**
|
|
305
|
+
|
|
306
|
+
# ── 这道闸的流水账(#611,`-4` 2026-09-13 08:50Z 提的那个缺口)────────
|
|
307
|
+
#
|
|
308
|
+
# 🔴 **一道闸只报「我拦了」,不报「它至今拦了多少次」,那么
|
|
309
|
+
# 「它是不是误伤太多」这个问题在结构上就答不了** ——
|
|
310
|
+
# 而那正是「**误伤会让人把守卫关掉**」(L13)发生之前,唯一能救它的那个数。
|
|
311
|
+
#
|
|
312
|
+
# 本闸 2026-09-13 一天之内**误伤三次**(#645 两次 · #666 一次),三次都被人实打实地咬到;
|
|
313
|
+
# 而**它正确拦下过几次,没有任何地方记着**(`skill-lock` 自己也不写任何日志——查过了,
|
|
314
|
+
# `scripts/skill-lock.sh` 135 行里没有一处写日志)。⇒ **分子有人记,分母没有。**
|
|
315
|
+
#
|
|
316
|
+
# ## 🔴 放行也要记(L139)
|
|
317
|
+
#
|
|
318
|
+
# 「我用了哪条判据」是**记账**,不是诊断 —— **成功的时候也要记**。
|
|
319
|
+
# 只记拦下的话,算出来的永远是 100%:**没有分母的比例不是比例。**
|
|
320
|
+
#
|
|
321
|
+
# ## ⚠️ 本账**不答**「拦对了几次」
|
|
322
|
+
#
|
|
323
|
+
# 那要人来判 —— 记录只提供**分母**和**每次拦下的现场**。别把它读成准确率。
|
|
324
|
+
#
|
|
325
|
+
# ## ⚠️ 它绝不许把闸本身弄坏
|
|
326
|
+
#
|
|
327
|
+
# 记账失败一律咽掉:**一条记账用的 I/O 不许成为这道闸的新失败模式**。
|
|
328
|
+
# 代价是「从来没记过」和「记坏了」共用同一种表现(L65)——
|
|
329
|
+
# ⇒ **消费端(`scripts/ziniao-gate-report.py`)对「文件不在」回 `unknown`,不回 `0`**。
|
|
330
|
+
ZINIAO_GATE_LEDGER = os.path.expanduser("~/.optima-locks/ziniao-gate.jsonl")
|
|
331
|
+
ZINIAO_GATE_REPORT_HINT = (
|
|
332
|
+
"(这一次已记进流水账;要看这道闸至今拦了多少次、放行多少次,"
|
|
333
|
+
"跑 `python3 scripts/ziniao-gate-report.py`)")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _gate_record(event, verdict, **fields):
|
|
337
|
+
"""往流水账追加一行。**永不抛** —— 见上面那段。
|
|
338
|
+
|
|
339
|
+
`verdict` 只有两个取值:`"block"`(这一次拦下了)· `"pass"`(这一次放行了)。
|
|
340
|
+
🔴 **两个取值分开写死**,不从 `event` 现推 —— 推导会让「新加了一种 event
|
|
341
|
+
但忘了归类」悄悄落进某一格(L32:表里要有出口)。
|
|
342
|
+
"""
|
|
343
|
+
assert verdict in ("block", "pass"), verdict
|
|
344
|
+
row = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
345
|
+
"event": event, "verdict": verdict,
|
|
346
|
+
"clone": os.path.basename(os.getcwd()), "pid": os.getpid()}
|
|
347
|
+
row.update({k: v for k, v in fields.items() if v is not None})
|
|
348
|
+
try:
|
|
349
|
+
os.makedirs(os.path.dirname(ZINIAO_GATE_LEDGER), exist_ok=True)
|
|
350
|
+
with open(ZINIAO_GATE_LEDGER, "a", encoding="utf-8") as fh:
|
|
351
|
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
352
|
+
except Exception: # noqa: BLE001
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def ziniao_lock_key(profile_id) -> str:
|
|
357
|
+
"""→ `ziniao-<profile_id>`:**这一轮的声明键**。
|
|
358
|
+
|
|
359
|
+
⚠️ 名字里的 `lock` 是历史遗留 —— **锁已经撤了**,这个串现在只有两个用途:
|
|
360
|
+
① `send()` 拿它和正文点名的 profile 对账;② 流水账里标明这一轮在哪个 profile 上。
|
|
361
|
+
|
|
362
|
+
🔴 **必须用 profile id,不能用店名**:别名和 id 对不上账,
|
|
363
|
+
`send()` 的对账会把「声明了 A、正文点了 A」判成「声明了别的」。
|
|
364
|
+
"""
|
|
365
|
+
pid = str(profile_id).strip()
|
|
366
|
+
if not pid:
|
|
367
|
+
raise ValueError("ziniao profile id 是空的")
|
|
368
|
+
if not re.fullmatch(r"[0-9]{6,}", pid):
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f"ziniao={pid!r} 看起来不是 profile id(应是纯数字,≥6 位)。"
|
|
371
|
+
"**别用店名** —— 别名和 id 对不上账,`send()` 的对账会判错。")
|
|
372
|
+
return f"ziniao-{pid}"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def acquire_ziniao(ziniao, reason=None, what="e2e", _run=None):
|
|
376
|
+
"""进 `attach()` 之前的**声明**闸。**→ 两个值,不是一个。**
|
|
377
|
+
|
|
378
|
+
→ **`(key, owned)`**:`key` 是「我这一轮在哪个 profile 上」(`send()` 对账用),
|
|
379
|
+
`owned` **恒为 `False`** —— 锁撤了,没有任何东西可以被「持有」,
|
|
380
|
+
因此也没有任何东西需要在 `close()` 里放。
|
|
381
|
+
|
|
382
|
+
🔴 **`owned` 为什么不干脆删掉**:它是调用方 `close()` 那条分支的开关,
|
|
383
|
+
删了要同时改三处调用点;留成恒 `False` 反而让「谁都不许去 release」这件事
|
|
384
|
+
**在返回值上写死**。⚠️ 但它**不许**再长回「有时为真」—— 那意味着锁回来了。
|
|
385
|
+
|
|
386
|
+
· `ziniao="278…"` ⇒ 声明这一轮要驱动哪个 profile。
|
|
387
|
+
🔴 **它只是声明,不再取任何锁**(2026-09-14 撤执法)。
|
|
388
|
+
**各开各的 tab(`own_tab=True`),不要接管别人正在用的那个** —— 这条纪律没撤。
|
|
389
|
+
· `ziniao=None` + 理由 ⇒ 放行,并把理由打出来(**它会进流水账,供事后核**)
|
|
390
|
+
· `ziniao=None` 无理由 ⇒ **抛 `ValueError`**:「明确不需要」必须写出来,
|
|
391
|
+
否则它和「忘了传」又共用一个取值。
|
|
392
|
+
|
|
393
|
+
⚠️ `_run` 参数留着只为兼容既有调用/测试签名 —— **本函数不再调用任何外部命令**。
|
|
394
|
+
"""
|
|
395
|
+
if ziniao is None:
|
|
396
|
+
if not (isinstance(reason, str) and reason.strip()):
|
|
397
|
+
raise ValueError(
|
|
398
|
+
"ziniao=None 必须同时写明理由(reason=\"这一轮不碰任何紫鸟 profile,因为…\")。"
|
|
399
|
+
"**「我确实不需要」和「我忘了」不许共用一个取值。**")
|
|
400
|
+
print(f"[ziniao] 显式声明不碰任何 profile:{reason.strip()}", flush=True)
|
|
401
|
+
_gate_record("declared_none", "pass", reason=reason.strip(), what=what)
|
|
402
|
+
return None, False
|
|
403
|
+
key = ziniao_lock_key(ziniao)
|
|
404
|
+
# 🔴 **这里以前有一句 `skill-lock acquire`。它是「撤了条文没撤执法」的那一半。**
|
|
405
|
+
# 它落下的 `skill-ziniao-*.lock` 会让**别人**在 CLI 那条路上被拒
|
|
406
|
+
# —— 本进程自己看不见那个后果,所以它躲了一整天。
|
|
407
|
+
print(f"[ziniao] 本轮声明的 profile:{key}(**这把锁已撤销,不取锁、不拦人**)\n"
|
|
408
|
+
f" ⚠️ **各开各的 tab,别接管别人正在用的那个**(own_tab=True)——"
|
|
409
|
+
f"这条纪律没有随锁一起撤。", flush=True)
|
|
410
|
+
_gate_record("declared", "pass", key=key, what=what)
|
|
411
|
+
return key, False
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# 🔴 **「没传」要有自己的取值**(#666 / L42)。
|
|
415
|
+
# 拿 `None` 当默认 ⇒ **「忘了传」会被读成「我明确不碰任何 profile」**,
|
|
416
|
+
# 而那正好是这道闸要抓的那种假声明 —— **默认值不许长得像一个合法声明。**
|
|
417
|
+
_INHERIT = object()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class ZiniaoDeclarationConflict(RuntimeError):
|
|
421
|
+
"""**声明说不碰 profile,发出去的正文里却点名了一个** —— 那个声明是假的。"""
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# 🔴 **位数是判据的关键**(`-4` 2026-09-13 review 时点出来的):
|
|
425
|
+
# 紫鸟 profile id = **14 位**;货盘 `product_id` = **19 位**。
|
|
426
|
+
# ⇒ 用 `\d{14,}` 会**把商品 ID 全打成 profile** —— 误伤,而**误伤会让人把守卫关掉**。
|
|
427
|
+
# 这里用**前后都不能再有数字**的 14 位,19 位串整个不匹配。
|
|
428
|
+
_PROFILE_IN_TEXT = re.compile(r"(?<!\d)(\d{14})(?!\d)")
|
|
429
|
+
_PROFILE_FLAG = "--ziniao-profile"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def known_profile_ids() -> set:
|
|
433
|
+
"""这台机器/这个仓**认识**的紫鸟 profile id。
|
|
434
|
+
|
|
435
|
+
唯一来源:`e2e/registry*.yaml` 里的 `store:` —— 本仓写下来的真实 profile。
|
|
436
|
+
|
|
437
|
+
## ⚠️ 2026-09-14:**第二个来源没了,而且是结构性地没了**
|
|
438
|
+
|
|
439
|
+
原来还从 `skill-lock status` 里捞 `ziniao-*` 已被占的 key。
|
|
440
|
+
**锁撤销之后 `skill-lock acquire ziniao-*` 不再落任何文件** ⇒
|
|
441
|
+
那条来源**永远返回空**。留着它就是留一个「0 命中」和「方法坏了」
|
|
442
|
+
长得一模一样的东西(L11),所以删掉,**并把窄下来的覆盖写在这里**。
|
|
443
|
+
|
|
444
|
+
🔴 **为什么必须有这张表**(`-4` 2026-09-13 真机上被拦住才发现):
|
|
445
|
+
只按「14 位数字」判会**把时间戳当成 profile** ——
|
|
446
|
+
`Tadaparty_20260912180918` 里的 `YYYYMMDDHHMMSS` **正好 14 位**,
|
|
447
|
+
而它在本仓到处都是(计划名、导出文件名、日志名)。
|
|
448
|
+
⚠️ 那次误伤挡住的是一次**操作正确 profile 的真机**。
|
|
449
|
+
**而下一个撞上的人,最省事的办法是把参数改短绕过去 —— 那才是真的绕过了闸。**
|
|
450
|
+
|
|
451
|
+
⚠️ **它的盲区要写出来**:**两张表都没收录的真 profile,这道闸看不见它**
|
|
452
|
+
(比如新开一家店、还没进 registry)。**不声称没验过的覆盖。**
|
|
453
|
+
⚠️ 盲区比 2026-09-13 那版**更宽了一格** —— 以前「有人锁着」也算认识,现在不算。
|
|
454
|
+
兜底只剩 `--ziniao-profile <id>` 那条无歧义写法。
|
|
455
|
+
"""
|
|
456
|
+
ids = set()
|
|
457
|
+
try:
|
|
458
|
+
e2e = os.path.join(_repo_root(), "e2e")
|
|
459
|
+
for fn in os.listdir(e2e):
|
|
460
|
+
if fn.startswith("registry") and fn.endswith(".yaml"):
|
|
461
|
+
txt = open(os.path.join(e2e, fn), encoding="utf-8", errors="replace").read()
|
|
462
|
+
ids.update(re.findall(r"store:\s*[\"']?(\d{6,})", txt))
|
|
463
|
+
except OSError:
|
|
464
|
+
pass
|
|
465
|
+
return ids
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _repo_root():
|
|
469
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
470
|
+
return os.path.dirname(os.path.dirname(os.path.dirname(here)))
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def profiles_named_in(msg, known=None) -> list:
|
|
474
|
+
"""一段要发给 Agent 的文本里,点名了哪些紫鸟 profile。
|
|
475
|
+
|
|
476
|
+
**判据故意窄**(只两种形态),因为**误伤会让人把守卫关掉**:
|
|
477
|
+
· 字面 `--ziniao-profile`(云端 `runscript` 那条路的写法)—— **无条件认**
|
|
478
|
+
· **独立的 14 位数字,且它是一个已知的 profile id**(见 `known_profile_ids`)
|
|
479
|
+
|
|
480
|
+
⚠️ **它抓不到**:用店名指代(「小海店」)· 把 id 拆开写 ·
|
|
481
|
+
让 Agent 自己去 `ziniao profiles` 里挑 · **两张表都没收录的真 profile**。
|
|
482
|
+
**写下来,别声称没验过的覆盖。**
|
|
483
|
+
"""
|
|
484
|
+
text = str(msg or "")
|
|
485
|
+
known = known_profile_ids() if known is None else set(known)
|
|
486
|
+
ids = sorted({x for x in _PROFILE_IN_TEXT.findall(text) if x in known})
|
|
487
|
+
# `--ziniao-profile <id>` 是**无歧义**的写法:它后面跟的数字一律认,不看表。
|
|
488
|
+
for m in re.finditer(re.escape(_PROFILE_FLAG) + r"[=\s]+(\d{6,})", text):
|
|
489
|
+
if m.group(1) not in ids:
|
|
490
|
+
ids.append(m.group(1))
|
|
491
|
+
if _PROFILE_FLAG in text and not ids:
|
|
492
|
+
return ["<--ziniao-profile 但没写出 id>"]
|
|
493
|
+
return sorted(ids)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def check_declaration(declared_key, msg, known=None):
|
|
497
|
+
"""**声明与事实对账**:发出去的正文点名的 profile,和 `attach()` 时声明的对得上吗?
|
|
498
|
+
|
|
499
|
+
🔴 这道闸补的是前两道的固有上限:**显式出口一定允许一个错误的显式声明** ——
|
|
500
|
+
`ziniao=None, reason="只读 dump"` 写得出来,而那一轮真的动了 profile
|
|
501
|
+
(`-4` 2026-09-13 06:14Z 自陈的那次就是这个形态)。
|
|
502
|
+
**声明是 `None`、而正文里带着 profile —— 那一刻它就变成假的了,这时候抓得住。**
|
|
503
|
+
|
|
504
|
+
⚠️ 它**不是**「保证没动别的 profile」:见 `profiles_named_in` 的盲区。
|
|
505
|
+
|
|
506
|
+
## 🔴 为什么这一格是**拦**,而 `must_call` 那一格是**留痕**(#666 要求先定的那条)
|
|
507
|
+
|
|
508
|
+
`-5` 问得对:**「声明 ≠ 实际」这一格该留痕还是该拦?**
|
|
509
|
+
**两者不是同一类事,判据是「错了之后谁付代价」:**
|
|
510
|
+
|
|
511
|
+
| | `must_call` 不符 | **本闸**不符 |
|
|
512
|
+
|---|---|---|
|
|
513
|
+
| 发生了什么 | 用例声明不加载 skill,实际加载了 | **正文点名 B,而我们手上是 A 的锁** |
|
|
514
|
+
| 代价 | **测试质量**打折 —— 一条观测不准 | 🔴 **一条消息发给云端 Agent,去动一家不属于本轮的店** |
|
|
515
|
+
| 可撤销吗 | 是(重跑一次) | **否 ——「发出去就收不回来了」** |
|
|
516
|
+
|
|
517
|
+
⇒ **留痕适用于「结论可能不准」;拦适用于「动作不可撤销」。** 本闸是后者。
|
|
518
|
+
|
|
519
|
+
⚠️ **而三次误伤都不是这条政策造成的**,逐条核过:
|
|
520
|
+
① 14 位时间戳被当成 profile id ⇒ **判据错**(#645 已修);
|
|
521
|
+
② `close()` 放了别人的锁 ⇒ **所有权判据**(#645 已修);
|
|
522
|
+
③ 点名 profile 的用例全被拦死 ⇒ **那个值有两个家**(#666,本次)。
|
|
523
|
+
**没有一次是「它本该只记一笔却抛了异常」。** ⇒ **政策不动。**
|
|
524
|
+
"""
|
|
525
|
+
named = profiles_named_in(msg, known)
|
|
526
|
+
if not named:
|
|
527
|
+
# 🔴 **正文没点名任何 profile ⇒ 不记账。**(分母的定义,L116)
|
|
528
|
+
# 这一格不是「放行」,是**这道闸这一次没有可判的东西** ——
|
|
529
|
+
# 把它记进去,分母就被每一条普通消息灌满,**算出来的比例不再是这道闸的比例**。
|
|
530
|
+
# ⇒ 记账的一次 = **它真的做了一次判断**。
|
|
531
|
+
return
|
|
532
|
+
if declared_key is None:
|
|
533
|
+
_gate_record("false_declaration", "block", named=named)
|
|
534
|
+
raise ZiniaoDeclarationConflict(
|
|
535
|
+
f"attach() 时声明了 `ziniao=None`(这一轮不碰任何 profile),"
|
|
536
|
+
f"但要发的正文里点名了 {named} —— **那个声明是假的**。\n"
|
|
537
|
+
f"⇒ 要么改成 `ziniao=<那个 id>` 真去取锁,要么别在正文里点名 profile。\n"
|
|
538
|
+
f"{ZINIAO_GATE_REPORT_HINT}")
|
|
539
|
+
want = declared_key.split("ziniao-", 1)[-1]
|
|
540
|
+
other = [n for n in named if n != want]
|
|
541
|
+
if other:
|
|
542
|
+
_gate_record("wrong_profile", "block", key=declared_key, named=named)
|
|
543
|
+
raise ZiniaoDeclarationConflict(
|
|
544
|
+
f"锁取的是 {declared_key},正文里却点名了 {other} —— **拿着 A 的锁去开 B**。\n"
|
|
545
|
+
f"{ZINIAO_GATE_REPORT_HINT}")
|
|
546
|
+
# 🔴 **放行也要记**(L139)—— 只记拦下的话,算出来的永远是 100%。
|
|
547
|
+
_gate_record("consistent", "pass", key=declared_key, named=named)
|
|
29
548
|
|
|
30
549
|
|
|
31
550
|
class ChatDriver:
|
|
@@ -34,40 +553,247 @@ class ChatDriver:
|
|
|
34
553
|
self._pw = None
|
|
35
554
|
self.browser = None
|
|
36
555
|
self.page = None
|
|
556
|
+
self.session_id = None # 本 tab 认领的 gateway sessionId(wire 归因/并行隔离校验都靠它)
|
|
557
|
+
self._own_tab = False
|
|
558
|
+
self._keep_for_human = None # 非 None ⇒ 这一轮停手交人,tab 留着(CEO 那一格)
|
|
559
|
+
self._ziniao_key = None # 这一轮声明在哪个 profile 上(send() 的对账用;**不是锁**)
|
|
560
|
+
self.tab_isolated = False # 是否真独占了一个 gateway session(并行的前提,降级后为 False)
|
|
37
561
|
self._tool_baseline = 0 # 发送前的「個工具」面板数;本轮只抓之后新增的(防超时用例污染下一轮)
|
|
38
562
|
self._console_errs = [] # 前端 console 报错缓冲——区分「傳送失敗」的真实根因(weekly_limit vs credits)
|
|
39
563
|
|
|
40
564
|
# ── 连接生命周期 ──
|
|
41
|
-
|
|
565
|
+
@staticmethod
|
|
566
|
+
def _read_sid(page):
|
|
567
|
+
"""读某个 page 认领的 gateway sessionId(读不到返回 None)。"""
|
|
568
|
+
try:
|
|
569
|
+
return page.evaluate("(k)=>sessionStorage.getItem(k)", TAB_SID_KEY)
|
|
570
|
+
except Exception:
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
def _await_sid(self, timeout: int = 60):
|
|
574
|
+
"""等本 tab 把 sessionId 认领上(session_ready 才写)。超时返回 None。"""
|
|
575
|
+
deadline = time.time() + timeout
|
|
576
|
+
while time.time() < deadline:
|
|
577
|
+
sid = self._read_sid(self.page)
|
|
578
|
+
if sid:
|
|
579
|
+
return sid
|
|
580
|
+
self.page.wait_for_timeout(1000)
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
def attach(self, *, ziniao, reason=None, own_tab="auto",
|
|
584
|
+
claim_timeout: int = 60) -> "ChatDriver":
|
|
585
|
+
"""连上调试端口 Chrome 并选定本 driver 要驱动的 tab。
|
|
586
|
+
|
|
587
|
+
🔴 `ziniao` 是**必填关键字参数**(#611)——漏传直接 `TypeError`,
|
|
588
|
+
**不是警告、不是默认值**。它答的是「这一轮要动哪个紫鸟 profile」:
|
|
589
|
+
|
|
590
|
+
- `ziniao="27884995544032"` ⇒ **声明**这一轮要驱动哪个 profile
|
|
591
|
+
(🔴 **不取任何锁** —— `ziniao-<id>` 这把锁 2026-09-14 撤了执法,见模块上方那段)
|
|
592
|
+
- `ziniao=None, reason="…"` ⇒ **显式**声明这一轮不碰任何 profile(理由必填)
|
|
593
|
+
|
|
594
|
+
**「我确实不需要」和「我忘了」不共用取值** —— 后者是 `TypeError`。
|
|
595
|
+
🔴 **没有锁要放** ⇒ `close()` 里那半也一起没了。
|
|
596
|
+
|
|
597
|
+
## 🔴 这个声明**管谁、不管谁**(写在这里,别只写在 PR 里)
|
|
598
|
+
|
|
599
|
+
穷举过所有能驱动一个紫鸟 profile 的入口(#611):
|
|
600
|
+
|
|
601
|
+
| 入口 | 在哪跑 | 本闸拦不拦 |
|
|
602
|
+
|---|---|---|
|
|
603
|
+
| e2e 逐条用例(`case["store"]`) | 本机 | ✅ 在 `run.run_case` 那一层声明 |
|
|
604
|
+
| **直接 `ChatDriver().attach()`**(人/脚本) | 本机 | ✅ **就是这个参数** |
|
|
605
|
+
| 🔴 云端 Agent 跑 marketplace skill:`browser-cli … --ziniao-profile` | **云端 pod** | ❌ **看不见** |
|
|
606
|
+
| 人在终端直接敲 `browser-cli --ziniao-profile` | 本机 | ❌ 看不见 |
|
|
607
|
+
|
|
608
|
+
🔴 **量**:20/34 份 SKILL.md 教 Agent 传 `--ziniao-profile` ——
|
|
609
|
+
**绝大多数真正驱动 profile 的流量走的是云端那条路,而 pod 上没有
|
|
610
|
+
`~/.optima-locks/`,这把锁在那条路上结构性够不着。**
|
|
611
|
+
|
|
612
|
+
⚠️ **所以别把「有这道闸」读成「profile 不会被同时开」。**
|
|
613
|
+
它盖住的是**本机调用方**;云端那条要互斥,得在服务端有东西执法,**不在本仓**。
|
|
614
|
+
|
|
615
|
+
⚠️ 另:e2e 主路径上 `attach()` 收到的**恒是 `ziniao=None`**
|
|
616
|
+
(worker 的 tab 不绑定 profile,profile 在逐条用例那层声明)——
|
|
617
|
+
**也就是说这个必填参数在主路径上永远不被真正行使**。
|
|
618
|
+
它挡的是**别的调用方**。**知道这一点再决定要不要依赖它。**
|
|
619
|
+
|
|
620
|
+
`own_tab` 三态 —— **默认自己开 tab**(平台既然支持多 tab,独占就是常态,共用才是例外):
|
|
621
|
+
|
|
622
|
+
- `"auto"`(默认):先试着自己开 tab 独占一个 gateway session;该环境不支持多 tab
|
|
623
|
+
(`NEXT_PUBLIC_MULTI_TAB_SESSION` 没开,新 tab 会被并回同一个 session)或认领超时时,
|
|
624
|
+
**降级复用已有 tab** 并把 `self.tab_isolated` 置 False、打一行 warn。
|
|
625
|
+
单 driver 场景下复用是安全的(那就是改并发之前的老行为),所以降级不是问题。
|
|
626
|
+
- `True`(**并行必用**):严格独占,拿不到独立 session 直接抛 `TabSessionUnavailable`。
|
|
627
|
+
🔴 并行时绝不能用 "auto" —— 多个 worker 各自降级到同一个已有 tab = 静默串台
|
|
628
|
+
(见模块 docstring 的实证)。要并行就必须 fail-fast 让调用方退回串行。
|
|
629
|
+
- `False`:显式复用已有 tab(旧行为;只在你确实想操作用户当前那个 tab 时用)。
|
|
630
|
+
|
|
631
|
+
`self.session_id` 记认领到的 gateway sessionId(Wire 归因锚点);
|
|
632
|
+
`self.tab_isolated` 说明这个 driver 是不是真独占了一个 session。
|
|
633
|
+
"""
|
|
634
|
+
# 🔴 闸在**开浏览器之前**(L17:位置不对等于没有)——
|
|
635
|
+
# 声明没写全(漏传 / `None` 无理由)就不该有任何动作发生。
|
|
636
|
+
# ⚠️ 第二个返回值**恒为 `False`**(锁撤了,没有可持有的东西)⇒ 直接丢掉,
|
|
637
|
+
# **不要再落一个 `self._ziniao_owned`** —— 那个字段是 `close()` 放锁的开关,
|
|
638
|
+
# 留着它等于把锁的接口留在那儿等人接回去。
|
|
639
|
+
self._ziniao_key, _ = acquire_ziniao(
|
|
640
|
+
ziniao, reason, what=f"e2e attach own_tab={own_tab}")
|
|
641
|
+
strict = (own_tab is True)
|
|
642
|
+
want_own = (own_tab is True or own_tab == "auto")
|
|
42
643
|
self._pw = sync_playwright().start()
|
|
43
644
|
self.browser = self._pw.chromium.connect_over_cdp(f"http://localhost:{self.port}")
|
|
44
645
|
ctx = self.browser.contexts[0]
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
646
|
+
if want_own:
|
|
647
|
+
# 先快照**别的 tab 已认领的 sid**,再开自己的 —— 顺序反了就分不清撞没撞车。
|
|
648
|
+
# ⚠️ 多 worker 并行 attach 时调用方要串行化这一段(各自认领完再放下一个),
|
|
649
|
+
# 否则两个新 tab 可能都在对方写 sessionStorage 之前完成快照,撞车检测漏判。
|
|
650
|
+
taken = {sid for sid in (self._read_sid(p) for p in ctx.pages if "/chat" in p.url) if sid}
|
|
651
|
+
self.page = ctx.new_page()
|
|
652
|
+
self._own_tab = True
|
|
653
|
+
self.page.goto(CHAT_URL, wait_until="domcontentloaded")
|
|
654
|
+
else:
|
|
655
|
+
# 明确选 chat page(url 含 /chat),别抓到残留 tab(如 device 授权页)——它可能中途关闭致 TargetClosedError
|
|
656
|
+
chat = [p for p in ctx.pages if "/chat" in p.url]
|
|
657
|
+
self.page = chat[0] if chat else (ctx.pages[-1] if ctx.pages else ctx.new_page())
|
|
658
|
+
taken = None
|
|
659
|
+
# 清掉可能残留的视口仿真:driver 进程被杀时 Playwright 的 1440x900 override 会孤儿化留在
|
|
660
|
+
# 标签页上(用户看到页面右侧大块空白)。别的会话 clear 不掉它——必须先 set 接管再 clear。
|
|
661
|
+
try:
|
|
662
|
+
cdp = self.page.context.new_cdp_session(self.page)
|
|
663
|
+
cdp.send("Emulation.setDeviceMetricsOverride",
|
|
664
|
+
{"width": 0, "height": 0, "deviceScaleFactor": 0, "mobile": False})
|
|
665
|
+
cdp.send("Emulation.clearDeviceMetricsOverride")
|
|
666
|
+
except Exception:
|
|
667
|
+
pass # 清不掉不影响驱动,只影响观感
|
|
48
668
|
# 捕获前端 console:发送被拒时「傳送失敗」toast 是通用的,真实原因(weekly_limit=本周额度用完 / 积分不足 …)
|
|
49
669
|
# 只在 console 里([Chat Error] weekly_limit…)。留最近 20 条供 send() 分流,别再把 weekly_limit 误报成 credits。
|
|
670
|
+
self._hook_console()
|
|
671
|
+
self.session_id = self._await_sid(claim_timeout if want_own else 10)
|
|
672
|
+
if want_own:
|
|
673
|
+
why = None
|
|
674
|
+
if not self.session_id:
|
|
675
|
+
why = f"新 tab {claim_timeout}s 内没认领到 gateway session(没登录?连不上 gateway?)"
|
|
676
|
+
elif self.session_id in taken:
|
|
677
|
+
why = (f"新 tab 与已有 tab 撞同一个 session({self.session_id})—— 该环境 multi-tab 没开"
|
|
678
|
+
f"(NEXT_PUBLIC_MULTI_TAB_SESSION 是 build-time flag、默认关)")
|
|
679
|
+
if why:
|
|
680
|
+
if strict:
|
|
681
|
+
self.close() # 失败路径也要把刚开的 tab 关掉,别留垃圾
|
|
682
|
+
raise TabSessionUnavailable(why + ",并行会静默串台。退回串行跑。")
|
|
683
|
+
# auto:降级复用已有 tab。单 driver 复用是安全的(= 改并发之前的老行为)。
|
|
684
|
+
print(f"[chat_driver] ⚠️ 独占 tab 失败({why})—— 降级复用已有 tab;"
|
|
685
|
+
f"**此 driver 不可用于并行**")
|
|
686
|
+
self._close_own_tab()
|
|
687
|
+
self._own_tab = False
|
|
688
|
+
chat = [p for p in ctx.pages if "/chat" in p.url]
|
|
689
|
+
self.page = chat[0] if chat else (ctx.pages[-1] if ctx.pages else ctx.new_page())
|
|
690
|
+
self._hook_console()
|
|
691
|
+
self.session_id = self._await_sid(10)
|
|
692
|
+
self.tab_isolated = False
|
|
693
|
+
return self
|
|
694
|
+
self.tab_isolated = bool(want_own)
|
|
695
|
+
return self
|
|
696
|
+
|
|
697
|
+
def _hook_console(self) -> None:
|
|
698
|
+
"""捕获前端 console:发送被拒时「傳送失敗」toast 是通用的,真实原因(weekly_limit / 积分…)只在 console 里。"""
|
|
50
699
|
self.page.on("console", lambda m: self._console_errs.append((m.text or "")[:200])
|
|
51
700
|
if m.type in ("error", "warning") else None)
|
|
701
|
+
|
|
702
|
+
# ── 收尾:这一轮开的 tab,这一轮关掉 ────────────────────────────────
|
|
703
|
+
#
|
|
704
|
+
# 🔴 **CEO 2026-09-13 夜发来紫鸟截图:24 个 tab。** 原话:
|
|
705
|
+
# 「所有的 skills、所有的任务都只会新建 tab 而不关闭 tab,紫鸟就是越开 tab 越多」
|
|
706
|
+
#
|
|
707
|
+
# ⚠️ **`close()` 本来就会关自己那个 tab,问题从来不在这里** ——
|
|
708
|
+
# 在于**调用方**:临时脚本写 `d.close()` 在函数末尾,**一抛异常就漏**,
|
|
709
|
+
# 🔴 **而失败轮次恰恰最多。**(本文件作者自己今晚漏过两个。)
|
|
710
|
+
# ⇒ 所以加的不是「再关一次」,是**让漏掉变难**:`with` 一定关。
|
|
711
|
+
#
|
|
712
|
+
# 🔴 **CEO 亲自留的那一格**:
|
|
713
|
+
# 「任务完成后就应该把它关掉;**除非需要用户手动介入去点击,才应该保留**。」
|
|
714
|
+
# ⇒ 判据:**这一轮是不是给店主留了一句「你去点一下」?是 ⇒ 留;否 ⇒ 关。**
|
|
715
|
+
# 用 `keep_tab_for_human("为什么")` 声明,**理由必填** ——
|
|
716
|
+
# 「确实要留给人」和「忘了关」不许共用一个取值。
|
|
717
|
+
|
|
718
|
+
def keep_tab_for_human(self, reason: str) -> None:
|
|
719
|
+
"""声明这一轮**停手交人**,tab 留着不关(CEO 定的那一格)。
|
|
720
|
+
|
|
721
|
+
🔴 **理由必填**:留一个 tab 是要占并发名额的,
|
|
722
|
+
「确实要留给人」和「忘了关」**不许共用一个取值**。
|
|
723
|
+
"""
|
|
724
|
+
if not (isinstance(reason, str) and reason.strip()):
|
|
725
|
+
raise ValueError(
|
|
726
|
+
"keep_tab_for_human(reason=...) 的理由必填 —— "
|
|
727
|
+
"**「要留给店主点」和「忘了关」不许共用一个取值**。"
|
|
728
|
+
"例:keep_tab_for_human('停在验证码,要店主本人过')")
|
|
729
|
+
self._keep_for_human = reason.strip()
|
|
730
|
+
print(f"[tab] 这一轮**留着不关**(停手交人):{reason.strip()}", flush=True)
|
|
731
|
+
|
|
732
|
+
def __enter__(self):
|
|
52
733
|
return self
|
|
53
734
|
|
|
735
|
+
def __exit__(self, exc_type, exc, tb):
|
|
736
|
+
# 🔴 **异常退出也要关** —— 只在成功路径上关,等于「失败轮次的 tab 永远留着」。
|
|
737
|
+
self.close()
|
|
738
|
+
return False # 不吞异常
|
|
739
|
+
|
|
740
|
+
def _close_own_tab(self) -> None:
|
|
741
|
+
"""只关本 driver 自己开的 tab(降级/收尾共用)。
|
|
742
|
+
|
|
743
|
+
⚠️ **只关自己这一轮开的那个**(`self._own_tab`)——
|
|
744
|
+
🔴 **绝不去关别人正在用的**:十个 session 并发,
|
|
745
|
+
「顺手清理陈旧 tab」会关掉别人的那一个,而那种误伤没有任何东西拦得住。
|
|
746
|
+
"""
|
|
747
|
+
if getattr(self, "_keep_for_human", None):
|
|
748
|
+
print(f"[tab] 保留(停手交人):{self._keep_for_human}", flush=True)
|
|
749
|
+
return
|
|
750
|
+
if self._own_tab and self.page:
|
|
751
|
+
try:
|
|
752
|
+
self.page.close()
|
|
753
|
+
except Exception:
|
|
754
|
+
pass
|
|
755
|
+
|
|
54
756
|
def close(self) -> None:
|
|
55
|
-
# 只断开 attach,不关用户的 Chrome
|
|
757
|
+
# 只断开 attach,不关用户的 Chrome。**自己开的 tab 要自己关**(关掉即释放该 gateway
|
|
758
|
+
# session 的并发名额);不是自己开的(默认 attach 复用的用户 tab)一律不动。
|
|
56
759
|
try:
|
|
57
|
-
|
|
58
|
-
self.browser.close()
|
|
760
|
+
self._close_own_tab()
|
|
59
761
|
finally:
|
|
60
|
-
|
|
61
|
-
self.
|
|
762
|
+
try:
|
|
763
|
+
if self.browser:
|
|
764
|
+
self.browser.close()
|
|
765
|
+
finally:
|
|
766
|
+
try:
|
|
767
|
+
if self._pw:
|
|
768
|
+
self._pw.stop()
|
|
769
|
+
finally:
|
|
770
|
+
# ⚠️ 这里以前放锁。**锁撤了(2026-09-14)⇒ 没有任何东西要放。**
|
|
771
|
+
# 只把声明清掉,免得下一次 attach 之前 `send()` 读到上一轮的 key。
|
|
772
|
+
self._ziniao_key = None
|
|
62
773
|
|
|
63
774
|
def _eval(self, js: str, arg=None):
|
|
64
775
|
return self.page.evaluate(js, arg) if arg is not None else self.page.evaluate(js)
|
|
65
776
|
|
|
66
|
-
# ──
|
|
777
|
+
# ── 单对话隔离(**本 tab 内**同一时间只能有一个对话在进行;跨 tab 可并行,见模块 docstring)──
|
|
67
778
|
def is_generating(self) -> bool:
|
|
68
779
|
"""当前是否有对话正在流式生成。派生自 chat_state(单一真相)。"""
|
|
69
780
|
return self.chat_state() == "generating"
|
|
70
781
|
|
|
782
|
+
# #1635:#1197 B1 真机——两问 AskUserQuestion 卡片,chat_state 判到 waiting_input,read_question 却回 '',
|
|
783
|
+
# 预设 answers 永不触发、5 分钟后 gateway 判超时。那次没留 DOM dump,**真实形态没探到**;候选形态有三种,
|
|
784
|
+
# 这里一并盖住:(a) 已渲染的活卡 innerText 为 ''(祖先 visibility:hidden 之类,rect>0 但 innerText 空);
|
|
785
|
+
# (b) 两次 _eval 之间 DOM 被换掉/重挂;(c) 页面上有**多张** question-card(消息流里内联的历史只读卡 #161B3 T6 无提交按钮
|
|
786
|
+
# + 当前活卡)而老代码四处各取「第一张可见的卡」。
|
|
787
|
+
# ⇒ 活卡的定义只有一个:**卡内有 確認/下一題/補充回答 按钮**(多张取最后一张——DOM 序历史在前、当前在后);
|
|
788
|
+
# 找不到活卡再退到最后一张可见卡。所有读/答/关都用同一条判据。
|
|
789
|
+
_CARD_PICK_JS = r"""
|
|
790
|
+
const cards=[...document.querySelectorAll('[data-testid="question-card"]')];
|
|
791
|
+
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
792
|
+
const isLive=c=>[...c.querySelectorAll('button')].some(b=>/確認|确认|下一題|下一题|補充回答|补充回答/.test((b.textContent||'').trim()));
|
|
793
|
+
const liveCards=cards.filter(isLive);
|
|
794
|
+
const card=liveCards[liveCards.length-1] || cards.filter(vis).pop() || null;
|
|
795
|
+
"""
|
|
796
|
+
|
|
71
797
|
def has_pending_question(self) -> bool:
|
|
72
798
|
"""Agent 是否停在「需要你的輸入」(AskUserQuestion 弹问)。派生自 chat_state。
|
|
73
799
|
这个状态既非「生成中」也非「结束」,若不处理,下一个用例会串进这个卡住的对话。"""
|
|
@@ -75,9 +801,7 @@ class ChatDriver:
|
|
|
75
801
|
|
|
76
802
|
def dismiss_pending_question(self) -> bool:
|
|
77
803
|
"""关掉待输入问题框(点卡内 取消/關閉),让对话回到可开新对话的状态。返回是否点到。"""
|
|
78
|
-
clicked = bool(self._eval(
|
|
79
|
-
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
80
|
-
const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
|
|
804
|
+
clicked = bool(self._eval("()=>{" + self._CARD_PICK_JS + r"""
|
|
81
805
|
if(!card) return false;
|
|
82
806
|
const btn=[...card.querySelectorAll('button')].find(e=>/^(取消|關閉|关闭)$/.test((e.textContent||'').trim()) && vis(e) && !e.disabled);
|
|
83
807
|
if(btn){btn.click(); return true;}
|
|
@@ -88,12 +812,28 @@ class ChatDriver:
|
|
|
88
812
|
return clicked
|
|
89
813
|
|
|
90
814
|
def read_question(self) -> str:
|
|
91
|
-
"""读当前 AskUserQuestion 卡片全文(问题+选项标题+按钮)——供 _pick_answer 匹配。
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
815
|
+
"""读当前 AskUserQuestion 卡片全文(问题+选项标题+按钮)——供 _pick_answer 匹配。
|
|
816
|
+
|
|
817
|
+
#1635:**不按可见性过滤**(可见性留给 answer_question 点按钮那步)——活卡先 `scrollIntoView` 再读,
|
|
818
|
+
`innerText` 空就退到 `textContent`(祖先 visibility:hidden 时 innerText 为空、textContent 仍在);
|
|
819
|
+
没有活卡就把**所有** question-card 的文本合并(历史只读卡也算,宁可多匹配也别空串)。
|
|
820
|
+
读到的来源记在 `self.last_question`(`source ∈ live|all_cards|none`、`n_cards`、`text`),
|
|
821
|
+
wait_reply 会把它带进返回值 —— **「读不到」和「没问」不许长得一样**。"""
|
|
822
|
+
got = self._eval("()=>{" + self._CARD_PICK_JS + r"""
|
|
823
|
+
const text=e=>(e.innerText||e.textContent||'').trim();
|
|
824
|
+
if(card && isLive(card)){ // 只有真活卡才单读;退回来的「最后一张可见卡」走合并
|
|
825
|
+
try{ card.scrollIntoView({block:'nearest'}); }catch(e){} // nearest:别把 react-window 的自动跟随关掉
|
|
826
|
+
const t=text(card);
|
|
827
|
+
if(t) return {source:'live', n_cards:cards.length, text:t.slice(0,1200)};
|
|
828
|
+
}
|
|
829
|
+
const all=cards.map(text).filter(Boolean).join('\n---\n');
|
|
830
|
+
if(all) return {source:'all_cards', n_cards:cards.length, text:all.slice(-1200)};
|
|
831
|
+
return {source:'none', n_cards:cards.length, text:''};
|
|
832
|
+
}""") or {"source": "none", "n_cards": 0, "text": ""}
|
|
833
|
+
if not isinstance(got, dict):
|
|
834
|
+
got = {"source": "none", "n_cards": 0, "text": str(got or "")}
|
|
835
|
+
self.last_question = got
|
|
836
|
+
return got.get("text") or ""
|
|
97
837
|
|
|
98
838
|
def answer_question(self, text: str) -> bool:
|
|
99
839
|
"""给当前 AskUserQuestion **回答并提交** —— 真跟鸭嘴兽对话往下走(不是关掉)。
|
|
@@ -101,11 +841,10 @@ class ChatDriver:
|
|
|
101
841
|
① 选项题:`text` 与某选项标题互含 → 点该选项行;否则点「其它」展开 textarea 填 text;
|
|
102
842
|
② 开放题(0 选项):直接有 textarea → 填 text。
|
|
103
843
|
再点「下一題」(非末题) 或「確認/補充回答」(末题) 提交。返回提交动作是否真的点了。"""
|
|
104
|
-
acted = self._eval(
|
|
105
|
-
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
844
|
+
acted = self._eval("(t)=>{" + self._CARD_PICK_JS + r"""
|
|
106
845
|
const norm=s=>(s||'').replace(/\s+/g,'').toLowerCase();
|
|
107
|
-
const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
|
|
108
846
|
if(!card) return 'no-card';
|
|
847
|
+
try{ card.scrollIntoView({block:'nearest'}); }catch(e){}
|
|
109
848
|
const nt=norm(t);
|
|
110
849
|
// 选项行 = 卡内 cursor:pointer 的 div(OptionRow),标题在 .font-medium
|
|
111
850
|
const rows=[...card.querySelectorAll('div')].filter(e=>{
|
|
@@ -136,18 +875,14 @@ class ChatDriver:
|
|
|
136
875
|
self.page.wait_for_timeout(700)
|
|
137
876
|
# 若刚点开「其它」,textarea 才出现 → 再填一次
|
|
138
877
|
if acted == "other-opened":
|
|
139
|
-
self._eval(
|
|
140
|
-
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
141
|
-
const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
|
|
878
|
+
self._eval("(t)=>{" + self._CARD_PICK_JS + r"""
|
|
142
879
|
const ta=card && [...card.querySelectorAll('textarea')].find(vis);
|
|
143
880
|
if(ta){const set=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
|
|
144
881
|
ta.focus(); set.call(ta,t); ta.dispatchEvent(new Event('input',{bubbles:true}));}
|
|
145
882
|
}""", text)
|
|
146
883
|
self.page.wait_for_timeout(500)
|
|
147
884
|
# 提交:末题点 確認/補充回答,非末题点 下一題(都在卡内、非取消/關閉、未 disabled)
|
|
148
|
-
submitted = bool(self._eval(
|
|
149
|
-
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
150
|
-
const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
|
|
885
|
+
submitted = bool(self._eval("()=>{" + self._CARD_PICK_JS + r"""
|
|
151
886
|
if(!card) return false;
|
|
152
887
|
const btns=[...card.querySelectorAll('button')].filter(e=>vis(e) && !e.disabled);
|
|
153
888
|
const b=btns.find(e=>/^(確認|确认|補充回答|补充回答)$/.test((e.textContent||'').replace(/[✓\s]/g,'')))
|
|
@@ -169,10 +904,6 @@ class ChatDriver:
|
|
|
169
904
|
return a.get("answer")
|
|
170
905
|
return None
|
|
171
906
|
|
|
172
|
-
def is_service_error(self) -> bool:
|
|
173
|
-
"""yzsgo 侧 LLM 服务报错(「AI 服務出錯」/llm_error toast)—— 非 skill 缺陷。"""
|
|
174
|
-
return self.chat_state() == "service_error"
|
|
175
|
-
|
|
176
907
|
def read_toasts(self) -> list:
|
|
177
908
|
"""读**全局 ToastContainer**(providers.tsx 挂载的 div[aria-live="assertive"],
|
|
178
909
|
ui/Toast.tsx 渲染)里当前可见的 toast。返回 [{type,title,description}],
|
|
@@ -191,6 +922,10 @@ class ChatDriver:
|
|
|
191
922
|
});
|
|
192
923
|
}""") or []
|
|
193
924
|
|
|
925
|
+
def is_service_error(self) -> bool:
|
|
926
|
+
"""yzsgo 侧 LLM 服务报错(「AI 服務出錯」/llm_error toast)—— 非 skill 缺陷。"""
|
|
927
|
+
return self.chat_state() == "service_error"
|
|
928
|
+
|
|
194
929
|
def chat_state(self) -> str:
|
|
195
930
|
"""**统一感知对话 UI 状态**(单一真相),优先级:待输入 > 生成中 > 报错 > 空闲。
|
|
196
931
|
所有 send/answer/wait 都据此判断,不靠「填了字就以为发出去了」。返回:
|
|
@@ -206,17 +941,14 @@ class ChatDriver:
|
|
|
206
941
|
return self._eval(r"""()=>{
|
|
207
942
|
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
208
943
|
// ① 待输入:question-card 且**可答**(卡内有 確認/下一題/補充回答 按钮;readOnly 历史卡无按钮=不算)
|
|
209
|
-
|
|
210
|
-
|
|
944
|
+
// #1635:多张卡时看**任何一张**可见活卡,不是「第一张可见卡恰好有按钮」(第一张可能是历史只读卡)
|
|
945
|
+
const cards=[...document.querySelectorAll('[data-testid="question-card"]')];
|
|
946
|
+
const isLive=c=>[...c.querySelectorAll('button')].some(b=>/確認|确认|下一題|下一题|補充回答|补充回答/.test((b.textContent||'').trim()));
|
|
947
|
+
if(cards.some(c=>vis(c) && isLive(c))) return 'waiting_input';
|
|
211
948
|
// ② 生成中:stop-button 在(权威信号,优先于残留错误 toast)
|
|
212
949
|
if([...document.querySelectorAll('[data-testid="stop-button"]')].some(vis)) return 'generating';
|
|
213
950
|
// ③ 报错 toast:仅在**既不待输入也不生成**时才当真(自身文本短=真 toast)
|
|
214
951
|
if([...document.querySelectorAll('*')].some(e=>{const t=(e.textContent||'').trim();return t.length<40 && /服務出錯,請重試|服务出错,请重试|AI 服務出錯|AI 服务出错/.test(t) && vis(e);})) return 'service_error';
|
|
215
|
-
// ③b 全局 ToastContainer(providers.tsx 的 div[aria-live=assertive])里的 **error 级** toast:
|
|
216
|
-
// useFriendlyError 走这条路(如 未找到/SESSION_NOT_FOUND、伺服器錯誤…),旧检查只认「AI 服務出錯」
|
|
217
|
-
// 文案,这类 toast 完全感知不到(2026-08-31 用户截图实证盲区)。warning 级(重試类)不翻状态。
|
|
218
|
-
const tc=document.querySelector('div[aria-live="assertive"]');
|
|
219
|
-
if(tc && [...tc.querySelectorAll('div.rounded-lg')].some(e=>vis(e)&&/bg-red-50/.test(e.className||''))) return 'service_error';
|
|
220
952
|
// ④ 输入框 disabled = 忙(生成中/上一轮 finalizing,但没显示 stop-button)→ 不算 idle,
|
|
221
953
|
// 否则 send 会往禁用框打字+回车、无声失败(send_failed)。
|
|
222
954
|
const ta=document.querySelector('textarea');
|
|
@@ -225,9 +957,11 @@ class ChatDriver:
|
|
|
225
957
|
}""") or "idle"
|
|
226
958
|
|
|
227
959
|
def ensure_idle(self, timeout: int = 90) -> bool:
|
|
228
|
-
"""
|
|
229
|
-
stop(abort),不被动等它自然结束**(用户点醒:我发起的我停,不用干等)。
|
|
230
|
-
待输入→关问题框;报错→reload 清 toast。返回是否 idle。
|
|
960
|
+
"""让**本 tab** 回到 idle 好开新的。**本 tab 的对话都是本 driver 发起、我控制它——正在生成
|
|
961
|
+
就直接 stop(abort),不被动等它自然结束**(用户点醒:我发起的我停,不用干等)。
|
|
962
|
+
待输入→关问题框;报错→reload 清 toast。返回是否 idle。
|
|
963
|
+
⚠️ 作用域**只有本 tab**:并行跑时别指望它能清别的 tab,也绝不会误停别的 tab 的 turn
|
|
964
|
+
(chat_state/stop_generating 都只看 self.page 的 DOM)。"""
|
|
231
965
|
start = time.time()
|
|
232
966
|
while time.time() - start < timeout:
|
|
233
967
|
st = self.chat_state()
|
|
@@ -282,21 +1016,51 @@ class ChatDriver:
|
|
|
282
1016
|
}""", name)
|
|
283
1017
|
self.page.wait_for_timeout(2000)
|
|
284
1018
|
|
|
1019
|
+
def concurrency_status(self) -> dict | None:
|
|
1020
|
+
"""读 gateway 的并发名额 `GET /api/sessions/concurrency` → {active, limit}。
|
|
1021
|
+
并行跑测**定标并行度**用:limit 是按 plan 的会话数上限(free 1 / starter 2 / pro 4 /
|
|
1022
|
+
enterprise 20 / custom 不限);`limit=None` = 不执法(无权益/billing 不可达),
|
|
1023
|
+
**不等于无限**,此时别贪,按保守值走。借页面自己的 token 发请求,不额外要凭据。
|
|
1024
|
+
读不到返回 None(网络/未登录/端点变更),调用方按「未知」处理、别当 0。"""
|
|
1025
|
+
return self._eval(r"""async ()=>{
|
|
1026
|
+
try{
|
|
1027
|
+
const t=localStorage.getItem('unified_access_token'); if(!t) return null;
|
|
1028
|
+
const base=[...new Set(performance.getEntriesByType('resource')
|
|
1029
|
+
.map(e=>{try{return new URL(e.name).origin}catch(_){return ''}}))]
|
|
1030
|
+
.find(o=>/(^|\/\/)(gw|gateway)\./.test(o));
|
|
1031
|
+
if(!base) return null;
|
|
1032
|
+
const r=await fetch(base+'/api/sessions/concurrency',{headers:{Authorization:'Bearer '+t}});
|
|
1033
|
+
if(!r.ok) return null;
|
|
1034
|
+
const j=await r.json();
|
|
1035
|
+
return (typeof j.active==='number') ? {active:j.active, limit:j.limit} : null;
|
|
1036
|
+
}catch(_){ return null; }
|
|
1037
|
+
}""")
|
|
1038
|
+
|
|
285
1039
|
def preflight(self, timeout: int = 150) -> dict:
|
|
286
1040
|
"""测试前置预检(每轮必做):确认桌面客户端连着**正确账号**、能列出可操作的紫鸟店。
|
|
287
|
-
|
|
1041
|
+
|
|
1042
|
+
判据取 `browser-cli ziniao doctor` 的权威输出,不解析自由文本(见 _PREFLIGHT_PROMPT 的注释)。
|
|
1043
|
+
返回 {ok(三态), stores, shop_count, reply, scan_chars}。"""
|
|
288
1044
|
self.goto_tab("AI 助手")
|
|
289
1045
|
self.new_conversation()
|
|
290
|
-
r = self.send_and_wait(
|
|
1046
|
+
r = self.send_and_wait(_PREFLIGHT_PROMPT, timeout)
|
|
291
1047
|
txt = r["text"]
|
|
292
|
-
|
|
293
|
-
ok
|
|
294
|
-
|
|
1048
|
+
res = parse_preflight(txt)
|
|
1049
|
+
if res["ok"] is None: # 没抓到 → 再抓一次页面文本(不重发消息)
|
|
1050
|
+
self.page.wait_for_timeout(3000)
|
|
1051
|
+
txt2 = self._main_panel_text() or ""
|
|
1052
|
+
if len(txt2) > len(txt):
|
|
1053
|
+
txt = txt2
|
|
1054
|
+
res = parse_preflight(txt)
|
|
1055
|
+
res.update({"reply": txt[:600], "scan_chars": len(txt)})
|
|
1056
|
+
return res
|
|
295
1057
|
|
|
296
1058
|
def new_conversation(self) -> bool:
|
|
297
1059
|
"""开新对话,隔离每个测试用例。开新对话是**图标按钮**(文本空、靠 aria-label「新建對話」),
|
|
298
1060
|
不能用文本匹配。找不到则留在当前对话,返回 False。
|
|
299
|
-
⚠️
|
|
1061
|
+
⚠️ **同一个 tab(=同一个 gateway session)里**同一时间只能一个对话在跑 —— 上一个还在生成时
|
|
1062
|
+
开新对话会失败/串数据,先 ensure_idle。想真并行请开多个 tab(attach(own_tab=True)),
|
|
1063
|
+
不是在这一个 tab 里连开对话。"""
|
|
300
1064
|
self.ensure_idle()
|
|
301
1065
|
click_new = r"""()=>{
|
|
302
1066
|
const el=[...document.querySelectorAll('button,a,[role=button]')].find(e=>{
|
|
@@ -307,10 +1071,23 @@ class ChatDriver:
|
|
|
307
1071
|
if(!el) return false; el.click(); return true;
|
|
308
1072
|
}"""
|
|
309
1073
|
ok = self._eval(click_new)
|
|
1074
|
+
if not ok:
|
|
1075
|
+
# 「新建對話」按钮找不到 ≠ 按钮没了——技能市场等视图与聊天页**共用 /chat URL**,
|
|
1076
|
+
# goto_chat() 会 no-op 停在别的视图(按钮 width=0)。切回「AI 助手」tab 再试一次。
|
|
1077
|
+
self.goto_tab("AI 助手")
|
|
1078
|
+
self.ensure_idle()
|
|
1079
|
+
ok = self._eval(click_new)
|
|
310
1080
|
if ok:
|
|
311
1081
|
self.page.wait_for_timeout(1500)
|
|
312
|
-
#
|
|
313
|
-
|
|
1082
|
+
# 校验真空白:新对话既不该残留「已完成 N 個工具」面板,**也不该残留 AskUserQuestion 问答卡**。
|
|
1083
|
+
# ⚠️ 漏了问答卡这条,就是 gmvmax 用例抓到上一条 promotions 残留问答卡(「建一场秒杀活动…」待输入)
|
|
1084
|
+
# 的根因——工具面板碰巧空了就被当「真空白」放行,旧问答卡却还盖在页上。两样都空才算真新建成功。
|
|
1085
|
+
# (鸭嘴兽 chat 页只有这两类残留会串下一条;这里的“框”指 question-card,不是卖家后台的风控验证码。)
|
|
1086
|
+
fresh = self._eval(r"""()=>{
|
|
1087
|
+
const hasTool=[...document.querySelectorAll('button[aria-expanded]')].some(e=>/個工具|个工具/.test(e.textContent||''));
|
|
1088
|
+
const hasCard=[...document.querySelectorAll('[data-testid="question-card"]')].some(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;});
|
|
1089
|
+
return !hasTool && !hasCard;
|
|
1090
|
+
}""")
|
|
314
1091
|
if not fresh:
|
|
315
1092
|
self.page.goto(CHAT_URL, wait_until="domcontentloaded")
|
|
316
1093
|
self.page.wait_for_timeout(5000)
|
|
@@ -321,14 +1098,47 @@ class ChatDriver:
|
|
|
321
1098
|
return bool(ok)
|
|
322
1099
|
|
|
323
1100
|
# ── 发消息 + 等回复 ──
|
|
1101
|
+
# 🔴 `panel_found` 独立一格(#676):**「面板不在」和「面板里 0 个工具」不许共用 `count: 0`。**
|
|
1102
|
+
# `False` ⇒ 这一轮的**工具维度整个作废**(不 pass 不 fail —— 它没观测到任何东西,
|
|
1103
|
+
# 而 pass 和 fail 都是断言),**其余维度照常判**。
|
|
324
1104
|
_EMPTY_TRACE = {"tools": [], "count": 0, "names": [], "failures": 0, "failed_tools": [], "repeats": {},
|
|
325
|
-
"
|
|
1105
|
+
"panel_found": False,
|
|
1106
|
+
"script_failed": 0, "script_ok": 0,
|
|
1107
|
+
"script_last": None, "script_scan_chars": 0, "risk_hits": 0,
|
|
1108
|
+
"login_states": [], "session_limit_hits": 0,
|
|
1109
|
+
"session_limit_mentions": 0}
|
|
326
1110
|
|
|
327
|
-
def send(self, msg: str) -> bool:
|
|
1111
|
+
def send(self, msg: str, *, ziniao=_INHERIT) -> bool:
|
|
328
1112
|
"""发消息,并**验证真的发出去了**(状态离开 idle → generating/waiting,或输入框清空+消息上屏)。
|
|
329
1113
|
发不出去(仍 idle 且没上屏)返回 False —— 不再「填了字就以为发出去了」。
|
|
330
|
-
⚠️ **发之前强制 ensure_idle
|
|
331
|
-
往忙着的对话里塞消息 → concurrent/傳送中卡死。从代码上根绝——绝不往非 idle 的对话发。
|
|
1114
|
+
⚠️ **发之前强制 ensure_idle**:本 tab 的对话都是本 driver 发起的,上一个 turn 没结束就发下一个 =
|
|
1115
|
+
往忙着的对话里塞消息 → concurrent/傳送中卡死。从代码上根绝——绝不往非 idle 的对话发。
|
|
1116
|
+
|
|
1117
|
+
## `ziniao=` —— **这一条消息**要动哪个 profile(#666)
|
|
1118
|
+
|
|
1119
|
+
🔴 **`-3` 给的判据**:**「需要备份还原,就说明那个值待在了不属于它的地方。」**
|
|
1120
|
+
|
|
1121
|
+
改之前:`run_case` 把本用例的 key **挂到 driver 上**,`send()` 去 `self._ziniao_key` 取,
|
|
1122
|
+
用完**还原**。⇒ **那个事实有两个家**,两家之间靠手工同步连着,**每次同步都是一条新缝**:
|
|
1123
|
+
一天之内那道闸误伤三次,**三次是同一个层级错位的三个出口**。
|
|
1124
|
+
|
|
1125
|
+
改之后:**谁决定谁传,读的人不去别处取。**
|
|
1126
|
+
|
|
1127
|
+
| 传什么 | 意思 |
|
|
1128
|
+
|---|---|
|
|
1129
|
+
| `ziniao="ziniao-<id>"` / `"<id>"` | **这一条**消息动的是这个 profile |
|
|
1130
|
+
| `ziniao=None` | **这一条**消息明确不碰任何 profile |
|
|
1131
|
+
| **不传** | 用 `attach()` 时那个声明当**默认值** |
|
|
1132
|
+
|
|
1133
|
+
⚠️ **「不传」和 `None` 不共用取值**:`None` 是一个**声明**,不传是**沿用默认**。
|
|
1134
|
+
这就是为什么默认值是 `_INHERIT` 这个哨兵,而**不是 `None`**
|
|
1135
|
+
(`None` 当默认 ⇒ 「忘了传」会被读成「我明确不碰」——**最危险的那种默认**)。
|
|
1136
|
+
"""
|
|
1137
|
+
# 🔴 第三道闸(#611,`-4` review 提的):**声明与事实对账**。
|
|
1138
|
+
# 前两道挡的是「忘了传」;这一道挡的是「**传了一个假的**」——
|
|
1139
|
+
# 而那正是三次真实越锁里两次的形态。**排在最前面:发出去就收不回来了。**
|
|
1140
|
+
key = getattr(self, "_ziniao_key", None) if ziniao is _INHERIT else ziniao
|
|
1141
|
+
check_declaration(key, msg)
|
|
332
1142
|
if not self.ensure_idle(180):
|
|
333
1143
|
self._last_send_fail = "concurrent" # 上一个 turn 迟迟不结束 → 别硬发
|
|
334
1144
|
return False
|
|
@@ -352,25 +1162,40 @@ class ChatDriver:
|
|
|
352
1162
|
if st == "service_error":
|
|
353
1163
|
self._last_send_fail = "service_error"
|
|
354
1164
|
return False
|
|
355
|
-
#
|
|
356
|
-
# ①
|
|
357
|
-
#
|
|
1165
|
+
# 消息被拒的**五种**根因,UI 都不进生成但含义天差地别,必须区分(否则 judge 报错方向全反):
|
|
1166
|
+
# ① 'concurrency_limit':会话数撞 plan 上限(ConcurrencyLimitNotice,testid
|
|
1167
|
+
# concurrency-limit-notice)。并行跑测的头号 blocked —— 降并行度或升 plan,
|
|
1168
|
+
# 跟积分/额度都无关。plan 分档:free 1 / starter 2 / pro 4 / enterprise 20。
|
|
1169
|
+
# ② 'busy_elsewhere':**这个对话**正被别的 session(另一个 tab)处理
|
|
1170
|
+
# (ConversationBusyNotice,testid conversation-busy-notice;文案「傳送失敗(另一會話處理中)」)。
|
|
1171
|
+
# 🔴 它的文案里**也含「傳送失敗」**——必须排在 ④ 前面判,否则会被误报成积分不足。
|
|
1172
|
+
# ③ 'concurrent':**同一个 session 内**前一个对话还没跑完(另一對話正在處理/會話正忙)。
|
|
1173
|
+
# 注意 ② 和 ③ 不是一回事:② 跨 session,③ 同 session。
|
|
1174
|
+
# ④ 「傳送失敗/传送失败」:通用发送被拒,真实根因看 console(toast 本身分不出):
|
|
358
1175
|
# [Chat Error] weekly_limit(本周额度用完)→ 'weekly_limit'(要升级 plan/等重置,充积分没用!)
|
|
359
1176
|
# 否则按积分耗尽(余额不足发不出)→ 'credits'
|
|
360
|
-
# 收集 text='' 的失败也能被 judge
|
|
1177
|
+
# 收集 text='' 的失败也能被 judge 分流(降并行 vs 换 tab vs 等锁 vs 升级 plan vs 充值)。
|
|
1178
|
+
# ①② 用前端**稳定 testid**认(agentic-chat ConcurrencyLimitNotice.tsx:46 /
|
|
1179
|
+
# ConversationBusyNotice.tsx:32),比文案可靠;③④ 才退回短文本匹配。
|
|
361
1180
|
rej = self._eval(r"""()=>{
|
|
362
1181
|
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
1182
|
+
const seen=id=>[...document.querySelectorAll('[data-testid="'+id+'"]')].some(vis);
|
|
1183
|
+
if(seen('concurrency-limit-notice')) return 'concurrency_limit';
|
|
1184
|
+
if(seen('conversation-busy-notice')) return 'busy_elsewhere';
|
|
363
1185
|
const hit=[...document.querySelectorAll('*')].find(e=>{const t=(e.textContent||'').trim();
|
|
364
1186
|
return t.length<30 && /另一對話正在處理|另一对话正在处理|會話正忙|会话正忙|請稍後重試|请稍后重试|傳送失敗|传送失败/.test(t) && vis(e);});
|
|
365
1187
|
if(!hit) return '';
|
|
366
1188
|
const t=(hit.textContent||'').trim();
|
|
1189
|
+
// 「傳送失敗(另一會話處理中)」= 跨 session 忙,别当积分不足
|
|
1190
|
+
if(/另一會話處理中|另一会话处理中|另一個分頁|另一个标签页/.test(t)) return 'busy_elsewhere';
|
|
367
1191
|
return /傳送失敗|传送失败/.test(t) ? 'credits' : 'concurrent';
|
|
368
1192
|
}""")
|
|
369
1193
|
if rej == "credits" and any(("weekly_limit" in e or "本周额度" in e or "本週額度" in e)
|
|
370
1194
|
for e in self._console_errs):
|
|
371
1195
|
rej = "weekly_limit" # console 坐实:不是积分,是本周额度墙(升级 plan/等重置)
|
|
372
1196
|
if rej:
|
|
373
|
-
|
|
1197
|
+
# concurrency_limit / busy_elsewhere / weekly_limit / credits / concurrent
|
|
1198
|
+
self._last_send_fail = rej
|
|
374
1199
|
return False
|
|
375
1200
|
# 「傳送中」= 消息在途(还没被后端接受进生成),继续等,别当已发
|
|
376
1201
|
if self._eval(r"""()=>{const b=document.body.innerText||'';return b.includes('傳送中')||b.includes('传送中');}"""):
|
|
@@ -394,6 +1219,7 @@ class ChatDriver:
|
|
|
394
1219
|
stable = 0
|
|
395
1220
|
tail = ""
|
|
396
1221
|
end_state = "timeout"
|
|
1222
|
+
question_seen = None
|
|
397
1223
|
while time.time() - start < timeout:
|
|
398
1224
|
time.sleep(3)
|
|
399
1225
|
st = self.chat_state()
|
|
@@ -402,8 +1228,17 @@ class ChatDriver:
|
|
|
402
1228
|
break
|
|
403
1229
|
if st == "waiting_input":
|
|
404
1230
|
q = self.read_question()
|
|
1231
|
+
question_seen = dict(getattr(self, "last_question", None) or {"source": "none", "n_cards": 0, "text": ""})
|
|
1232
|
+
if not q:
|
|
1233
|
+
# #1635:卡片文本读不到(虚拟列表把它卸了 / 结构变了)⇒ 至少拿页面尾部去匹配,别让预设答案永不触发;
|
|
1234
|
+
# 同时把 source 记成 body_tail —— 「读不到」和「没问」不许长得一样
|
|
1235
|
+
q = (self._eval("()=>document.body.innerText||''") or "")[-1500:]
|
|
1236
|
+
question_seen = {"source": "body_tail" if q else "none", "n_cards": question_seen.get("n_cards", 0), "text": q[-1200:]}
|
|
405
1237
|
ans = self._pick_answer(q, answers)
|
|
1238
|
+
question_seen["matched"] = ans # 预设答案里匹配到了哪条(None = 没匹配到)
|
|
1239
|
+
question_seen["answered"] = None # 只有 answer_question 真的提交了才记 —— 「匹配到但没答上」≠「答了」
|
|
406
1240
|
if ans is not None and self.answer_question(ans):
|
|
1241
|
+
question_seen["answered"] = ans
|
|
407
1242
|
stable = 0
|
|
408
1243
|
prev = "" # 回答后 Agent 继续,重置稳定判定
|
|
409
1244
|
continue
|
|
@@ -434,19 +1269,18 @@ class ChatDriver:
|
|
|
434
1269
|
"elapsed": round(time.time() - start, 1),
|
|
435
1270
|
"timed_out": end_state == "timeout",
|
|
436
1271
|
"state": end_state,
|
|
1272
|
+
"question": question_seen, # #1635:停在 waiting_input 时这里是卡片原文 + 来源,None = 这一轮没问过
|
|
437
1273
|
"tool_trace": self._scrape_tool_trace(),
|
|
438
|
-
# 全局 toast 快照(错误码根因,如「錯誤代碼:SESSION_NOT_FOUND」)——service_error/timeout
|
|
439
|
-
# 时判 blocked 根因用;正常 done 时多为空(非 persist toast 5s 自动消失)。
|
|
440
|
-
"toasts": self.read_toasts(),
|
|
441
1274
|
}
|
|
442
1275
|
|
|
443
|
-
def send_and_wait(self, msg: str, timeout: int = 180, answers=None) -> dict:
|
|
444
|
-
|
|
1276
|
+
def send_and_wait(self, msg: str, timeout: int = 180, answers=None, *, ziniao=_INHERIT) -> dict:
|
|
1277
|
+
# ⚠️ **原样透传**,不在这儿做任何判断 —— 多一层解释就多一个家(#666)。
|
|
1278
|
+
if not self.send(msg, ziniao=ziniao):
|
|
445
1279
|
# 消息没真发出去(对话忙/并发锁未释放/积分耗尽/报错)—— 别再 wait 一堆残留内容。
|
|
446
1280
|
# state 区分 credits(积分不足)/ concurrent(前一 turn 未释放)/ service_error / send_failed,judge 据此报清楚。
|
|
447
1281
|
return {"text": "", "transcript": "", "tail": "", "elapsed": 0,
|
|
448
1282
|
"timed_out": False, "state": getattr(self, "_last_send_fail", None) or "send_failed",
|
|
449
|
-
"
|
|
1283
|
+
"question": None, "tool_trace": dict(self._EMPTY_TRACE)}
|
|
450
1284
|
return self.wait_reply(timeout, answers)
|
|
451
1285
|
|
|
452
1286
|
def stop_generating(self) -> None:
|
|
@@ -469,17 +1303,22 @@ class ChatDriver:
|
|
|
469
1303
|
网页结构(实测):每次 Agent 回复带一个可展开 button「已完成 N 個工具」,展开后每个 tool 一行
|
|
470
1304
|
= <name> <status> [參數][結果](name 如 bash/write/read;status 已完成/失敗/進行中)。"""
|
|
471
1305
|
# 只看**最后一条回复**的工具面板(当前测试用例;用例间应 new_conversation 隔离)
|
|
1306
|
+
# 🔴 **先回答「面板在不在」,再回答「里面有几个工具」**(#676)。
|
|
1307
|
+
# 原来 `if(!last) return []` —— **「页面上找不到那个聚合面板」和「面板里一行工具都没有」
|
|
1308
|
+
# 回的是同一个值**,于是都变成 `count: 0`。**而「工具 0 次」正是「编了一个答案」的签名。**
|
|
1309
|
+
# 2026-09-13 量过:299 份 per-case 日志里 26 份是「跑了但报 0」,**而这两种分不开。**
|
|
1310
|
+
self._panel_found = bool(self._eval(r"""()=>{const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));return bs.length>0;}"""))
|
|
472
1311
|
self._eval(r"""()=>{const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));const last=bs[bs.length-1];if(last&&last.getAttribute('aria-expanded')==='false')last.click();}""")
|
|
473
1312
|
self.page.wait_for_timeout(800)
|
|
474
1313
|
tools = self._eval(r"""()=>{
|
|
475
1314
|
const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
|
|
476
1315
|
const last=bs[bs.length-1]; if(!last) return [];
|
|
477
1316
|
const panel=last.parentElement; // 限定到当轮面板
|
|
478
|
-
const ops=[...panel.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 &&
|
|
1317
|
+
const ops=[...panel.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數|参数/.test(e.textContent) && /結果|结果/.test(e.textContent) && e.textContent.trim().length<12);
|
|
479
1318
|
const out=[];
|
|
480
1319
|
for(const op of ops){
|
|
481
1320
|
const t=(op.parentElement?.innerText||'').replace(/\n/g,' ').trim();
|
|
482
|
-
if((t.match(
|
|
1321
|
+
if((t.match(/參數|参数/g)||[]).length!==1) continue; // 跳过嵌套整面板行
|
|
483
1322
|
const m=t.match(/^(\S+)\s+(已完成|失敗|失败|進行中|进行中|錯誤|错误|error|失敗了)/i);
|
|
484
1323
|
if(m) out.push({name:m[1], status:m[2]});
|
|
485
1324
|
}
|
|
@@ -497,22 +1336,52 @@ class ChatDriver:
|
|
|
497
1336
|
# ✗ Script failed = 脚本任务失败; ✓ Script <...> = 脚本完成(completed 及各 skill 名变体)。
|
|
498
1337
|
# ⚠️ 只在这里就地读、**只回整数计数、不回结果原文**——结果体里可能含 env 明文密钥/JWT(gw#2350),
|
|
499
1338
|
# 绝不落进 transcript/日志(transcript 已在本方法调用前抓定,此处展开不会回灌它)。
|
|
500
|
-
script_failed = script_ok = 0
|
|
1339
|
+
script_failed = script_ok = script_check_failed = risk_hits = 0
|
|
1340
|
+
login_states = []
|
|
1341
|
+
session_limit_hits = 0
|
|
1342
|
+
session_limit_mentions = 0
|
|
501
1343
|
try: # 结果体扫描出任何岔子都只降为 0,绝不让整个 tool_trace 崩掉(它喂所有用例判定)
|
|
502
1344
|
self._eval(r"""()=>{
|
|
503
1345
|
const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
|
|
504
|
-
|
|
1346
|
+
// 🔴 **面板不在就什么都不做** —— 原来回退到 `document`(整页),
|
|
1347
|
+
// 于是 `risk_hits`/`login_states`/`session_limit_hits` 会从**Agent 自己那段回复**里数出来。
|
|
1348
|
+
// **「没读到」被变成了「读了别的东西」,而后者给出一个自信的错答案。**
|
|
1349
|
+
const last=bs[bs.length-1]; if(!last) return; const panel=last.parentElement;
|
|
505
1350
|
for(const b of panel.querySelectorAll('button')){ if(/^(結果|结果)$/.test((b.textContent||'').trim())) b.click(); }
|
|
506
1351
|
}""")
|
|
507
1352
|
self.page.wait_for_timeout(700)
|
|
508
1353
|
res_text = self._eval(r"""()=>{
|
|
509
1354
|
const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
|
|
510
|
-
|
|
1355
|
+
// 🔴 **面板不在 ⇒ 回空串,不回整页**(同上)。空串会让下面每个计数都是 0,
|
|
1356
|
+
// 而 `panel_found=False` 会把它们一起标成 `unknown` —— **0 不再冒充观测值**。
|
|
1357
|
+
const last=bs[bs.length-1]; return last? (last.parentElement.innerText||'') : '';
|
|
511
1358
|
}""") or ""
|
|
1359
|
+
# 🔴 `✓ Script` 会命中 **submit-script 的上传回执**(`✓ Script <name> created/updated`),
|
|
1360
|
+
# 那不是任务完成 —— 而浏览器类 skill 的标准流程第一步就是 submit-script,
|
|
1361
|
+
# 于是 script_ok 恒 >0,「failed>0 且 ok==0」这条降级条件**几乎永不成立**(#241)。
|
|
1362
|
+
# 任务完成的真实回执只有一种:`✓ Script completed`。
|
|
1363
|
+
# 脚本**自报的校验不过**:`✓ Script completed` 但结果里写着「校验=不过」或 ABORT。
|
|
1364
|
+
# #295 实证:add_to_discount 填完回读对不上(填 15%、读回别的商品的 30),
|
|
1365
|
+
# 结果字符串里明明白白写着「校验=不过」,而三维判定只看 ✓/✗ 和关键词 → 判了 pass。
|
|
1366
|
+
script_check_failed = res_text.count("校验=不过") + res_text.count("ABORT ")
|
|
512
1367
|
script_failed = res_text.count("✗ Script failed")
|
|
513
|
-
script_ok = res_text.count("✓ Script")
|
|
1368
|
+
script_ok = res_text.count("✓ Script completed")
|
|
1369
|
+
# 只数次数还不够:失败后重试成功要算成功,成功后重试失败要算失败 ——
|
|
1370
|
+
# 看的是**最后一次**的结局,不是谁多谁少。
|
|
1371
|
+
i_ok = res_text.rfind("✓ Script completed")
|
|
1372
|
+
i_bad = res_text.rfind("✗ Script failed")
|
|
1373
|
+
script_last = ("ok" if i_ok > i_bad else "failed") if (i_ok >= 0 or i_bad >= 0) else None
|
|
1374
|
+
# 抓取失效守卫(#26):结果体一个字都没读到时,上面三个数全是「没发现问题」的样子,
|
|
1375
|
+
# 与「真的没问题」不可区分。把扫描长度带出去,让判定方自己决定信不信。
|
|
1376
|
+
script_scan_chars = len(res_text)
|
|
1377
|
+
# 平台侧风控证据(#331):只认脚本失败回执里自己说撞了风控的那种,只回计数。
|
|
1378
|
+
risk_hits = count_platform_risk_receipts(res_text)
|
|
1379
|
+
login_states += scrape_login_states(res_text)
|
|
1380
|
+
session_limit_hits += count_session_limit_receipts(res_text)
|
|
1381
|
+
session_limit_mentions += count_session_limit_mentions(res_text)
|
|
514
1382
|
except Exception: # noqa: BLE001
|
|
515
1383
|
pass
|
|
1384
|
+
skill_src = self._scrape_skill_source_edits()
|
|
516
1385
|
return {
|
|
517
1386
|
"tools": tools, # [{name,status}] 完整顺序
|
|
518
1387
|
"count": len(tools),
|
|
@@ -520,15 +1389,81 @@ class ChatDriver:
|
|
|
520
1389
|
"failures": len(failures),
|
|
521
1390
|
"failed_tools": [t["name"] for t in failures],
|
|
522
1391
|
"repeats": repeats, # {工具名: 次数},看有没有反复
|
|
1392
|
+
# 🔴 **这一轮到底有没有那个面板**(#676)。`False` ⇒ 上面每个计数都是
|
|
1393
|
+
# 「没读到」,**不是「读到了 0」** —— 消费者要据此把工具维度作废。
|
|
1394
|
+
"panel_found": self._panel_found,
|
|
523
1395
|
"script_failed": script_failed, # stdout 里 `✗ Script failed` 次数(任务级失败)
|
|
524
|
-
"script_ok": script_ok, # stdout 里 `✓ Script
|
|
1396
|
+
"script_ok": script_ok, # stdout 里 `✓ Script completed` 次数(**任务级**完成)
|
|
1397
|
+
"script_last": script_last, # 最后一次脚本任务的结局:"ok" / "failed" / None
|
|
1398
|
+
"script_scan_chars": script_scan_chars, # 结果体扫到的字符数;0 = 没抓到,别信上面三个数
|
|
1399
|
+
"script_check_failed": script_check_failed, # 脚本自报「校验=不过」/ABORT 的次数
|
|
1400
|
+
# 平台侧风控停手回执数(#331)。**0 不代表没撞风控**,只代表没拿到平台侧证据——
|
|
1401
|
+
# 判定方须按三态处理(run.py `classify_risk_control`),不许默认落到 blocked。
|
|
1402
|
+
"risk_hits": risk_hits,
|
|
1403
|
+
"login_states": login_states,
|
|
1404
|
+
# #671:**挨着失败回执**的计数(唯一允许判 hit 的取数)
|
|
1405
|
+
"session_limit_hits": session_limit_hits,
|
|
1406
|
+
# #671:**总出现数**(含文档/源码/复述)。🔴 不是证据,只许走 unknown。
|
|
1407
|
+
# 两者都留着,是为了让「收窄之后一次都不响」和「本来就没出现过」分得开。
|
|
1408
|
+
"session_limit_mentions": session_limit_mentions,
|
|
1409
|
+
# Agent 中途改了自己加载的 skill 源码(#277 实证)——这轮跑通的不是发布版
|
|
1410
|
+
"skill_src_edits": skill_src["count"],
|
|
1411
|
+
"skill_src_slugs": skill_src["slugs"],
|
|
525
1412
|
}
|
|
526
1413
|
|
|
1414
|
+
_SKILL_SRC_RE = re.compile(r"\.claude/skills/([a-z0-9][a-z0-9-]*)/")
|
|
1415
|
+
_FILE_PATH_RE = re.compile(r'"file_path"\s*:\s*"([^"]+)"')
|
|
1416
|
+
|
|
1417
|
+
def _scrape_skill_source_edits(self) -> dict:
|
|
1418
|
+
"""当轮 Agent 有没有**改自己加载的 skill 源码**(`~/.claude/skills/<slug>/…`)。
|
|
1419
|
+
|
|
1420
|
+
#277 实证:跨境店跑 managing-promotions,Agent 连撞三个本土专有假设,
|
|
1421
|
+
在 pod 里 `edit` 了 `/home/aiuser/.claude/skills/managing-promotions/script.py` 三次
|
|
1422
|
+
才把 dry-run 跑通 —— 工具零失败、脚本 `✓ Script completed`、关键词全中,
|
|
1423
|
+
三个维度**一个都没察觉**,直接判 pass。可 pod 是一次性的:下次会话拿到的还是发布版,
|
|
1424
|
+
照样跑不通。**这种轮次的「pass」是拿改过的代码跑出来的,必须降级人工核。**
|
|
1425
|
+
|
|
1426
|
+
判据取 edit/write 类工具**參數里的 `file_path`**(不扫结果体、不扫全文)——
|
|
1427
|
+
Agent 经常写一次性诊断脚本、内容里引用 `~/.claude/skills/…`,扫全文会满屏误报。
|
|
1428
|
+
代价是:參數不暴露 `file_path` 的工具形态会漏检(宁漏勿误报,漏了还有 Wire 兜底)。
|
|
1429
|
+
只回**计数 + skill slug**,不回參數原文(同结果体:可能含 env 明文密钥/JWT,gw#2350)。
|
|
1430
|
+
"""
|
|
1431
|
+
try:
|
|
1432
|
+
blobs = self._eval(r"""()=>{
|
|
1433
|
+
const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
|
|
1434
|
+
// 🔴 **同 #676**:面板不在就回空,**不回退到整页** ——
|
|
1435
|
+
// 否则 Agent 回复里任何带 `參數/結果` 字样的东西都会被当成工具行。
|
|
1436
|
+
const last=bs[bs.length-1]; if(!last) return []; const panel=last.parentElement;
|
|
1437
|
+
const ops=[...panel.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數|参数/.test(e.textContent) && /結果|结果/.test(e.textContent) && e.textContent.trim().length<12);
|
|
1438
|
+
const out=[];
|
|
1439
|
+
for(const op of ops){
|
|
1440
|
+
const row=op.parentElement;
|
|
1441
|
+
const t=(row?.innerText||'').replace(/\n/g,' ').trim();
|
|
1442
|
+
if((t.match(/參數|参数/g)||[]).length!==1) continue;
|
|
1443
|
+
const m=t.match(/^(\S+)\s+/);
|
|
1444
|
+
if(!m||!/^(edit|write|multi_edit|multiedit|notebook_edit|str_replace_editor)$/i.test(m[1])) continue;
|
|
1445
|
+
for(const b of op.querySelectorAll('button')){ if(/^(參數|参数)$/.test((b.textContent||'').trim())) b.click(); }
|
|
1446
|
+
out.push((row?.parentElement?.innerText)||'');
|
|
1447
|
+
}
|
|
1448
|
+
return out;
|
|
1449
|
+
}""") or []
|
|
1450
|
+
except Exception: # noqa: BLE001
|
|
1451
|
+
return {"count": 0, "slugs": []}
|
|
1452
|
+
self.page.wait_for_timeout(400)
|
|
1453
|
+
count, slugs = 0, set()
|
|
1454
|
+
for blob in blobs:
|
|
1455
|
+
for path in self._FILE_PATH_RE.findall(blob or ""):
|
|
1456
|
+
m = self._SKILL_SRC_RE.search(path)
|
|
1457
|
+
if m:
|
|
1458
|
+
count += 1
|
|
1459
|
+
slugs.add(m.group(1))
|
|
1460
|
+
return {"count": count, "slugs": sorted(slugs)}
|
|
1461
|
+
|
|
527
1462
|
def read_tool_io(self, index: int) -> dict:
|
|
528
1463
|
"""深挖第 index 个 tool 的參數/結果内容(点开对应「參數」「結果」button 读文本)。判定存疑时用。"""
|
|
529
1464
|
return self._eval(r"""(idx)=>{
|
|
530
|
-
const ops=[...document.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 &&
|
|
531
|
-
const clean=ops.filter(op=>((op.parentElement?.innerText||'').match(
|
|
1465
|
+
const ops=[...document.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數|参数/.test(e.textContent) && /結果|结果/.test(e.textContent) && e.textContent.trim().length<12);
|
|
1466
|
+
const clean=ops.filter(op=>((op.parentElement?.innerText||'').match(/參數|参数/g)||[]).length===1);
|
|
532
1467
|
const op=clean[idx]; if(!op) return {err:'no-tool'};
|
|
533
1468
|
const btns=[...op.querySelectorAll('button')];
|
|
534
1469
|
btns.forEach(b=>b.click());
|
|
@@ -540,28 +1475,52 @@ class ChatDriver:
|
|
|
540
1475
|
"""当轮 Agent 通过 `load_skill` 加载了哪些 skill —— 用于精确验证 must_call(「到底调没调对 skill」)。
|
|
541
1476
|
实测:load_skill 的參數展开是 `<pre>` JSON `{"name": "<slug>"}`。
|
|
542
1477
|
**结构无关**:不管有没有「N 個工具」聚合头——**单工具轮次**(只 load_skill、无 bash)不生成聚合头,
|
|
543
|
-
load_skill 是个 `<span>` + 就近 `參數/結果` 按钮。直接找所有 `load_skill` 条目、点它的「參數」,读 pre。
|
|
544
|
-
|
|
545
|
-
|
|
1478
|
+
load_skill 是个 `<span>` + 就近 `參數/結果` 按钮。直接找所有 `load_skill` 条目、点它的「參數」,读 pre。
|
|
1479
|
+
|
|
1480
|
+
⚠️ **两个都踩过的坑(2026-09-09 实测)**:
|
|
1481
|
+
|
|
1482
|
+
1. **不能扫全页 `<pre>` 只匹配 `"name"`** —— Agent 最终回复里任何带 `name` 字段的 JSON 代码块
|
|
1483
|
+
(markdown ```json``` 渲染成 `<pre>`)都会被当成加载过的 skill,实测把 meta.json 的商品名
|
|
1484
|
+
`"Lampu Suluh LED Super Terang TJ-G089…"` 记成 skill ⇒ `verify_must_call` **假通过**。
|
|
1485
|
+
2. **「點開參數」是 toggle,不是 open** —— `wait_reply` 里的 `_expand_all_tools()` 已经把面板
|
|
1486
|
+
展开过了,这里再点一次就**把它关掉**,参数 `<pre>` 消失 ⇒ 返回 `[]` **假不通过**。
|
|
1487
|
+
(实测:点击前全页 3 个 `<pre>`,点击后 2 个。)
|
|
1488
|
+
|
|
1489
|
+
修法:**靠形状判别,不靠容器/位置**。load_skill 的參數面板形状是固定的、**只有一个 key**:
|
|
1490
|
+
`{"name": "<slug>"}`;而误报源(listing.json / meta.json 代码块)必然还带别的 key。
|
|
1491
|
+
并且先读一遍再决定要不要点——已经展开就别去 toggle 它。"""
|
|
1492
|
+
# 只认「整段恰好是 {"name": "<slug>"}」的 pre —— 商品/档案 JSON 必然还有 product_id 等其它键,天然排除
|
|
1493
|
+
READ = r"""()=>{
|
|
1494
|
+
const out=[];
|
|
1495
|
+
for(const pre of document.querySelectorAll('pre')){
|
|
1496
|
+
const m=(pre.textContent||'').trim().match(/^\{\s*"name"\s*:\s*"([^"]+)"\s*\}$/);
|
|
1497
|
+
if(m) out.push(m[1]);
|
|
1498
|
+
}
|
|
1499
|
+
return [...new Set(out)];
|
|
1500
|
+
}"""
|
|
1501
|
+
n_calls = self._eval(r"""()=>[...document.querySelectorAll('span,div')]
|
|
1502
|
+
.filter(e=>(e.textContent||'').trim()==='load_skill' && e.getBoundingClientRect().width>0).length""")
|
|
1503
|
+
got = self._eval(READ) or []
|
|
1504
|
+
if not n_calls or len(got) >= n_calls:
|
|
1505
|
+
return got # 面板已经是展开的(或本轮压根没调 load_skill)—— 别去 toggle 它
|
|
1506
|
+
# 还没展开:点一次「參數」再读
|
|
1507
|
+
CLICK = r"""()=>{
|
|
546
1508
|
const nodes=[...document.querySelectorAll('span,div')].filter(e=>(e.textContent||'').trim()==='load_skill' && e.getBoundingClientRect().width>0);
|
|
547
1509
|
for(const n of nodes){
|
|
548
1510
|
let box=n; for(let i=0;i<5 && box;i++){ box=box.parentElement;
|
|
549
1511
|
if(!box) break;
|
|
550
|
-
const p=[...box.querySelectorAll('button')].find(b=>b.textContent.trim()
|
|
1512
|
+
const p=[...box.querySelectorAll('button')].find(b=>['參數','参数'].includes(b.textContent.trim()) && b.getBoundingClientRect().width>0);
|
|
551
1513
|
if(p){ p.click(); break; }
|
|
552
1514
|
}
|
|
553
1515
|
}
|
|
554
|
-
}"""
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
return [...new Set(out)];
|
|
564
|
-
}""") or []
|
|
1516
|
+
}"""
|
|
1517
|
+
for _ in range(2): # 第 2 次是兜底:万一第 1 次点反了(把已开的关上),再点回来
|
|
1518
|
+
self._eval(CLICK)
|
|
1519
|
+
self.page.wait_for_timeout(900)
|
|
1520
|
+
got = self._eval(READ) or []
|
|
1521
|
+
if len(got) >= n_calls:
|
|
1522
|
+
break
|
|
1523
|
+
return got
|
|
565
1524
|
|
|
566
1525
|
def verify_must_call(self, expected) -> dict:
|
|
567
1526
|
"""验证当轮是否加载了期望 skill。expected 可为单个 slug 或 slug 列表(**多选之一**——
|
|
@@ -595,12 +1554,25 @@ class ChatDriver:
|
|
|
595
1554
|
self.page.wait_for_timeout(2200)
|
|
596
1555
|
|
|
597
1556
|
def card_status(self, slug: str) -> str:
|
|
598
|
-
"""搜到卡后读该 slug 卡的状态:已安裝 / 待安裝 / 安裝中 / notfound。
|
|
1557
|
+
"""搜到卡后读该 slug 卡的状态:已安裝 / 待安裝 / 安裝中 / notfound。
|
|
1558
|
+
⚠️ UI 文案随账号 locale 简繁都可能出现(zh-HK「安裝」/ zh-CN「安装」),两种都要匹配。
|
|
1559
|
+
⚠️ **必须遍历 slug 的全部出现位置,不能只取 indexOf 的首次命中**——skill 描述里会互相引用
|
|
1560
|
+
(`developing-video-creative` 的描述写着「交给 producing-hq-ugc-video 生产」,
|
|
1561
|
+
`generating-listing-video` 的描述写着「发布交 publishing-shoppable-video」),
|
|
1562
|
+
首次命中往往落在**别人卡片的描述文字**上,其后 60 字内没有状态词 → 误报 '?' → 上层记「失败」,
|
|
1563
|
+
明明装着却被判没装(2026-09-09 实测踩中)。真卡片的 slug 后面紧跟「免費/包含 N 個技能/已安裝」。"""
|
|
599
1564
|
return self._eval(r"""(slug)=>{
|
|
600
|
-
const body=document.body.innerText||'';
|
|
601
|
-
if(
|
|
602
|
-
|
|
603
|
-
|
|
1565
|
+
const body=document.body.innerText||'';
|
|
1566
|
+
if(body.indexOf(slug)<0) return 'notfound';
|
|
1567
|
+
let seen=false;
|
|
1568
|
+
for(let i=body.indexOf(slug); i>=0; i=body.indexOf(slug, i+1)){
|
|
1569
|
+
seen=true;
|
|
1570
|
+
const seg=body.slice(i, i+60);
|
|
1571
|
+
if(/已安[裝装]/.test(seg)) return '已安裝';
|
|
1572
|
+
if(/安[裝装]中/.test(seg)) return '安裝中';
|
|
1573
|
+
if(/安[裝装]/.test(seg)) return '待安裝';
|
|
1574
|
+
}
|
|
1575
|
+
return seen?'?':'notfound';
|
|
604
1576
|
}""", slug)
|
|
605
1577
|
|
|
606
1578
|
def search_skill(self, slug: str) -> str:
|
|
@@ -616,7 +1588,7 @@ class ChatDriver:
|
|
|
616
1588
|
return True
|
|
617
1589
|
if st == 'notfound':
|
|
618
1590
|
return False
|
|
619
|
-
self._eval(r"""()=>{const btn=[...document.querySelectorAll('button')].find(e
|
|
1591
|
+
self._eval(r"""()=>{const btn=[...document.querySelectorAll('button')].find(e=>/^安[裝装]$/.test(e.textContent.trim()) && e.getBoundingClientRect().width>0 && !e.closest('nav'));if(btn)btn.click();}""")
|
|
620
1592
|
self.page.wait_for_timeout(2500)
|
|
621
1593
|
# 可能的确认弹窗(pilot 实测无,但兜底)
|
|
622
1594
|
self._eval(r"""()=>{const b=[...document.querySelectorAll('button,[role=button]')].find(e=>/確認|确认|確定|确定|立即安/.test(e.textContent.trim()) && e.getBoundingClientRect().width>0);if(b)b.click();}""")
|
|
@@ -649,7 +1621,7 @@ class ChatDriver:
|
|
|
649
1621
|
if __name__ == "__main__":
|
|
650
1622
|
# 冒烟:attach + 报告当前页 + 已装技能数
|
|
651
1623
|
import sys
|
|
652
|
-
d = ChatDriver().attach()
|
|
1624
|
+
d = ChatDriver().attach(ziniao=None, reason="冒烟只看 URL 和已装技能数,不驱动任何店")
|
|
653
1625
|
print("URL:", d.page.url)
|
|
654
1626
|
print("在 chat 页:", "/chat" in d.page.url)
|
|
655
1627
|
if "--install" in sys.argv:
|