codebee 0.1.66 → 0.1.68

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.
Files changed (116) hide show
  1. package/CHANGELOG.md +543 -520
  2. package/LICENSE +21 -21
  3. package/README.md +11 -7
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -0
  12. package/app/core/aiflavor.py +197 -197
  13. package/app/core/artifacts.py +148 -148
  14. package/app/core/assets.py +110 -0
  15. package/app/core/attachments.py +623 -532
  16. package/app/core/automation.py +758 -758
  17. package/app/core/backup.py +470 -470
  18. package/app/core/beekeeper.py +183 -183
  19. package/app/core/board.py +209 -209
  20. package/app/core/bookmeta.py +930 -796
  21. package/app/core/bookmeta_catalog.py +180 -180
  22. package/app/core/branching.py +137 -137
  23. package/app/core/builtin_agent.py +1234 -1234
  24. package/app/core/capability.py +144 -144
  25. package/app/core/catalog.py +42 -0
  26. package/app/core/chaptersafe.py +32 -32
  27. package/app/core/cleanup.py +303 -303
  28. package/app/core/compaction.py +201 -201
  29. package/app/core/covergen.py +356 -356
  30. package/app/core/defectretro.py +43 -0
  31. package/app/core/diagnostics.py +114 -114
  32. package/app/core/dispatch.py +173 -173
  33. package/app/core/dispatch_log.py +128 -113
  34. package/app/core/env_scrub.py +85 -85
  35. package/app/core/error_codes.py +23 -23
  36. package/app/core/errorlog.py +137 -137
  37. package/app/core/evaluation.py +174 -174
  38. package/app/core/failure_notify.py +290 -290
  39. package/app/core/flows.py +48 -2
  40. package/app/core/goal_service.py +167 -167
  41. package/app/core/health.py +374 -374
  42. package/app/core/history.py +38 -38
  43. package/app/core/hooks.py +162 -162
  44. package/app/core/jobs.py +46 -26
  45. package/app/core/knowledge.py +57 -3
  46. package/app/core/manager.py +2122 -2115
  47. package/app/core/market.py +362 -362
  48. package/app/core/market_remote.py +124 -26
  49. package/app/core/mocks.py +64 -64
  50. package/app/core/modelhub.py +51 -3
  51. package/app/core/notify.py +106 -106
  52. package/app/core/operations.py +228 -0
  53. package/app/core/paihang.py +138 -109
  54. package/app/core/paths.py +2 -2
  55. package/app/core/pipeline.py +221 -12
  56. package/app/core/planner.py +6 -1
  57. package/app/core/portguard.py +111 -111
  58. package/app/core/preview.py +293 -293
  59. package/app/core/publish/__init__.py +7 -7
  60. package/app/core/publish/auto.py +367 -367
  61. package/app/core/publish/browser.py +539 -533
  62. package/app/core/publish/fanqie.py +159 -128
  63. package/app/core/publish/flow.py +440 -432
  64. package/app/core/publish/flows-fanqie-calibrated.json +332 -332
  65. package/app/core/publish/flows-qimao-calibrated.json +7 -3
  66. package/app/core/publish/ledger.py +203 -198
  67. package/app/core/publish/manager.py +692 -623
  68. package/app/core/publish/qimao.py +141 -141
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/redact.py +38 -38
  71. package/app/core/repeat_guard.py +129 -129
  72. package/app/core/revisions.py +51 -0
  73. package/app/core/router.py +398 -398
  74. package/app/core/runner.py +2067 -2050
  75. package/app/core/selfupdate.py +384 -384
  76. package/app/core/session_log.py +161 -161
  77. package/app/core/sessions.py +312 -312
  78. package/app/core/settings.py +149 -149
  79. package/app/core/settings_schema.py +262 -262
  80. package/app/core/share_page.py +114 -114
  81. package/app/core/skill_scan.py +122 -114
  82. package/app/core/skillpacks/fanqie-novel.md +80 -80
  83. package/app/core/skillpacks/market/character-bible.md +66 -66
  84. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  85. package/app/core/skillpacks/market/git-workflow.md +57 -57
  86. package/app/core/skillpacks/market/release-notes.md +72 -72
  87. package/app/core/skillpacks/market/weekly-report.md +71 -71
  88. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  89. package/app/core/skills.py +109 -2
  90. package/app/core/step_runner.py +132 -132
  91. package/app/core/store.py +278 -41
  92. package/app/core/task_compile.py +199 -199
  93. package/app/core/telemetry.py +292 -292
  94. package/app/core/tlsctx.py +64 -64
  95. package/app/core/token_meter.py +147 -147
  96. package/app/core/tracing.py +144 -0
  97. package/app/core/upstream.py +41 -41
  98. package/app/core/usage.py +862 -858
  99. package/app/core/volumes.py +389 -355
  100. package/app/core/wxdigest.py +858 -858
  101. package/app/core/zentao.py +2465 -2447
  102. package/app/main.py +2981 -2838
  103. package/app/pick_dialog.py +110 -110
  104. package/app/ui/app.js +14399 -13994
  105. package/app/ui/artdiff.js +74 -0
  106. package/app/ui/board.html +162 -162
  107. package/app/ui/board.js +305 -305
  108. package/app/ui/i18n.js +2770 -2716
  109. package/app/ui/icons/bee.svg +79 -79
  110. package/app/ui/index.html +6 -0
  111. package/app/ui/manifest.json +16 -16
  112. package/app/ui/qrcode.js +2297 -2297
  113. package/app/ui/style.css +96 -24
  114. package/bin/tutti.js +147 -147
  115. package/package.json +46 -46
  116. package/tools/wx_reader.py +261 -261
@@ -1,432 +1,440 @@
1
- # -*- coding: utf-8 -*-
2
- """数据驱动的发布流程解释器:按步骤表操作一个 CDP 页面。
3
-
4
- 平台流程(建书/发章)描述为步骤数组,与代码分离:
5
- - 内置默认表在各平台模块(fanqie/qimao),是「待校准」的推测选择器;
6
- - data/publish/flows-<platform>.json 存在时整体覆盖内置表——平台改版/
7
- 首次校准只改数据文件,不用动代码;
8
- - 每步失败先落一张 fail-*.png 再抛人话错误,发布中断可回看卡在哪一步。
9
-
10
- 步骤类型:
11
- navigate {url} 打开地址(支持 {home} 等占位符,来自平台配置)
12
- wait {sel, timeout} 等元素出现
13
- fill {sel, key} 把 values[key] 填进输入框(React 安全 + 富文本)
14
- click {sel} 点选择器命中的元素
15
- click_text {text, scope, contains} 按「可见文本」点按钮/标签(无稳定 id 的弹层项)
16
- shot {name} 截图存证
17
- probe {note} dump 表单元素清单(校准选择器用,结果进 log)
18
- submit {sel} 终步:auto_submit=false 时跳过(留给人工确认),
19
- 跳过时补一张 ready-*.png,用户在浏览器窗口里自查提交
20
- url_any {any: [..]} 断言当前 URL 含任一标记,不含则报错(登录跳转检测)
21
- """
22
- from __future__ import annotations
23
-
24
- import json
25
- import time
26
-
27
- from .browser import BrowserError
28
-
29
-
30
- class FlowError(Exception):
31
- """流程失败:message 面向用户(含步骤序号与截图路径线索)。"""
32
-
33
-
34
- def _click_match_js():
35
- # 点同时包含所有关键词的最小可见元素(长度最小者=最内层卡片)
36
- return ("(keys,maxLen)=>{"
37
- "const hit=[...document.querySelectorAll('div,li,section,label,span,a')].filter(e=>{"
38
- "const x=(e.innerText||'').trim();"
39
- "return x && x.length<=maxLen && keys.every(k=>x.includes(k));});"
40
- "if(!hit.length)return{ok:false,err:'找不到同时含 '+keys.join('+')+' 的元素'};"
41
- "hit.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
42
- "let el=hit[0];"
43
- "const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
44
- "act.scrollIntoView({block:'center'});act.click();"
45
- "return{ok:true,tag:act.tagName};}")
46
-
47
-
48
- def _fill_label_js():
49
- # Element UI 表单:在 .el-form-item(或 class 含 form-item 的容器)里按
50
- # label 文本定位控件,走与 browser.fill 相同的 native setter 管道
51
- return ("(labelText,text)=>{"
52
- "const items=[...document.querySelectorAll('.el-form-item,[class*=form-item]')];"
53
- "let target=null;"
54
- "for(const it of items){"
55
- "const lb=it.querySelector('[class*=label],[class*=label]');"
56
- "const ltxt=((lb&&lb.innerText)||'').trim();"
57
- "if(ltxt&&ltxt.includes(labelText)){target=it;break;}}"
58
- "if(!target)return{ok:false,err:'找不到字段「'+labelText+'」'};"
59
- "const el=target.querySelector('textarea,[contenteditable=true],input[type=text]')||"
60
- "target.querySelector('input,textarea');"
61
- "if(!el)return{ok:false,err:'字段「'+labelText+'」下没有输入控件'};"
62
- "el.scrollIntoView({block:'center'});el.focus();"
63
- "if(el.isContentEditable){"
64
- "const r=document.createRange();r.selectNodeContents(el);"
65
- "const g=getSelection();g.removeAllRanges();g.addRange(r);"
66
- "document.execCommand('insertText',false,text);"
67
- "return{ok:true};}"
68
- "const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;"
69
- "const d=Object.getOwnPropertyDescriptor(proto,'value');"
70
- "(d&&d.set?d.set:function(v){el.value=v}).call(el,text);"
71
- "el.dispatchEvent(new Event('input',{bubbles:true}));"
72
- "el.dispatchEvent(new Event('change',{bubbles:true}));"
73
- "return{ok:true};}")
74
-
75
-
76
- def _click_text_js():
77
- # contains 模式下外层容器(整页文本)也会 includes 命中——候选按文本长度
78
- # 升序取最短者=最内层最精确元素(「新建小说」按钮赢过包它的大 DIV)
79
- return ("(t,scope,contains)=>{"
80
- "const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li')];"
81
- "const cands=els.filter(e=>{const x=(e.innerText||'').trim();"
82
- "return x&&(contains?x.includes(t):x===t);});"
83
- "if(!cands.length)return{ok:false,err:'页面上找不到文本为「'+t+'」的可点元素'};"
84
- "cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
85
- "const hit=cands[0];"
86
- "hit.scrollIntoView({block:'center'});hit.click();return{ok:true};}")
87
-
88
-
89
- def _page_error_text_js():
90
- # 抓页面上可见的报错/提示(toast、表单校验消息):提交被平台静默拒绝
91
- # 时 url_any 只看得到「没跳转」,把页面自己的话带回来才不用瞎猜
92
- return ("()=>{const sels='.arco-message,.arco-notification,"
93
- "[class*=message],[class*=toast],[class*=error],[class*=alert]';"
94
- "const out=[],seen={};"
95
- "for(const e of document.querySelectorAll(sels)){"
96
- "const r=e.getBoundingClientRect();"
97
- "if(r.width<=0||r.height<=0)continue;"
98
- "const x=(e.innerText||'').trim();"
99
- "if(!x||x.length>120||seen[x])continue;seen[x]=1;out.push(x);}"
100
- "return out.slice(0,5).join(' | ');}")
101
-
102
-
103
- def run_flow(page, steps, values=None, config=None, auto_submit=False,
104
- shot=None, log=None):
105
- """跑一个流程。values:fill 取值字典;config:平台 URL 等占位符来源。
106
-
107
- shot(name) → 截图落盘函数(manager 注入,路径含任务/平台维度);
108
- log(line) → 步骤日志函数(进发布记录的 log 字段)。返回执行到的步数。"""
109
- values = values or {}
110
- config = config or {}
111
- log = log or (lambda s: None)
112
-
113
- def note(i, s):
114
- log("步骤%d %s" % (i, s))
115
-
116
- for i, st in enumerate(steps):
117
- act = (st.get("do") or "").strip()
118
- try:
119
- if act == "navigate":
120
- url = st["url"]
121
- for k, v in config.items():
122
- url = url.replace("{%s}" % k, str(v))
123
- for k, v in (values or {}).items(): # editor_url/draft_url 等任务级占位
124
- url = url.replace("{%s}" % k, str(v))
125
- note(i, "打开 %s" % url)
126
- # 重页(编辑器/管理列表)可按步放宽;缺省 45s(30s 对慢网偏紧)
127
- page.navigate(url, timeout=float(st.get("timeout") or 45))
128
- elif act == "wait":
129
- note(i, "等待 %s" % st["sel"])
130
- page.wait_for(st["sel"], timeout=float(st.get("timeout") or 12))
131
- elif act == "fill":
132
- key = st.get("key") or ""
133
- text = values.get(key, "")
134
- if text in ("", None):
135
- continue # 空值字段跳过(如无第二主角)
136
- note(i, "填入 %s(%d 字)" % (key, len(str(text))))
137
- page.wait_for(st["sel"], timeout=8)
138
- page.fill(st["sel"], str(text))
139
- elif act == "click":
140
- note(i, "点击 %s" % st["sel"])
141
- page.wait_for(st["sel"], timeout=8)
142
- page.click(st["sel"])
143
- elif act == "click_text":
144
- text = str(st.get("text") or "")
145
- for k, v in (values or {}).items(): # {signing_mode} 等动态值
146
- text = text.replace("{%s}" % k, str(v))
147
- if not text:
148
- continue
149
- note(i, "点击「%s」" % text)
150
- r = None
151
- for _try in range(3): # SPA 渲染慢:找不到先等再试
152
- r = page.call(_click_text_js(), text, st.get("scope") or "",
153
- bool(st.get("contains")))
154
- if (r or {}).get("ok"):
155
- break
156
- time.sleep(0.9)
157
- if not (r or {}).get("ok"):
158
- raise FlowError((r or {}).get("err") or text)
159
- elif act == "click_real":
160
- # 真实鼠标事件点击(CDP Input 派发):qm-btn 等自定义按钮
161
- # 只认真实事件序列,el.click() 无效。发布确认链全靠它。
162
- text = str(st.get("text") or "")
163
- for k, v in (values or {}).items(): # {target_reader} 等动态值
164
- text = text.replace("{%s}" % k, str(v))
165
- for k, v in (values or {}).items():
166
- text = text.replace("{%s}" % k, str(v))
167
- if not text:
168
- continue
169
- note(i, "真实点击「%s」" % text)
170
- r = None
171
- for _try in range(int(st.get("tries") or 25)):
172
- r = page.real_click_text(text, st.get("scope") or
173
- "a,button,[class*=btn]",
174
- y_min=st.get("y_min"),
175
- y_max=st.get("y_max"))
176
- if (r or {}).get("ok"):
177
- break
178
- time.sleep(0.4)
179
- if not (r or {}).get("ok"):
180
- if st.get("optional"):
181
- note(i, "「%s」未出现,跳过(optional)" % text)
182
- continue
183
- raise FlowError((r or {}).get("err") or text)
184
- elif act == "click_in":
185
- # 先按 scope_text 定位容器(如书卡),再在容器内点 text 按钮。
186
- # 解决「按钮与书名同卡片但不在彼此祖先链」的组合定位。
187
- scope_t = str(st.get("scope_text") or "")
188
- text = str(st.get("text") or "")
189
- for k, v in (values or {}).items():
190
- scope_t = scope_t.replace("{%s}" % k, str(v))
191
- text = text.replace("{%s}" % k, str(v))
192
- note(i, "在含「%s」的卡片内点「%s」" % (scope_t, text))
193
- r = None
194
- for _try in range(10): # 表格异步渲染:重试窗口加长
195
- r = page.call(
196
- "(sc,t)=>{"
197
- "const cards=[...document.querySelectorAll('div,li,section,tr')].filter(e=>{"
198
- "const x=(e.innerText||'').trim();"
199
- "return x.includes(sc)&&x.length<600&&e.getBoundingClientRect().width>0;});"
200
- "if(!cards.length)return{ok:false,err:'找不到含'+sc+'的卡片'};"
201
- "cards.sort((a,b)=>(b.innerText||'').length-(a.innerText||'').length);"
202
- "const card=cards[0];"
203
- "const btns=[...card.querySelectorAll('a,button,[role=button],[class*=btn],span')].filter(e=>{"
204
- "const x=(e.innerText||'').trim();return x===t||x.includes(t)&&x.length<=t.length+6;});"
205
- "if(!btns.length)return{ok:false,err:'卡片内没有'+t};"
206
- "btns.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
207
- "let el=btns[0];"
208
- "const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
209
- "act.scrollIntoView({block:'center'});act.click();"
210
- "return{ok:true};}", scope_t, text)
211
- if (r or {}).get("ok"):
212
- break
213
- time.sleep(0.9)
214
- if not (r or {}).get("ok"):
215
- raise FlowError((r or {}).get("err") or "click_in 失败")
216
- elif act == "click_arrow":
217
- # 展开下拉按钮组(番茄「下一步▾」):点目标按钮组右缘 10px
218
- note(i, "展开下拉箭头")
219
- r = page.call(
220
- "(t)=>{"
221
- "const vis=e=>e.getBoundingClientRect().width>0;"
222
- "const btn=[...document.querySelectorAll('button')].find(e=>"
223
- "vis(e)&&(e.innerText||'').trim().includes(t));"
224
- "if(!btn)return{ok:false,err:'按钮未找到'};"
225
- "const host=btn.closest('[class*=group],[class*=dropdown]')||btn.parentElement;"
226
- "const r=host.getBoundingClientRect();"
227
- "return{ok:true,x:Math.round(r.x+r.width-10),y:Math.round(r.y+r.height/2)};}", str(st.get("target") or "下一步"))
228
- if not (r or {}).get("ok"):
229
- raise FlowError((r or {}).get("err") or "箭头定位失败")
230
- page.send("Input.dispatchMouseEvent",
231
- {"type": "mousePressed", "x": r["x"], "y": r["y"],
232
- "button": "left", "clickCount": 1}, timeout=8.0)
233
- page.send("Input.dispatchMouseEvent",
234
- {"type": "mouseReleased", "x": r["x"], "y": r["y"],
235
- "button": "left", "clickCount": 1}, timeout=8.0)
236
- time.sleep(float(st.get("settle") or 1.2))
237
- elif act == "click_match":
238
- # 关键词选块(站点卡片等):点同时包含所有关键词的最小元素
239
- keys = [str(x) for x in (st.get("any") or []) if str(x).strip()]
240
- for k2, v2 in (values or {}).items(): # {book_name} 等动态值
241
- keys = [x.replace("{%s}" % k2, str(v2)) for x in keys]
242
- note(i, "点击含 %s 的卡片" % keys)
243
- r = None
244
- for _try in range(4): # 弹层渲染慢:找不到先等再试
245
- r = page.call(_click_match_js(), keys,
246
- int(st.get("max_len") or 400))
247
- if (r or {}).get("ok"):
248
- break
249
- time.sleep(1.0)
250
- if not (r or {}).get("ok"):
251
- raise FlowError((r or {}).get("err") or "卡片未找到")
252
- elif act == "fill_label":
253
- # 按字段标签填(Element UI form-item:label 与控件同容器)
254
- key = st.get("key") or ""
255
- label = str(st.get("label") or "")
256
- text = str(values.get(key, "") or "")
257
- if not text:
258
- continue
259
- note(i, "填字段「%s」(%d 字)" % (label, len(text)))
260
- page.wait_for("[class*=form]", timeout=8)
261
- r = None
262
- for _try in range(3):
263
- r = page.call(_fill_label_js(), label, text)
264
- if (r or {}).get("ok"):
265
- break
266
- time.sleep(0.9)
267
- if not (r or {}).get("ok"):
268
- raise FlowError((r or {}).get("err") or "填字段失败")
269
- elif act == "radio":
270
- # 单选组:values[map[key]] 映射到 radio value 后点它。
271
- # 隐藏 input(Element UI)点不到——点它的最近可见 label 祖先。
272
- key = st.get("key") or ""
273
- group = st.get("map") or {}
274
- want = str(values.get(key, "")).strip()
275
- val = group.get(want)
276
- if val is None:
277
- if st.get("optional"):
278
- note(i, "跳过单选 %s(无值)" % key)
279
- continue
280
- raise FlowError("单选 %s:值「%s」不在映射 %s 里" % (key, want, list(group)))
281
- note(i, "单选 %s=%s(value=%s)" % (key, want, val))
282
- r = page.call(
283
- "(v)=>{const r=[...document.querySelectorAll('input[type=radio]')]"
284
- ".find(x=>x.value===v);if(!r)return{ok:false,err:'radio v='+v};"
285
- "const lab=r.closest('label')||(r.closest('.el-radio')||{}).firstElementChild||r;"
286
- "const t=lab.matches('label,.el-radio')?lab:(r.parentElement||r);"
287
- "t.scrollIntoView({block:'center'});t.click();return{ok:true};}", str(val))
288
- if not (r or {}).get("ok"):
289
- raise FlowError((r or {}).get("err") or "单选点击失败")
290
- elif act == "tags":
291
- # 标签弹层逐个点选:manager 把标签清单放 values["_tags"]。
292
- # 项为 [组名, 标签] 时先点左侧组名切换(组标签懒渲染)再点标签;
293
- # 纯字符串直接点。弹层打开由前置 click_text「添加标签」负责。
294
- tags = values.get("_tags") or []
295
- if not tags:
296
- note(i, "无标签可点,跳过")
297
- continue
298
- scope = st.get("scope") or "span,li,label,[class*=dialog] *,[class*=popper] *"
299
- note(i, "点选标签 %d 项" % len(tags))
300
- for item in tags:
301
- grp, tg = ("", str(item)) if isinstance(item, (str, int)) \
302
- else (str(item[0] or ""), str(item[1] or ""))
303
- if not tg:
304
- continue
305
- if grp: # 切到目标组
306
- # 前置 click_text「添加标签」可能被分类下拉的关闭
307
- # 动画/遮罩吞掉(el.click() 打在遮罩上也返回 ok),
308
- # 组名找不到=弹层还没开:多等几轮让它开出来
309
- r0 = None
310
- for _try in range(4):
311
- r0 = page.call(_click_text_js(), grp, scope, True)
312
- if (r0 or {}).get("ok"):
313
- break
314
- time.sleep(1.0)
315
- if not (r0 or {}).get("ok"):
316
- raise FlowError("标签组「%s」切换失败(标签弹层未打开)" % grp)
317
- time.sleep(0.3)
318
- r = None
319
- for _try in range(3):
320
- r = page.call(_click_text_js(), tg, scope, True)
321
- if (r or {}).get("ok"):
322
- break
323
- time.sleep(0.7)
324
- if not (r or {}).get("ok"):
325
- raise FlowError("标签「%s」点选失败:%s" % (tg, (r or {}).get("err")))
326
- elif act == "shot":
327
- note(i, "截图 %s" % st.get("name"))
328
- if shot:
329
- shot(st.get("name") or "step")
330
- elif act == "probe":
331
- info = page.probe()
332
- note(i, "表单探测 %s:%d 个可交互元素" % (st.get("note") or "", len(info)))
333
- if log:
334
- log("PROBE " + json.dumps(info, ensure_ascii=False)[:4000])
335
- elif act == "submit":
336
- if not auto_submit:
337
- note(i, "已填好未提交——请人工检查后提交(auto_submit=false)")
338
- if shot:
339
- shot("ready-manual-submit")
340
- return i + 1
341
- if st.get("text"): # 按按钮文本提交
342
- text = str(st["text"])
343
- note(i, "提交「%s」(真实鼠标事件)" % text)
344
- r = None
345
- for _try in range(int(st.get("tries") or 3)):
346
- r = page.real_click_text(
347
- text, st.get("scope") or "button,a,[class*=btn]",
348
- contains=True)
349
- if (r or {}).get("ok"):
350
- break
351
- time.sleep(0.9)
352
- if not (r or {}).get("ok"):
353
- raise FlowError((r or {}).get("err") or "提交按钮未找到")
354
- else:
355
- note(i, "提交 %s" % st.get("sel") or "")
356
- page.wait_for(st["sel"], timeout=8)
357
- page.click(st["sel"])
358
- elif act == "sleep":
359
- # 固定等待(SPA 水合/动画缓冲):比 wait 更钝但最可靠
360
- time.sleep(float(st.get("s") or 1.0))
361
- note(i, "等待 %.1fs" % float(st.get("s") or 1.0))
362
- elif act == "wait_gone":
363
- # 等元素消失(弹层关闭动画对齐):连发点击被关闭动画的
364
- # mask 吞掉是多层弹窗流程的经典竞态
365
- sel = st["sel"]
366
- note(i, "等待 %s 消失" % sel)
367
- deadline = time.time() + float(st.get("timeout") or 10)
368
- while time.time() < deadline:
369
- try:
370
- if not page.exists(sel, timeout=1.0):
371
- break
372
- except BrowserError:
373
- break
374
- time.sleep(0.4)
375
- else:
376
- raise FlowError("等待 %s 消失超时" % sel)
377
- note(i, "已消失")
378
- elif act == "verify":
379
- # 上线验证:导航到验证页断言文本在场——防「流程完成但平台
380
- # 静默未发布」的假成功(0 字草稿案)。url/any 支持 values 占位。
381
- url = str(st.get("url") or "")
382
- for k, v in (values or {}).items():
383
- url = url.replace("{%s}" % k, str(v))
384
- note(i, "验证 %s" % url[:60])
385
- page.navigate(url, timeout=30)
386
- time.sleep(float(st.get("settle") or 4))
387
- marks = [str(m) for m in (st.get("any") or [])]
388
- body_txt = str(page.call(
389
- "()=>(document.body.innerText||'')", timeout=10) or "")
390
- for m in marks:
391
- mk = m
392
- for k, v in (values or {}).items():
393
- mk = mk.replace("{%s}" % k, str(v))
394
- if mk not in body_txt:
395
- raise FlowError("上线验证失败:%s 页面上没有「%s」(章节未真正发布)"
396
- % (url[:60], mk[:40]))
397
- note(i, "验证通过")
398
- elif act == "url_any":
399
- u = str(page.url() or "")
400
- marks = st.get("any") or []
401
- if not any(m in u for m in marks):
402
- hint = ""
403
- try:
404
- hint = str(page.call(_page_error_text_js(), timeout=5) or "")
405
- except Exception:
406
- pass
407
- msg = "当前页面 %s 不含预期标记 %s(可能未登录或改版)" % (u[:90], marks)
408
- if hint:
409
- msg += ";页面提示:%s" % hint[:150]
410
- else:
411
- msg += "(常见原因:表单校验未过,如简介字数不足)"
412
- raise FlowError(msg)
413
- note(i, "页面标记校验通过")
414
- else:
415
- raise FlowError("未知步骤类型:%s" % act)
416
- except FlowError:
417
- _fail_shot(shot, "step%d-%s" % (i, act))
418
- raise
419
- except BrowserError as e:
420
- _fail_shot(shot, "step%d-%s" % (i, act))
421
- raise FlowError("步骤%d(%s)失败:%s" % (i, act, e))
422
- except KeyError as e:
423
- raise FlowError("步骤%d 缺少字段 %s" % (i, e))
424
- return len(steps)
425
-
426
-
427
- def _fail_shot(shot, name):
428
- try:
429
- if shot:
430
- shot(name)
431
- except Exception:
432
- pass
1
+ # -*- coding: utf-8 -*-
2
+ """数据驱动的发布流程解释器:按步骤表操作一个 CDP 页面。
3
+
4
+ 平台流程(建书/发章)描述为步骤数组,与代码分离:
5
+ - 内置默认表在各平台模块(fanqie/qimao),是「待校准」的推测选择器;
6
+ - data/publish/flows-<platform>.json 存在时整体覆盖内置表——平台改版/
7
+ 首次校准只改数据文件,不用动代码;
8
+ - 每步失败先落一张 fail-*.png 再抛人话错误,发布中断可回看卡在哪一步。
9
+
10
+ 步骤类型:
11
+ navigate {url} 打开地址(支持 {home} 等占位符,来自平台配置)
12
+ wait {sel, timeout} 等元素出现
13
+ fill {sel, key} 把 values[key] 填进输入框(React 安全 + 富文本)
14
+ click {sel} 点选择器命中的元素
15
+ click_text {text, scope, contains} 按「可见文本」点按钮/标签(无稳定 id 的弹层项)
16
+ shot {name} 截图存证
17
+ probe {note} dump 表单元素清单(校准选择器用,结果进 log)
18
+ submit {sel} 终步:auto_submit=false 时跳过(留给人工确认),
19
+ 跳过时补一张 ready-*.png,用户在浏览器窗口里自查提交
20
+ url_any {any: [..]} 断言当前 URL 含任一标记,不含则报错(登录跳转检测)
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ import time
27
+
28
+ from .browser import BrowserError
29
+
30
+
31
+ class FlowError(Exception):
32
+ """流程失败:message 面向用户(含步骤序号与截图路径线索)。"""
33
+
34
+
35
+ def _click_match_js():
36
+ # 点同时包含所有关键词的最小可见元素(长度最小者=最内层卡片)
37
+ return ("(keys,maxLen)=>{"
38
+ "const hit=[...document.querySelectorAll('div,li,section,label,span,a')].filter(e=>{"
39
+ "const x=(e.innerText||'').trim();"
40
+ "return x && x.length<=maxLen && keys.every(k=>x.includes(k));});"
41
+ "if(!hit.length)return{ok:false,err:'找不到同时含 '+keys.join('+')+' 的元素'};"
42
+ "hit.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
43
+ "let el=hit[0];"
44
+ "const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
45
+ "act.scrollIntoView({block:'center'});act.click();"
46
+ "return{ok:true,tag:act.tagName};}")
47
+
48
+
49
+ def _fill_label_js():
50
+ # Element UI 表单:在 .el-form-item(或 class 含 form-item 的容器)里按
51
+ # label 文本定位控件,走与 browser.fill 相同的 native setter 管道
52
+ return ("(labelText,text)=>{"
53
+ "const items=[...document.querySelectorAll('.el-form-item,[class*=form-item]')];"
54
+ "let target=null;"
55
+ "for(const it of items){"
56
+ "const lb=it.querySelector('[class*=label],[class*=label]');"
57
+ "const ltxt=((lb&&lb.innerText)||'').trim();"
58
+ "if(ltxt&&ltxt.includes(labelText)){target=it;break;}}"
59
+ "if(!target)return{ok:false,err:'找不到字段「'+labelText+'」'};"
60
+ "const el=target.querySelector('textarea,[contenteditable=true],input[type=text]')||"
61
+ "target.querySelector('input,textarea');"
62
+ "if(!el)return{ok:false,err:'字段「'+labelText+'」下没有输入控件'};"
63
+ "el.scrollIntoView({block:'center'});el.focus();"
64
+ "if(el.isContentEditable){"
65
+ "const r=document.createRange();r.selectNodeContents(el);"
66
+ "const g=getSelection();g.removeAllRanges();g.addRange(r);"
67
+ "document.execCommand('insertText',false,text);"
68
+ "return{ok:true};}"
69
+ "const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;"
70
+ "const d=Object.getOwnPropertyDescriptor(proto,'value');"
71
+ "(d&&d.set?d.set:function(v){el.value=v}).call(el,text);"
72
+ "el.dispatchEvent(new Event('input',{bubbles:true}));"
73
+ "el.dispatchEvent(new Event('change',{bubbles:true}));"
74
+ "return{ok:true};}")
75
+
76
+
77
+ def _click_text_js():
78
+ # contains 模式下外层容器(整页文本)也会 includes 命中——候选按文本长度
79
+ # 升序取最短者=最内层最精确元素(「新建小说」按钮赢过包它的大 DIV)
80
+ return ("(t,scope,contains)=>{"
81
+ "const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li')];"
82
+ "const cands=els.filter(e=>{const x=(e.innerText||'').trim();"
83
+ "return x&&(contains?x.includes(t):x===t);});"
84
+ "if(!cands.length)return{ok:false,err:'页面上找不到文本为「'+t+'」的可点元素'};"
85
+ "cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
86
+ "const hit=cands[0];"
87
+ "hit.scrollIntoView({block:'center'});hit.click();return{ok:true};}")
88
+
89
+
90
+ def _page_error_text_js():
91
+ # 抓页面上可见的报错/提示(toast、表单校验消息):提交被平台静默拒绝
92
+ # 时 url_any 只看得到「没跳转」,把页面自己的话带回来才不用瞎猜
93
+ return ("()=>{const sels='.arco-message,.arco-notification,"
94
+ "[class*=message],[class*=toast],[class*=error],[class*=alert]';"
95
+ "const out=[],seen={};"
96
+ "for(const e of document.querySelectorAll(sels)){"
97
+ "const r=e.getBoundingClientRect();"
98
+ "if(r.width<=0||r.height<=0)continue;"
99
+ "const x=(e.innerText||'').trim();"
100
+ "if(!x||x.length>120||seen[x])continue;seen[x]=1;out.push(x);}"
101
+ "return out.slice(0,5).join(' | ');}")
102
+
103
+
104
+ def run_flow(page, steps, values=None, config=None, auto_submit=False,
105
+ shot=None, log=None):
106
+ """跑一个流程。values:fill 取值字典;config:平台 URL 等占位符来源。
107
+
108
+ shot(name) → 截图落盘函数(manager 注入,路径含任务/平台维度);
109
+ log(line) → 步骤日志函数(进发布记录的 log 字段)。返回执行到的步数。"""
110
+ values = values or {}
111
+ config = config or {}
112
+ log = log or (lambda s: None)
113
+
114
+ def note(i, s):
115
+ log("步骤%d %s" % (i, s))
116
+
117
+ for i, st in enumerate(steps):
118
+ act = (st.get("do") or "").strip()
119
+ try:
120
+ if act == "navigate":
121
+ url = st["url"]
122
+ for k, v in config.items():
123
+ url = url.replace("{%s}" % k, str(v))
124
+ for k, v in (values or {}).items(): # editor_url/draft_url 等任务级占位
125
+ url = url.replace("{%s}" % k, str(v))
126
+ # 占位符没被吃掉=values 缺键(平台模块缺 URL 生成器/登记不全)。
127
+ # 直接放行会让浏览器停在 about:blank,到 url_any 才误报
128
+ # 「未登录或改版」——在这里点名缺哪个键(0924 番茄三连败)。
129
+ left = re.findall(r"\{[A-Za-z_][A-Za-z0-9_]*\}", url)
130
+ if left:
131
+ raise FlowError("URL 占位符 %s 没有对应值:%s"
132
+ % (",".join(left), url[:90]))
133
+ note(i, "打开 %s" % url)
134
+ # 重页(编辑器/管理列表)可按步放宽;缺省 45s(30s 对慢网偏紧)
135
+ page.navigate(url, timeout=float(st.get("timeout") or 45))
136
+ elif act == "wait":
137
+ note(i, "等待 %s" % st["sel"])
138
+ page.wait_for(st["sel"], timeout=float(st.get("timeout") or 12))
139
+ elif act == "fill":
140
+ key = st.get("key") or ""
141
+ text = values.get(key, "")
142
+ if text in ("", None):
143
+ continue # 空值字段跳过(如无第二主角)
144
+ note(i, "填入 %s(%d 字)" % (key, len(str(text))))
145
+ page.wait_for(st["sel"], timeout=8)
146
+ page.fill(st["sel"], str(text))
147
+ elif act == "click":
148
+ note(i, "点击 %s" % st["sel"])
149
+ page.wait_for(st["sel"], timeout=8)
150
+ page.click(st["sel"])
151
+ elif act == "click_text":
152
+ text = str(st.get("text") or "")
153
+ for k, v in (values or {}).items(): # {signing_mode} 等动态值
154
+ text = text.replace("{%s}" % k, str(v))
155
+ if not text:
156
+ continue
157
+ note(i, "点击「%s」" % text)
158
+ r = None
159
+ for _try in range(3): # SPA 渲染慢:找不到先等再试
160
+ r = page.call(_click_text_js(), text, st.get("scope") or "",
161
+ bool(st.get("contains")))
162
+ if (r or {}).get("ok"):
163
+ break
164
+ time.sleep(0.9)
165
+ if not (r or {}).get("ok"):
166
+ raise FlowError((r or {}).get("err") or text)
167
+ elif act == "click_real":
168
+ # 真实鼠标事件点击(CDP Input 派发):qm-btn 等自定义按钮
169
+ # 只认真实事件序列,el.click() 无效。发布确认链全靠它。
170
+ text = str(st.get("text") or "")
171
+ for k, v in (values or {}).items(): # {target_reader} 等动态值
172
+ text = text.replace("{%s}" % k, str(v))
173
+ for k, v in (values or {}).items():
174
+ text = text.replace("{%s}" % k, str(v))
175
+ if not text:
176
+ continue
177
+ note(i, "真实点击「%s」" % text)
178
+ r = None
179
+ for _try in range(int(st.get("tries") or 25)):
180
+ r = page.real_click_text(text, st.get("scope") or
181
+ "a,button,[class*=btn]",
182
+ y_min=st.get("y_min"),
183
+ y_max=st.get("y_max"))
184
+ if (r or {}).get("ok"):
185
+ break
186
+ time.sleep(0.4)
187
+ if not (r or {}).get("ok"):
188
+ if st.get("optional"):
189
+ note(i, "「%s」未出现,跳过(optional)" % text)
190
+ continue
191
+ raise FlowError((r or {}).get("err") or text)
192
+ elif act == "click_in":
193
+ # 先按 scope_text 定位容器(如书卡),再在容器内点 text 按钮。
194
+ # 解决「按钮与书名同卡片但不在彼此祖先链」的组合定位。
195
+ scope_t = str(st.get("scope_text") or "")
196
+ text = str(st.get("text") or "")
197
+ for k, v in (values or {}).items():
198
+ scope_t = scope_t.replace("{%s}" % k, str(v))
199
+ text = text.replace("{%s}" % k, str(v))
200
+ note(i, "在含「%s」的卡片内点「%s」" % (scope_t, text))
201
+ r = None
202
+ for _try in range(10): # 表格异步渲染:重试窗口加长
203
+ r = page.call(
204
+ "(sc,t)=>{"
205
+ "const cards=[...document.querySelectorAll('div,li,section,tr')].filter(e=>{"
206
+ "const x=(e.innerText||'').trim();"
207
+ "return x.includes(sc)&&x.length<600&&e.getBoundingClientRect().width>0;});"
208
+ "if(!cards.length)return{ok:false,err:'找不到含'+sc+'的卡片'};"
209
+ "cards.sort((a,b)=>(b.innerText||'').length-(a.innerText||'').length);"
210
+ "const card=cards[0];"
211
+ "const btns=[...card.querySelectorAll('a,button,[role=button],[class*=btn],span')].filter(e=>{"
212
+ "const x=(e.innerText||'').trim();return x===t||x.includes(t)&&x.length<=t.length+6;});"
213
+ "if(!btns.length)return{ok:false,err:'卡片内没有'+t};"
214
+ "btns.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
215
+ "let el=btns[0];"
216
+ "const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
217
+ "act.scrollIntoView({block:'center'});act.click();"
218
+ "return{ok:true};}", scope_t, text)
219
+ if (r or {}).get("ok"):
220
+ break
221
+ time.sleep(0.9)
222
+ if not (r or {}).get("ok"):
223
+ raise FlowError((r or {}).get("err") or "click_in 失败")
224
+ elif act == "click_arrow":
225
+ # 展开下拉按钮组(番茄「下一步▾」):点目标按钮组右缘 10px
226
+ note(i, "展开下拉箭头")
227
+ r = page.call(
228
+ "(t)=>{"
229
+ "const vis=e=>e.getBoundingClientRect().width>0;"
230
+ "const btn=[...document.querySelectorAll('button')].find(e=>"
231
+ "vis(e)&&(e.innerText||'').trim().includes(t));"
232
+ "if(!btn)return{ok:false,err:'按钮未找到'};"
233
+ "const host=btn.closest('[class*=group],[class*=dropdown]')||btn.parentElement;"
234
+ "const r=host.getBoundingClientRect();"
235
+ "return{ok:true,x:Math.round(r.x+r.width-10),y:Math.round(r.y+r.height/2)};}", str(st.get("target") or "下一步"))
236
+ if not (r or {}).get("ok"):
237
+ raise FlowError((r or {}).get("err") or "箭头定位失败")
238
+ page.send("Input.dispatchMouseEvent",
239
+ {"type": "mousePressed", "x": r["x"], "y": r["y"],
240
+ "button": "left", "clickCount": 1}, timeout=8.0)
241
+ page.send("Input.dispatchMouseEvent",
242
+ {"type": "mouseReleased", "x": r["x"], "y": r["y"],
243
+ "button": "left", "clickCount": 1}, timeout=8.0)
244
+ time.sleep(float(st.get("settle") or 1.2))
245
+ elif act == "click_match":
246
+ # 关键词选块(站点卡片等):点同时包含所有关键词的最小元素
247
+ keys = [str(x) for x in (st.get("any") or []) if str(x).strip()]
248
+ for k2, v2 in (values or {}).items(): # {book_name} 等动态值
249
+ keys = [x.replace("{%s}" % k2, str(v2)) for x in keys]
250
+ note(i, "点击含 %s 的卡片" % keys)
251
+ r = None
252
+ for _try in range(4): # 弹层渲染慢:找不到先等再试
253
+ r = page.call(_click_match_js(), keys,
254
+ int(st.get("max_len") or 400))
255
+ if (r or {}).get("ok"):
256
+ break
257
+ time.sleep(1.0)
258
+ if not (r or {}).get("ok"):
259
+ raise FlowError((r or {}).get("err") or "卡片未找到")
260
+ elif act == "fill_label":
261
+ # 按字段标签填(Element UI form-item:label 与控件同容器)
262
+ key = st.get("key") or ""
263
+ label = str(st.get("label") or "")
264
+ text = str(values.get(key, "") or "")
265
+ if not text:
266
+ continue
267
+ note(i, "填字段「%s」(%d 字)" % (label, len(text)))
268
+ page.wait_for("[class*=form]", timeout=8)
269
+ r = None
270
+ for _try in range(3):
271
+ r = page.call(_fill_label_js(), label, text)
272
+ if (r or {}).get("ok"):
273
+ break
274
+ time.sleep(0.9)
275
+ if not (r or {}).get("ok"):
276
+ raise FlowError((r or {}).get("err") or "填字段失败")
277
+ elif act == "radio":
278
+ # 单选组:values[map[key]] 映射到 radio value 后点它。
279
+ # 隐藏 input(Element UI)点不到——点它的最近可见 label 祖先。
280
+ key = st.get("key") or ""
281
+ group = st.get("map") or {}
282
+ want = str(values.get(key, "")).strip()
283
+ val = group.get(want)
284
+ if val is None:
285
+ if st.get("optional"):
286
+ note(i, "跳过单选 %s(无值)" % key)
287
+ continue
288
+ raise FlowError("单选 %s:值「%s」不在映射 %s 里" % (key, want, list(group)))
289
+ note(i, "单选 %s=%s(value=%s)" % (key, want, val))
290
+ r = page.call(
291
+ "(v)=>{const r=[...document.querySelectorAll('input[type=radio]')]"
292
+ ".find(x=>x.value===v);if(!r)return{ok:false,err:'radio v='+v};"
293
+ "const lab=r.closest('label')||(r.closest('.el-radio')||{}).firstElementChild||r;"
294
+ "const t=lab.matches('label,.el-radio')?lab:(r.parentElement||r);"
295
+ "t.scrollIntoView({block:'center'});t.click();return{ok:true};}", str(val))
296
+ if not (r or {}).get("ok"):
297
+ raise FlowError((r or {}).get("err") or "单选点击失败")
298
+ elif act == "tags":
299
+ # 标签弹层逐个点选:manager 把标签清单放 values["_tags"]。
300
+ # 项为 [组名, 标签] 时先点左侧组名切换(组标签懒渲染)再点标签;
301
+ # 纯字符串直接点。弹层打开由前置 click_text「添加标签」负责。
302
+ tags = values.get("_tags") or []
303
+ if not tags:
304
+ note(i, "无标签可点,跳过")
305
+ continue
306
+ scope = st.get("scope") or "span,li,label,[class*=dialog] *,[class*=popper] *"
307
+ note(i, "点选标签 %d 项" % len(tags))
308
+ for item in tags:
309
+ grp, tg = ("", str(item)) if isinstance(item, (str, int)) \
310
+ else (str(item[0] or ""), str(item[1] or ""))
311
+ if not tg:
312
+ continue
313
+ if grp: # 切到目标组
314
+ # 前置 click_text「添加标签」可能被分类下拉的关闭
315
+ # 动画/遮罩吞掉(el.click() 打在遮罩上也返回 ok),
316
+ # 组名找不到=弹层还没开:多等几轮让它开出来
317
+ r0 = None
318
+ for _try in range(4):
319
+ r0 = page.call(_click_text_js(), grp, scope, True)
320
+ if (r0 or {}).get("ok"):
321
+ break
322
+ time.sleep(1.0)
323
+ if not (r0 or {}).get("ok"):
324
+ raise FlowError("标签组「%s」切换失败(标签弹层未打开)" % grp)
325
+ time.sleep(0.3)
326
+ r = None
327
+ for _try in range(3):
328
+ r = page.call(_click_text_js(), tg, scope, True)
329
+ if (r or {}).get("ok"):
330
+ break
331
+ time.sleep(0.7)
332
+ if not (r or {}).get("ok"):
333
+ raise FlowError("标签「%s」点选失败:%s" % (tg, (r or {}).get("err")))
334
+ elif act == "shot":
335
+ note(i, "截图 %s" % st.get("name"))
336
+ if shot:
337
+ shot(st.get("name") or "step")
338
+ elif act == "probe":
339
+ info = page.probe()
340
+ note(i, "表单探测 %s:%d 个可交互元素" % (st.get("note") or "", len(info)))
341
+ if log:
342
+ log("PROBE " + json.dumps(info, ensure_ascii=False)[:4000])
343
+ elif act == "submit":
344
+ if not auto_submit:
345
+ note(i, "已填好未提交——请人工检查后提交(auto_submit=false)")
346
+ if shot:
347
+ shot("ready-manual-submit")
348
+ return i + 1
349
+ if st.get("text"): # 按按钮文本提交
350
+ text = str(st["text"])
351
+ note(i, "提交「%s」(真实鼠标事件)" % text)
352
+ r = None
353
+ for _try in range(int(st.get("tries") or 3)):
354
+ r = page.real_click_text(
355
+ text, st.get("scope") or "button,a,[class*=btn]",
356
+ contains=True)
357
+ if (r or {}).get("ok"):
358
+ break
359
+ time.sleep(0.9)
360
+ if not (r or {}).get("ok"):
361
+ raise FlowError((r or {}).get("err") or "提交按钮未找到")
362
+ else:
363
+ note(i, "提交 %s" % st.get("sel") or "")
364
+ page.wait_for(st["sel"], timeout=8)
365
+ page.click(st["sel"])
366
+ elif act == "sleep":
367
+ # 固定等待(SPA 水合/动画缓冲):比 wait 更钝但最可靠
368
+ time.sleep(float(st.get("s") or 1.0))
369
+ note(i, "等待 %.1fs" % float(st.get("s") or 1.0))
370
+ elif act == "wait_gone":
371
+ # 等元素消失(弹层关闭动画对齐):连发点击被关闭动画的
372
+ # mask 吞掉是多层弹窗流程的经典竞态
373
+ sel = st["sel"]
374
+ note(i, "等待 %s 消失" % sel)
375
+ deadline = time.time() + float(st.get("timeout") or 10)
376
+ while time.time() < deadline:
377
+ try:
378
+ if not page.exists(sel, timeout=1.0):
379
+ break
380
+ except BrowserError:
381
+ break
382
+ time.sleep(0.4)
383
+ else:
384
+ raise FlowError("等待 %s 消失超时" % sel)
385
+ note(i, "已消失")
386
+ elif act == "verify":
387
+ # 上线验证:导航到验证页断言文本在场——防「流程完成但平台
388
+ # 静默未发布」的假成功(0 字草稿案)。url/any 支持 values 占位。
389
+ url = str(st.get("url") or "")
390
+ for k, v in (values or {}).items():
391
+ url = url.replace("{%s}" % k, str(v))
392
+ note(i, "验证 %s" % url[:60])
393
+ page.navigate(url, timeout=30)
394
+ time.sleep(float(st.get("settle") or 4))
395
+ marks = [str(m) for m in (st.get("any") or [])]
396
+ body_txt = str(page.call(
397
+ "()=>(document.body.innerText||'')", timeout=10) or "")
398
+ for m in marks:
399
+ mk = m
400
+ for k, v in (values or {}).items():
401
+ mk = mk.replace("{%s}" % k, str(v))
402
+ if mk not in body_txt:
403
+ raise FlowError("上线验证失败:%s 页面上没有「%s」(章节未真正发布)"
404
+ % (url[:60], mk[:40]))
405
+ note(i, "验证通过")
406
+ elif act == "url_any":
407
+ u = str(page.url() or "")
408
+ marks = st.get("any") or []
409
+ if not any(m in u for m in marks):
410
+ hint = ""
411
+ try:
412
+ hint = str(page.call(_page_error_text_js(), timeout=5) or "")
413
+ except Exception:
414
+ pass
415
+ msg = "当前页面 %s 不含预期标记 %s(可能未登录或改版)" % (u[:90], marks)
416
+ if hint:
417
+ msg += ";页面提示:%s" % hint[:150]
418
+ else:
419
+ msg += "(常见原因:表单校验未过,如简介字数不足)"
420
+ raise FlowError(msg)
421
+ note(i, "页面标记校验通过(%s)" % u[:80])
422
+ else:
423
+ raise FlowError("未知步骤类型:%s" % act)
424
+ except FlowError:
425
+ _fail_shot(shot, "step%d-%s" % (i, act))
426
+ raise
427
+ except BrowserError as e:
428
+ _fail_shot(shot, "step%d-%s" % (i, act))
429
+ raise FlowError("步骤%d(%s)失败:%s" % (i, act, e))
430
+ except KeyError as e:
431
+ raise FlowError("步骤%d 缺少字段 %s" % (i, e))
432
+ return len(steps)
433
+
434
+
435
+ def _fail_shot(shot, name):
436
+ try:
437
+ if shot:
438
+ shot(name)
439
+ except Exception:
440
+ pass