codebee 0.1.95 → 0.1.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +725 -720
- package/LICENSE +21 -21
- package/README.md +515 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/agent_context.py +100 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -95
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1680 -1680
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +143 -0
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +198 -191
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +82 -0
- package/app/core/evalbench.py +184 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -0
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +25 -4
- package/app/core/knowledge_pipeline.py +86 -0
- package/app/core/manager.py +2232 -2232
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -189
- package/app/core/openapi.py +177 -154
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +43 -19
- package/app/core/planner.py +17 -11
- package/app/core/policy.py +52 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +26 -3
- package/app/core/publish/browser.py +655 -655
- package/app/core/publish/chaprows.py +138 -0
- package/app/core/publish/fanqie.py +210 -165
- package/app/core/publish/flow.py +753 -640
- package/app/core/publish/flows-fanqie-calibrated.json +349 -336
- package/app/core/publish/ledger.py +13 -1
- package/app/core/publish/manager.py +1150 -1118
- package/app/core/publish/qimao.py +176 -176
- package/app/core/publish/volumes.py +108 -0
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +194 -194
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2238 -2238
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +20 -0
- package/app/core/settings_schema.py +270 -270
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/fanqie-novel.md +8 -6
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/skillpacks/qimao-signing.md +14 -11
- package/app/core/skills.py +32 -21
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +9 -0
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tracing.py +207 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -0
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +104 -9
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +286 -18
- package/app/ui/artdiff.js +74 -74
- package/app/ui/assets/community-qr.png +0 -0
- package/app/ui/assets/donate-alipay.jpg +0 -0
- package/app/ui/assets/donate-qr.jpg +0 -0
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +33 -0
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +32 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +196 -2
- package/bin/tutti.js +147 -147
- package/package.json +48 -48
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/output/_createbook_body.json +0 -1
- package/app/output/_probe_fq_click.py +0 -50
- package/app/output/_probe_fq_form.py +0 -42
- package/app/output/_probe_fq_modal.py +0 -55
- package/app/output/_probe_fq_points.py +0 -71
- package/app/output/_probe_fq_submit.py +0 -34
- package/app/output/_probe_fq_watch.py +0 -62
package/app/core/publish/flow.py
CHANGED
|
@@ -1,640 +1,753 @@
|
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
import
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"hit.
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"const
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"const
|
|
102
|
-
"
|
|
103
|
-
"if(
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
"const
|
|
111
|
-
"const
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
"el.
|
|
115
|
-
"
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
"
|
|
127
|
-
"const
|
|
128
|
-
"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
"
|
|
143
|
-
"el.
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
"
|
|
158
|
-
"const
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
"
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
"
|
|
189
|
-
"
|
|
190
|
-
"
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
for g, t in pairs:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
for
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
#
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
note(i, "
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
note(i, "
|
|
303
|
-
page.wait_for(st["sel"], timeout=8)
|
|
304
|
-
page.
|
|
305
|
-
elif act == "
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
note(i, "「%s
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
"
|
|
376
|
-
"cards.
|
|
377
|
-
"const
|
|
378
|
-
"
|
|
379
|
-
"
|
|
380
|
-
"
|
|
381
|
-
"
|
|
382
|
-
"
|
|
383
|
-
"const
|
|
384
|
-
"
|
|
385
|
-
"
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
"
|
|
400
|
-
"const
|
|
401
|
-
"const
|
|
402
|
-
"
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
for
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
"
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
"
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
#
|
|
471
|
-
#
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
elif act == "
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
elif act == "
|
|
571
|
-
#
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
+
click_match {any:[..], max_len} 关键词选块(点同时含所有关键词的最小元素)
|
|
19
|
+
submit {sel} 终步:auto_submit=false 时跳过(留给人工确认),
|
|
20
|
+
跳过时补一张 ready-*.png,用户在浏览器窗口里自查提交
|
|
21
|
+
volume {key, open_sel, modal_sel, item_sel, add_sel, input_sel,
|
|
22
|
+
confirm_sel, settle} 发章带卷:选平台已有卷,缺则新建(自动分卷)
|
|
23
|
+
verify {url, any, settle} 上线验证:导航后断言标记在场
|
|
24
|
+
url_any {any: [..]} 断言当前 URL 含任一标记(登录跳转检测)
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
from .browser import BrowserError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FlowError(Exception):
|
|
36
|
+
"""流程失败:message 面向用户(含步骤序号与截图路径线索)。"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TitleDupError(FlowError):
|
|
40
|
+
"""平台拒绝书名:重名(全平台书名唯一)。message 带平台提示原文。
|
|
41
|
+
|
|
42
|
+
与普通流程失败分开成独立类型:manager 建书要按它触发「对账复用 →
|
|
43
|
+
自动换名重试」,普通失败不能换名(换了也建不成)。"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, hint):
|
|
46
|
+
super().__init__("平台拒绝书名(已存在同名作品):%s" % hint)
|
|
47
|
+
self.hint = hint
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# 重名提示识别词(番茄/七猫建书页校验文案口径:「书名已存在」「该名称已被
|
|
51
|
+
# 使用」等)。核心词「已存在/已被使用/已被注册/重名」足够特异——建书页上
|
|
52
|
+
# 出现这些词几乎必然指向书名撞车;不加宽泛的「重复」,防误伤其它字段校验。
|
|
53
|
+
DUP_HINT_WORDS = ("已存在", "已被使用", "已被注册", "重名", "已被占用")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dup_probe_js():
|
|
57
|
+
# 抓页面上可见提示里命中重名关键词的那条(toast/表单校验/弹窗)。
|
|
58
|
+
# 选择器与 _page_error_text_js 同源;只回命中词的那条,空串=页面没喊重名。
|
|
59
|
+
return ("(words)=>{const sels='.arco-message,.arco-notification,"
|
|
60
|
+
"[class*=message],[class*=toast],[class*=error],[class*=alert],"
|
|
61
|
+
"[class*=tip],[class*=tooltip],[class*=valid]';"
|
|
62
|
+
"for(const e of document.querySelectorAll(sels)){"
|
|
63
|
+
"const r=e.getBoundingClientRect();"
|
|
64
|
+
"if(r.width<=0||r.height<=0)continue;"
|
|
65
|
+
"const x=(e.innerText||'').trim();"
|
|
66
|
+
"if(!x||x.length>160)continue;"
|
|
67
|
+
"for(const w of words){if(x.includes(w))return x;}}"
|
|
68
|
+
"return '';}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def dup_hint(page, timeout=5):
|
|
72
|
+
"""读页面上的重名提示文本;无/读失败返回空串。预检与事后探测共用。"""
|
|
73
|
+
try:
|
|
74
|
+
v = page.call(_dup_probe_js(), list(DUP_HINT_WORDS), timeout=timeout)
|
|
75
|
+
return v.strip() if isinstance(v, str) else ""
|
|
76
|
+
except Exception:
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _click_match_js():
|
|
81
|
+
# 点同时包含所有关键词的最小可见元素(长度最小者=最内层卡片)
|
|
82
|
+
return ("(keys,maxLen)=>{"
|
|
83
|
+
"const hit=[...document.querySelectorAll('div,li,section,label,span,a')].filter(e=>{"
|
|
84
|
+
"const x=(e.innerText||'').trim();"
|
|
85
|
+
"return x && x.length<=maxLen && keys.every(k=>x.includes(k));});"
|
|
86
|
+
"if(!hit.length)return{ok:false,err:'找不到同时含 '+keys.join('+')+' 的元素'};"
|
|
87
|
+
"hit.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
|
|
88
|
+
"let el=hit[0];"
|
|
89
|
+
"const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
|
|
90
|
+
"act.scrollIntoView({block:'center'});act.click();"
|
|
91
|
+
"return{ok:true,tag:act.tagName};}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fill_label_js():
|
|
95
|
+
# Element UI 表单:在 .el-form-item(或 class 含 form-item 的容器)里按
|
|
96
|
+
# label 文本定位控件,走与 browser.fill 相同的 native setter 管道
|
|
97
|
+
return ("(labelText,text)=>{"
|
|
98
|
+
"const items=[...document.querySelectorAll('.el-form-item,[class*=form-item]')];"
|
|
99
|
+
"let target=null;"
|
|
100
|
+
"for(const it of items){"
|
|
101
|
+
"const lb=it.querySelector('[class*=label],[class*=label]');"
|
|
102
|
+
"const ltxt=((lb&&lb.innerText)||'').trim();"
|
|
103
|
+
"if(ltxt&<xt.includes(labelText)){target=it;break;}}"
|
|
104
|
+
"if(!target)return{ok:false,err:'找不到字段「'+labelText+'」'};"
|
|
105
|
+
"const el=target.querySelector('textarea,[contenteditable=true],input[type=text]')||"
|
|
106
|
+
"target.querySelector('input,textarea');"
|
|
107
|
+
"if(!el)return{ok:false,err:'字段「'+labelText+'」下没有输入控件'};"
|
|
108
|
+
"el.scrollIntoView({block:'center'});el.focus();"
|
|
109
|
+
"if(el.isContentEditable){"
|
|
110
|
+
"const r=document.createRange();r.selectNodeContents(el);"
|
|
111
|
+
"const g=getSelection();g.removeAllRanges();g.addRange(r);"
|
|
112
|
+
"document.execCommand('insertText',false,text);"
|
|
113
|
+
"return{ok:true};}"
|
|
114
|
+
"const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;"
|
|
115
|
+
"const d=Object.getOwnPropertyDescriptor(proto,'value');"
|
|
116
|
+
"(d&&d.set?d.set:function(v){el.value=v}).call(el,text);"
|
|
117
|
+
"el.dispatchEvent(new Event('input',{bubbles:true}));"
|
|
118
|
+
"el.dispatchEvent(new Event('change',{bubbles:true}));"
|
|
119
|
+
"return{ok:true};}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _click_text_js():
|
|
123
|
+
# contains 模式下外层容器(整页文本)也会 includes 命中——候选按文本长度
|
|
124
|
+
# 升序取最短者=最内层最精确元素(「新建小说」按钮赢过包它的大 DIV)
|
|
125
|
+
return ("(t,scope,contains)=>{"
|
|
126
|
+
"const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li')];"
|
|
127
|
+
"const cands=els.filter(e=>{const x=(e.innerText||'').trim();"
|
|
128
|
+
"return x&&(contains?x.includes(t):x===t);});"
|
|
129
|
+
"if(!cands.length)return{ok:false,err:'页面上找不到文本为「'+t+'」的可点元素'};"
|
|
130
|
+
"cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
|
|
131
|
+
"const hit=cands[0];"
|
|
132
|
+
"hit.scrollIntoView({block:'center'});hit.click();return{ok:true};}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _checked_js():
|
|
136
|
+
# radio/checkbox 点击闭环:点击派发成功 ≠ 选中成功(遮罩/几何打偏会静默
|
|
137
|
+
# 吞掉,2026-09-28 建书两 radio 全空实案)。点完回头验 checked 态:
|
|
138
|
+
# arco 系 label 挂 checked 类、原生 input 挂 checked 属性,双信号任一即算
|
|
139
|
+
return ("(t,scope)=>{"
|
|
140
|
+
"const els=[...document.querySelectorAll("
|
|
141
|
+
"scope||'label,[class*=radio],[class*=checkbox]')]"
|
|
142
|
+
".filter(e=>e.getBoundingClientRect().width>0);"
|
|
143
|
+
"const el=els.find(e=>((e.innerText||'').trim()).includes(t));"
|
|
144
|
+
"if(!el)return{ok:false,err:'nf'};"
|
|
145
|
+
"const box=el.closest('[class*=radio],[class*=checkbox]')||el;"
|
|
146
|
+
"const inp=box.querySelector('input[type=radio],input[type=checkbox]')||"
|
|
147
|
+
"el.querySelector('input[type=radio],input[type=checkbox]');"
|
|
148
|
+
"const ck=(inp&&inp.checked)||/checked|active/.test(box.className||'')"
|
|
149
|
+
"||/checked|active/.test(el.className||'');"
|
|
150
|
+
"return{ok:!!ck};}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _page_error_text_js():
|
|
154
|
+
# 抓页面上可见的报错/提示(toast、表单校验消息):提交被平台静默拒绝
|
|
155
|
+
# 时 url_any 只看得到「没跳转」,把页面自己的话带回来才不用瞎猜
|
|
156
|
+
return ("()=>{const sels='.arco-message,.arco-notification,"
|
|
157
|
+
"[class*=message],[class*=toast],[class*=error],[class*=alert]';"
|
|
158
|
+
"const out=[],seen={};"
|
|
159
|
+
"for(const e of document.querySelectorAll(sels)){"
|
|
160
|
+
"const r=e.getBoundingClientRect();"
|
|
161
|
+
"if(r.width<=0||r.height<=0)continue;"
|
|
162
|
+
"const x=(e.innerText||'').trim();"
|
|
163
|
+
"if(!x||x.length>120||seen[x])continue;seen[x]=1;out.push(x);}"
|
|
164
|
+
"return out.slice(0,5).join(' | ');}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _tag_sections_js():
|
|
168
|
+
# 标签弹层分组读取(七猫形态):div.tags-wrap[data-type-id] 每组一块,
|
|
169
|
+
# 组名在 .tags-tit(带前导空格须 trim),芯片名 .tags-con-name,
|
|
170
|
+
# 选中态 .tags-con.tag-selected。返回 null = 弹层无此结构。
|
|
171
|
+
return ("()=>{const wraps=[...document.querySelectorAll('.tags-wrap')];"
|
|
172
|
+
"if(!wraps.length)return null;"
|
|
173
|
+
"return wraps.map(w=>({"
|
|
174
|
+
"g:(((w.querySelector('.tags-tit')||{}).innerText)||'').trim(),"
|
|
175
|
+
"sel:[...w.querySelectorAll('.tags-box .tags-con.tag-selected .tags-con-name')]"
|
|
176
|
+
".map(e=>(e.innerText||'').trim())}));}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _tag_click_js():
|
|
180
|
+
# 组内精确定位芯片再点。四组选项同屏渲染、芯片点击永远落在其所在
|
|
181
|
+
# 分组(左侧组名导航只管高亮不管归属),且组名导航/全弹层 contains
|
|
182
|
+
# 搜索在滚动竞态下会假成功——el.click() 打在滚动中的芯片上被平台吞
|
|
183
|
+
# 掉也报 ok(0928 实案:前 9 个全中、第 10 个「都市」假成功,背景组
|
|
184
|
+
# 空选,「确定」被平台以「每个类型下至少选择1个标签」打回)。
|
|
185
|
+
# err=nogroup(弹层没这组)单独返回,供上层区分「没等到弹层」与
|
|
186
|
+
# 「组里没这个标签」(后者=平台目录漂移,必须立刻报错而不是去别组碰)。
|
|
187
|
+
return ("(g,t)=>{for(const w of document.querySelectorAll('.tags-wrap')){"
|
|
188
|
+
"const gname=(((w.querySelector('.tags-tit')||{}).innerText)||'').trim();"
|
|
189
|
+
"if(gname!==g)continue;"
|
|
190
|
+
"const hit=[...w.querySelectorAll('.tags-box .tags-con-name')]"
|
|
191
|
+
".find(e=>((e.innerText||'').trim())===t);"
|
|
192
|
+
"if(!hit)return{ok:false,err:'组「'+g+'」下没有标签「'+t+'」'};"
|
|
193
|
+
"hit.scrollIntoView({block:'center'});hit.click();return{ok:true};}"
|
|
194
|
+
"return{ok:false,err:'nogroup'};}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _tags_select_grouped(page, note, i, pairs):
|
|
198
|
+
"""分组定位点选:逐项点+回读校验(点不中就重新定位重试),收尾全组
|
|
199
|
+
对账——多选的再点一次剔除(芯片是开关)、缺的补点,两轮仍不齐才报
|
|
200
|
+
错,错误带组名+差集,别让人对着平台的「每个类型下至少选择1个标签」
|
|
201
|
+
toast 瞎猜。"""
|
|
202
|
+
def read():
|
|
203
|
+
return page.call(_tag_sections_js()) or []
|
|
204
|
+
|
|
205
|
+
def one(g, t):
|
|
206
|
+
for _try in range(4):
|
|
207
|
+
r = page.call(_tag_click_js(), g, t)
|
|
208
|
+
err = (r or {}).get("err")
|
|
209
|
+
if err == "nogroup":
|
|
210
|
+
time.sleep(0.8) # 弹层中途重渲染:等它回来
|
|
211
|
+
continue
|
|
212
|
+
if err:
|
|
213
|
+
raise FlowError(err) # 组里没有这个标签=目录漂移,去别组碰只会点错
|
|
214
|
+
time.sleep(0.5)
|
|
215
|
+
for s in read():
|
|
216
|
+
if s["g"] == g:
|
|
217
|
+
if t in s["sel"]:
|
|
218
|
+
return True
|
|
219
|
+
break
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
for g, t in pairs:
|
|
223
|
+
if not one(g, t):
|
|
224
|
+
raise FlowError("标签「%s」未能选入「%s」组(该组或已选满3个)" % (t, g))
|
|
225
|
+
want = {}
|
|
226
|
+
for g, t in pairs:
|
|
227
|
+
want.setdefault(g, set()).add(t)
|
|
228
|
+
bad = []
|
|
229
|
+
for _round in range(2):
|
|
230
|
+
bad = []
|
|
231
|
+
for s in read():
|
|
232
|
+
w = want.get(s["g"])
|
|
233
|
+
if w is None:
|
|
234
|
+
continue # 没让点的组不动它
|
|
235
|
+
extra = sorted(set(s["sel"]) - w)
|
|
236
|
+
miss = sorted(w - set(s["sel"]))
|
|
237
|
+
if extra or miss:
|
|
238
|
+
parts = []
|
|
239
|
+
if extra:
|
|
240
|
+
parts.append("多选 " + "、".join(extra))
|
|
241
|
+
if miss:
|
|
242
|
+
parts.append("缺 " + "、".join(miss))
|
|
243
|
+
bad.append("「%s」组%s" % (s["g"], ";".join(parts)))
|
|
244
|
+
if not bad:
|
|
245
|
+
note(i, "标签对账通过(%d 项)" % len(pairs))
|
|
246
|
+
return
|
|
247
|
+
for s in read(): # 多选:再点一次取消
|
|
248
|
+
for x in set(s["sel"]) - want.get(s["g"], set()):
|
|
249
|
+
page.call(_tag_click_js(), s["g"], x)
|
|
250
|
+
time.sleep(0.4)
|
|
251
|
+
for g, t in pairs: # 缺选:补点
|
|
252
|
+
sel = next((x["sel"] for x in read() if x["g"] == g), [])
|
|
253
|
+
if t not in sel:
|
|
254
|
+
page.call(_tag_click_js(), g, t)
|
|
255
|
+
time.sleep(0.4)
|
|
256
|
+
raise FlowError("标签对账未过:%s" % ";".join(bad))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
260
|
+
shot=None, log=None, dup_check=False):
|
|
261
|
+
"""跑一个流程。values:fill 取值字典;config:平台 URL 等占位符来源。
|
|
262
|
+
|
|
263
|
+
shot(name) → 截图落盘函数(manager 注入,路径含任务/平台维度);
|
|
264
|
+
log(line) → 步骤日志函数(进发布记录的 log 字段)。返回执行到的步数。
|
|
265
|
+
dup_check → 提交前探测页面重名提示(建书专用):平台实时校验喊「已
|
|
266
|
+
存在」时点提交必然被拒、流程只会误报「未登录或改版」——在点击前抛
|
|
267
|
+
TitleDupError 让上层走换名重试,别白点(2026-09-30 马甲案)。"""
|
|
268
|
+
values = values or {}
|
|
269
|
+
config = config or {}
|
|
270
|
+
log = log or (lambda s: None)
|
|
271
|
+
|
|
272
|
+
def note(i, s):
|
|
273
|
+
log("步骤%d %s" % (i, s))
|
|
274
|
+
|
|
275
|
+
for i, st in enumerate(steps):
|
|
276
|
+
act = (st.get("do") or "").strip()
|
|
277
|
+
try:
|
|
278
|
+
if act == "navigate":
|
|
279
|
+
url = st["url"]
|
|
280
|
+
for k, v in config.items():
|
|
281
|
+
url = url.replace("{%s}" % k, str(v))
|
|
282
|
+
for k, v in (values or {}).items(): # editor_url/draft_url 等任务级占位
|
|
283
|
+
url = url.replace("{%s}" % k, str(v))
|
|
284
|
+
# 占位符没被吃掉=values 缺键(平台模块缺 URL 生成器/登记不全)。
|
|
285
|
+
# 直接放行会让浏览器停在 about:blank,到 url_any 才误报
|
|
286
|
+
# 「未登录或改版」——在这里点名缺哪个键(0924 番茄三连败)。
|
|
287
|
+
left = re.findall(r"\{[A-Za-z_][A-Za-z0-9_]*\}", url)
|
|
288
|
+
if left:
|
|
289
|
+
raise FlowError("URL 占位符 %s 没有对应值:%s"
|
|
290
|
+
% (",".join(left), url[:90]))
|
|
291
|
+
note(i, "打开 %s" % url)
|
|
292
|
+
# 重页(编辑器/管理列表)可按步放宽;缺省 45s(30s 对慢网偏紧)
|
|
293
|
+
page.navigate(url, timeout=float(st.get("timeout") or 45))
|
|
294
|
+
elif act == "wait":
|
|
295
|
+
note(i, "等待 %s" % st["sel"])
|
|
296
|
+
page.wait_for(st["sel"], timeout=float(st.get("timeout") or 12))
|
|
297
|
+
elif act == "fill":
|
|
298
|
+
key = st.get("key") or ""
|
|
299
|
+
text = values.get(key, "")
|
|
300
|
+
if text in ("", None):
|
|
301
|
+
continue # 空值字段跳过(如无第二主角)
|
|
302
|
+
note(i, "填入 %s(%d 字)" % (key, len(str(text))))
|
|
303
|
+
page.wait_for(st["sel"], timeout=8)
|
|
304
|
+
page.fill(st["sel"], str(text))
|
|
305
|
+
elif act == "click":
|
|
306
|
+
note(i, "点击 %s" % st["sel"])
|
|
307
|
+
page.wait_for(st["sel"], timeout=8)
|
|
308
|
+
page.click(st["sel"])
|
|
309
|
+
elif act == "click_text":
|
|
310
|
+
text = str(st.get("text") or "")
|
|
311
|
+
for k, v in (values or {}).items(): # {signing_mode} 等动态值
|
|
312
|
+
text = text.replace("{%s}" % k, str(v))
|
|
313
|
+
if not text:
|
|
314
|
+
continue
|
|
315
|
+
note(i, "点击「%s」" % text)
|
|
316
|
+
r = None
|
|
317
|
+
for _try in range(3): # SPA 渲染慢:找不到先等再试
|
|
318
|
+
r = page.call(_click_text_js(), text, st.get("scope") or "",
|
|
319
|
+
bool(st.get("contains")))
|
|
320
|
+
if (r or {}).get("ok"):
|
|
321
|
+
break
|
|
322
|
+
time.sleep(0.9)
|
|
323
|
+
if not (r or {}).get("ok"):
|
|
324
|
+
raise FlowError((r or {}).get("err") or text)
|
|
325
|
+
elif act == "click_real":
|
|
326
|
+
# 真实鼠标事件点击(CDP Input 派发):qm-btn 等自定义按钮
|
|
327
|
+
# 只认真实事件序列,el.click() 无效。发布确认链全靠它。
|
|
328
|
+
text = str(st.get("text") or "")
|
|
329
|
+
for k, v in (values or {}).items(): # {target_reader} 等动态值
|
|
330
|
+
text = text.replace("{%s}" % k, str(v))
|
|
331
|
+
for k, v in (values or {}).items():
|
|
332
|
+
text = text.replace("{%s}" % k, str(v))
|
|
333
|
+
if not text:
|
|
334
|
+
continue
|
|
335
|
+
note(i, "真实点击「%s」" % text)
|
|
336
|
+
r = None
|
|
337
|
+
verified = not st.get("expect_checked")
|
|
338
|
+
tries = int(st.get("tries") or 25)
|
|
339
|
+
for _try in range(tries):
|
|
340
|
+
r = page.real_click_text(text, st.get("scope") or
|
|
341
|
+
"a,button,[class*=btn]",
|
|
342
|
+
y_min=st.get("y_min"),
|
|
343
|
+
y_max=st.get("y_max"))
|
|
344
|
+
if (r or {}).get("ok"):
|
|
345
|
+
if verified:
|
|
346
|
+
break
|
|
347
|
+
# 点击成功≠选中成功:回头验 checked 态,不中重点
|
|
348
|
+
v = page.call(_checked_js(), text,
|
|
349
|
+
st.get("scope") or "")
|
|
350
|
+
if (v or {}).get("ok"):
|
|
351
|
+
verified = True
|
|
352
|
+
break
|
|
353
|
+
note(i, "「%s」已点但未见选中态,重试" % text)
|
|
354
|
+
time.sleep(0.4)
|
|
355
|
+
if not verified or not (r or {}).get("ok"):
|
|
356
|
+
if st.get("optional"):
|
|
357
|
+
note(i, "「%s」未出现,跳过(optional)" % text)
|
|
358
|
+
continue
|
|
359
|
+
if (r or {}).get("ok") and st.get("expect_checked"):
|
|
360
|
+
raise FlowError("「%s」连点 %d 次仍未见选中态"
|
|
361
|
+
"(radio 被遮罩/改版拦下)" % (text, tries))
|
|
362
|
+
raise FlowError((r or {}).get("err") or text)
|
|
363
|
+
elif act == "click_in":
|
|
364
|
+
# 先按 scope_text 定位容器(如书卡),再在容器内点 text 按钮。
|
|
365
|
+
# 解决「按钮与书名同卡片但不在彼此祖先链」的组合定位。
|
|
366
|
+
scope_t = str(st.get("scope_text") or "")
|
|
367
|
+
text = str(st.get("text") or "")
|
|
368
|
+
for k, v in (values or {}).items():
|
|
369
|
+
scope_t = scope_t.replace("{%s}" % k, str(v))
|
|
370
|
+
text = text.replace("{%s}" % k, str(v))
|
|
371
|
+
note(i, "在含「%s」的卡片内点「%s」" % (scope_t, text))
|
|
372
|
+
r = None
|
|
373
|
+
for _try in range(10): # 表格异步渲染:重试窗口加长
|
|
374
|
+
r = page.call(
|
|
375
|
+
"(sc,t)=>{"
|
|
376
|
+
"const cards=[...document.querySelectorAll('div,li,section,tr')].filter(e=>{"
|
|
377
|
+
"const x=(e.innerText||'').trim();"
|
|
378
|
+
"return x.includes(sc)&&x.length<600&&e.getBoundingClientRect().width>0;});"
|
|
379
|
+
"if(!cards.length)return{ok:false,err:'找不到含'+sc+'的卡片'};"
|
|
380
|
+
"cards.sort((a,b)=>(b.innerText||'').length-(a.innerText||'').length);"
|
|
381
|
+
"const card=cards[0];"
|
|
382
|
+
"const btns=[...card.querySelectorAll('a,button,[role=button],[class*=btn],span')].filter(e=>{"
|
|
383
|
+
"const x=(e.innerText||'').trim();return x===t||x.includes(t)&&x.length<=t.length+6;});"
|
|
384
|
+
"if(!btns.length)return{ok:false,err:'卡片内没有'+t};"
|
|
385
|
+
"btns.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
|
|
386
|
+
"let el=btns[0];"
|
|
387
|
+
"const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
|
|
388
|
+
"act.scrollIntoView({block:'center'});act.click();"
|
|
389
|
+
"return{ok:true};}", scope_t, text)
|
|
390
|
+
if (r or {}).get("ok"):
|
|
391
|
+
break
|
|
392
|
+
time.sleep(0.9)
|
|
393
|
+
if not (r or {}).get("ok"):
|
|
394
|
+
raise FlowError((r or {}).get("err") or "click_in 失败")
|
|
395
|
+
elif act == "click_arrow":
|
|
396
|
+
# 展开下拉按钮组(番茄「下一步▾」):点目标按钮组右缘 10px
|
|
397
|
+
note(i, "展开下拉箭头")
|
|
398
|
+
r = page.call(
|
|
399
|
+
"(t)=>{"
|
|
400
|
+
"const vis=e=>e.getBoundingClientRect().width>0;"
|
|
401
|
+
"const btn=[...document.querySelectorAll('button')].find(e=>"
|
|
402
|
+
"vis(e)&&(e.innerText||'').trim().includes(t));"
|
|
403
|
+
"if(!btn)return{ok:false,err:'按钮未找到'};"
|
|
404
|
+
"const host=btn.closest('[class*=group],[class*=dropdown]')||btn.parentElement;"
|
|
405
|
+
"const r=host.getBoundingClientRect();"
|
|
406
|
+
"return{ok:true,x:Math.round(r.x+r.width-10),y:Math.round(r.y+r.height/2)};}", str(st.get("target") or "下一步"))
|
|
407
|
+
if not (r or {}).get("ok"):
|
|
408
|
+
raise FlowError((r or {}).get("err") or "箭头定位失败")
|
|
409
|
+
page.send("Input.dispatchMouseEvent",
|
|
410
|
+
{"type": "mousePressed", "x": r["x"], "y": r["y"],
|
|
411
|
+
"button": "left", "clickCount": 1}, timeout=8.0)
|
|
412
|
+
page.send("Input.dispatchMouseEvent",
|
|
413
|
+
{"type": "mouseReleased", "x": r["x"], "y": r["y"],
|
|
414
|
+
"button": "left", "clickCount": 1}, timeout=8.0)
|
|
415
|
+
time.sleep(float(st.get("settle") or 1.2))
|
|
416
|
+
elif act == "click_match":
|
|
417
|
+
# 关键词选块(站点卡片等):点同时包含所有关键词的最小元素
|
|
418
|
+
keys = [str(x) for x in (st.get("any") or []) if str(x).strip()]
|
|
419
|
+
for k2, v2 in (values or {}).items(): # {book_name} 等动态值
|
|
420
|
+
keys = [x.replace("{%s}" % k2, str(v2)) for x in keys]
|
|
421
|
+
note(i, "点击含 %s 的卡片" % keys)
|
|
422
|
+
r = None
|
|
423
|
+
for _try in range(4): # 弹层渲染慢:找不到先等再试
|
|
424
|
+
r = page.call(_click_match_js(), keys,
|
|
425
|
+
int(st.get("max_len") or 400))
|
|
426
|
+
if (r or {}).get("ok"):
|
|
427
|
+
break
|
|
428
|
+
time.sleep(1.0)
|
|
429
|
+
if not (r or {}).get("ok"):
|
|
430
|
+
raise FlowError((r or {}).get("err") or "卡片未找到")
|
|
431
|
+
elif act == "fill_label":
|
|
432
|
+
# 按字段标签填(Element UI form-item:label 与控件同容器)
|
|
433
|
+
key = st.get("key") or ""
|
|
434
|
+
label = str(st.get("label") or "")
|
|
435
|
+
text = str(values.get(key, "") or "")
|
|
436
|
+
if not text:
|
|
437
|
+
continue
|
|
438
|
+
note(i, "填字段「%s」(%d 字)" % (label, len(text)))
|
|
439
|
+
page.wait_for("[class*=form]", timeout=8)
|
|
440
|
+
r = None
|
|
441
|
+
for _try in range(3):
|
|
442
|
+
r = page.call(_fill_label_js(), label, text)
|
|
443
|
+
if (r or {}).get("ok"):
|
|
444
|
+
break
|
|
445
|
+
time.sleep(0.9)
|
|
446
|
+
if not (r or {}).get("ok"):
|
|
447
|
+
raise FlowError((r or {}).get("err") or "填字段失败")
|
|
448
|
+
elif act == "radio":
|
|
449
|
+
# 单选组:values[map[key]] 映射到 radio value 后点它。
|
|
450
|
+
# 隐藏 input(Element UI)点不到——点它的最近可见 label 祖先。
|
|
451
|
+
key = st.get("key") or ""
|
|
452
|
+
group = st.get("map") or {}
|
|
453
|
+
want = str(values.get(key, "")).strip()
|
|
454
|
+
val = group.get(want)
|
|
455
|
+
if val is None:
|
|
456
|
+
if st.get("optional"):
|
|
457
|
+
note(i, "跳过单选 %s(无值)" % key)
|
|
458
|
+
continue
|
|
459
|
+
raise FlowError("单选 %s:值「%s」不在映射 %s 里" % (key, want, list(group)))
|
|
460
|
+
note(i, "单选 %s=%s(value=%s)" % (key, want, val))
|
|
461
|
+
r = page.call(
|
|
462
|
+
"(v)=>{const r=[...document.querySelectorAll('input[type=radio]')]"
|
|
463
|
+
".find(x=>x.value===v);if(!r)return{ok:false,err:'radio v='+v};"
|
|
464
|
+
"const lab=r.closest('label')||(r.closest('.el-radio')||{}).firstElementChild||r;"
|
|
465
|
+
"const t=lab.matches('label,.el-radio')?lab:(r.parentElement||r);"
|
|
466
|
+
"t.scrollIntoView({block:'center'});t.click();return{ok:true};}", str(val))
|
|
467
|
+
if not (r or {}).get("ok"):
|
|
468
|
+
raise FlowError((r or {}).get("err") or "单选点击失败")
|
|
469
|
+
elif act == "tags":
|
|
470
|
+
# 标签弹层逐个点选:manager 把标签清单放 values["_tags"]。
|
|
471
|
+
# 项为 [组名, 标签] 且弹层呈现分组结构(.tags-wrap)时走
|
|
472
|
+
# 「分组定位」:芯片在**它所属组的区块内**精确定位、点完即
|
|
473
|
+
# 回读该组选中集校验——点不中就重新定位重试,绝不带病前进。
|
|
474
|
+
# 弹层无此结构或清单含无组名项时回退旧路径(整页 contains
|
|
475
|
+
# 搜索:组名切换 + 直接点标签,历史平台兜底)。
|
|
476
|
+
tags = values.get("_tags") or []
|
|
477
|
+
if not tags:
|
|
478
|
+
note(i, "无标签可点,跳过")
|
|
479
|
+
continue
|
|
480
|
+
scope = st.get("scope") or "span,li,label,[class*=dialog] *,[class*=popper] *"
|
|
481
|
+
pairs = [(str(it[0]).strip(), str(it[1]).strip()) for it in tags
|
|
482
|
+
if not isinstance(it, (str, int))
|
|
483
|
+
and str(it[0] or "").strip() and str(it[1] or "").strip()]
|
|
484
|
+
sections = None
|
|
485
|
+
if pairs and len(pairs) == len(tags):
|
|
486
|
+
for _w in range(8): # 等弹层渲染出分组结构
|
|
487
|
+
sections = page.call(_tag_sections_js())
|
|
488
|
+
if sections:
|
|
489
|
+
break
|
|
490
|
+
time.sleep(1.0)
|
|
491
|
+
if sections and isinstance(sections, list):
|
|
492
|
+
missing = {g for g, _ in pairs} - {s["g"] for s in sections}
|
|
493
|
+
if missing:
|
|
494
|
+
# 弹层是分组结构但组名对不上=平台目录/组名改了——
|
|
495
|
+
# 回退旧路径只会全局乱点(本次事故根源形态),当场报错
|
|
496
|
+
raise FlowError(
|
|
497
|
+
"标签弹层分组与预期不符:缺 %s(弹层现有组:%s);"
|
|
498
|
+
"请重生成作品信息后再试"
|
|
499
|
+
% ("、".join(sorted(missing)),
|
|
500
|
+
"、".join(s["g"] for s in sections)))
|
|
501
|
+
note(i, "点选标签 %d 项(分组定位)" % len(pairs))
|
|
502
|
+
_tags_select_grouped(page, note, i, pairs)
|
|
503
|
+
continue
|
|
504
|
+
note(i, "点选标签 %d 项" % len(tags))
|
|
505
|
+
for item in tags:
|
|
506
|
+
grp, tg = ("", str(item)) if isinstance(item, (str, int)) \
|
|
507
|
+
else (str(item[0] or ""), str(item[1] or ""))
|
|
508
|
+
if not tg:
|
|
509
|
+
continue
|
|
510
|
+
if grp: # 切到目标组
|
|
511
|
+
# 前置 click_text「添加标签」可能被分类下拉的关闭
|
|
512
|
+
# 动画/遮罩吞掉(el.click() 打在遮罩上也返回 ok),
|
|
513
|
+
# 组名找不到=弹层还没开:多等几轮让它开出来
|
|
514
|
+
r0 = None
|
|
515
|
+
for _try in range(4):
|
|
516
|
+
r0 = page.call(_click_text_js(), grp, scope, True)
|
|
517
|
+
if (r0 or {}).get("ok"):
|
|
518
|
+
break
|
|
519
|
+
time.sleep(1.0)
|
|
520
|
+
if not (r0 or {}).get("ok"):
|
|
521
|
+
raise FlowError("标签组「%s」切换失败(标签弹层未打开)" % grp)
|
|
522
|
+
time.sleep(0.3)
|
|
523
|
+
r = None
|
|
524
|
+
for _try in range(3):
|
|
525
|
+
r = page.call(_click_text_js(), tg, scope, True)
|
|
526
|
+
if (r or {}).get("ok"):
|
|
527
|
+
break
|
|
528
|
+
time.sleep(0.7)
|
|
529
|
+
if not (r or {}).get("ok"):
|
|
530
|
+
raise FlowError("标签「%s」点选失败:%s" % (tg, (r or {}).get("err")))
|
|
531
|
+
elif act == "shot":
|
|
532
|
+
note(i, "截图 %s" % st.get("name"))
|
|
533
|
+
if shot:
|
|
534
|
+
shot(st.get("name") or "step")
|
|
535
|
+
elif act == "probe":
|
|
536
|
+
info = page.probe()
|
|
537
|
+
note(i, "表单探测 %s:%d 个可交互元素" % (st.get("note") or "", len(info)))
|
|
538
|
+
if log:
|
|
539
|
+
log("PROBE " + json.dumps(info, ensure_ascii=False)[:4000])
|
|
540
|
+
elif act == "submit":
|
|
541
|
+
if not auto_submit:
|
|
542
|
+
note(i, "已填好未提交——请人工检查后提交(auto_submit=false)")
|
|
543
|
+
if shot:
|
|
544
|
+
shot("ready-manual-submit")
|
|
545
|
+
return i + 1
|
|
546
|
+
if dup_check:
|
|
547
|
+
# 事前预检:填表阶段平台实时校验(fill 派发过 change 事件,
|
|
548
|
+
# blur/输入即触发)喊了重名就别点了,点了也必被拒
|
|
549
|
+
hint = dup_hint(page)
|
|
550
|
+
if hint:
|
|
551
|
+
_fail_shot(shot, "step%d-title-dup" % i)
|
|
552
|
+
raise TitleDupError(hint)
|
|
553
|
+
if st.get("text"): # 按按钮文本提交
|
|
554
|
+
text = str(st["text"])
|
|
555
|
+
note(i, "提交「%s」(真实鼠标事件)" % text)
|
|
556
|
+
r = None
|
|
557
|
+
for _try in range(int(st.get("tries") or 3)):
|
|
558
|
+
r = page.real_click_text(
|
|
559
|
+
text, st.get("scope") or "button,a,[class*=btn]",
|
|
560
|
+
contains=True)
|
|
561
|
+
if (r or {}).get("ok"):
|
|
562
|
+
break
|
|
563
|
+
time.sleep(0.9)
|
|
564
|
+
if not (r or {}).get("ok"):
|
|
565
|
+
raise FlowError((r or {}).get("err") or "提交按钮未找到")
|
|
566
|
+
else:
|
|
567
|
+
note(i, "提交 %s" % st.get("sel") or "")
|
|
568
|
+
page.wait_for(st["sel"], timeout=8)
|
|
569
|
+
page.click(st["sel"])
|
|
570
|
+
elif act == "sleep":
|
|
571
|
+
# 固定等待(SPA 水合/动画缓冲):比 wait 更钝但最可靠
|
|
572
|
+
time.sleep(float(st.get("s") or 1.0))
|
|
573
|
+
note(i, "等待 %.1fs" % float(st.get("s") or 1.0))
|
|
574
|
+
elif act == "wait_gone":
|
|
575
|
+
# 等元素消失(弹层关闭动画对齐):连发点击被关闭动画的
|
|
576
|
+
# mask 吞掉是多层弹窗流程的经典竞态
|
|
577
|
+
sel = st["sel"]
|
|
578
|
+
note(i, "等待 %s 消失" % sel)
|
|
579
|
+
deadline = time.time() + float(st.get("timeout") or 10)
|
|
580
|
+
while time.time() < deadline:
|
|
581
|
+
try:
|
|
582
|
+
if not page.exists(sel, timeout=1.0):
|
|
583
|
+
break
|
|
584
|
+
except BrowserError:
|
|
585
|
+
break
|
|
586
|
+
time.sleep(0.4)
|
|
587
|
+
else:
|
|
588
|
+
raise FlowError("等待 %s 消失超时" % sel)
|
|
589
|
+
note(i, "已消失")
|
|
590
|
+
elif act == "verify":
|
|
591
|
+
# 上线验证:导航到验证页断言文本在场——防「流程完成但平台
|
|
592
|
+
# 静默未发布」的假成功(0 字草稿案)。url/any 支持 values 占位。
|
|
593
|
+
url = str(st.get("url") or "")
|
|
594
|
+
for k, v in (values or {}).items():
|
|
595
|
+
url = url.replace("{%s}" % k, str(v))
|
|
596
|
+
note(i, "验证 %s" % url[:60])
|
|
597
|
+
page.navigate(url, timeout=30)
|
|
598
|
+
time.sleep(float(st.get("settle") or 4))
|
|
599
|
+
marks = [str(m) for m in (st.get("any") or [])]
|
|
600
|
+
body_txt = str(page.call(
|
|
601
|
+
"()=>(document.body.innerText||'')", timeout=10) or "")
|
|
602
|
+
for m in marks:
|
|
603
|
+
mk = m
|
|
604
|
+
for k, v in (values or {}).items():
|
|
605
|
+
mk = mk.replace("{%s}" % k, str(v))
|
|
606
|
+
if mk not in body_txt:
|
|
607
|
+
raise FlowError("上线验证失败:%s 页面上没有「%s」(章节未真正发布)"
|
|
608
|
+
% (url[:60], mk[:40]))
|
|
609
|
+
note(i, "验证通过")
|
|
610
|
+
elif act == "volume":
|
|
611
|
+
# 发章带卷(2026-10-08 批量自动发布·自动分卷):确保目标卷
|
|
612
|
+
# 在平台上存在(缺则走「新建分卷」)。平台新章按卷自动归类、
|
|
613
|
+
# 不提供章节切卷入口(编辑器卷弹窗实测只能增删改卷,真机
|
|
614
|
+
# code -4054 还不允许连续两个无章节空卷)——所以本步骤只
|
|
615
|
+
# 「备好卷」,不假装能切卷。选择器全部数据驱动;无目标卷名
|
|
616
|
+
# 或编辑器没有卷选择器(optional)时跳过。
|
|
617
|
+
from .volumes import norm_volume_name
|
|
618
|
+
target = str(values.get(st.get("key") or "volume_name") or "").strip()
|
|
619
|
+
open_sel = str(st.get("open_sel") or "")
|
|
620
|
+
modal_sel = str(st.get("modal_sel") or "")
|
|
621
|
+
item_sel = str(st.get("item_sel") or "")
|
|
622
|
+
settle = float(st.get("settle") or 1.2)
|
|
623
|
+
if not target:
|
|
624
|
+
note(i, "无分卷目标,跳过")
|
|
625
|
+
continue
|
|
626
|
+
if not (open_sel and modal_sel and item_sel):
|
|
627
|
+
raise FlowError("volume 步骤缺选择器配置(open_sel/modal_sel/item_sel)")
|
|
628
|
+
hdr = ""
|
|
629
|
+
r = {"ok": False}
|
|
630
|
+
for _try in range(int(st.get("tries") or 6)):
|
|
631
|
+
hdr = str(page.call("(s)=>{var v=document.querySelector(s);"
|
|
632
|
+
"return v?(v.innerText||'').trim():'';}",
|
|
633
|
+
open_sel, timeout=8) or "").strip()
|
|
634
|
+
if hdr:
|
|
635
|
+
break
|
|
636
|
+
time.sleep(1.0) # 编辑器头部卷名渲染慢,别一次就放弃
|
|
637
|
+
if not hdr:
|
|
638
|
+
if st.get("optional"):
|
|
639
|
+
note(i, "编辑器无分卷选择器,跳过")
|
|
640
|
+
continue
|
|
641
|
+
raise FlowError("分卷选择器未出现:%s" % open_sel)
|
|
642
|
+
if norm_volume_name(hdr) == norm_volume_name(target):
|
|
643
|
+
note(i, "当前卷已是「%s」" % hdr)
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
def vol_items():
|
|
647
|
+
out = page.call("(s)=>[...document.querySelectorAll(s)]"
|
|
648
|
+
".filter(e=>e.getBoundingClientRect().width>0)"
|
|
649
|
+
".map(e=>(e.innerText||'').trim())",
|
|
650
|
+
item_sel, timeout=8)
|
|
651
|
+
return [str(x).strip() for x in (out or []) if str(x or "").strip()]
|
|
652
|
+
|
|
653
|
+
r = page.call("(s)=>{var v=document.querySelector(s);"
|
|
654
|
+
"if(!v)return{ok:false};v.click();return{ok:true};}",
|
|
655
|
+
open_sel, timeout=8)
|
|
656
|
+
if not (r or {}).get("ok"):
|
|
657
|
+
if st.get("optional"):
|
|
658
|
+
note(i, "编辑器无分卷选择器,跳过")
|
|
659
|
+
continue
|
|
660
|
+
raise FlowError("分卷选择器点不开:%s" % open_sel)
|
|
661
|
+
time.sleep(settle)
|
|
662
|
+
exists = any(norm_volume_name(t) == norm_volume_name(target)
|
|
663
|
+
for t in vol_items())
|
|
664
|
+
if not exists:
|
|
665
|
+
add_sel = str(st.get("add_sel") or "")
|
|
666
|
+
if not add_sel:
|
|
667
|
+
raise FlowError("平台没有目标分卷「%s」且流程未配新建分卷" % target)
|
|
668
|
+
r = page.call("(s)=>{var e=document.querySelector(s);"
|
|
669
|
+
"if(!e)return{ok:false};e.click();return{ok:true};}",
|
|
670
|
+
add_sel, timeout=8)
|
|
671
|
+
if not (r or {}).get("ok"):
|
|
672
|
+
raise FlowError("「新建分卷」点不到:%s" % add_sel)
|
|
673
|
+
time.sleep(settle)
|
|
674
|
+
rr = page.call("(m,t,inp,cs)=>{var mm=document.querySelector(m);"
|
|
675
|
+
"if(!mm)return{ok:false,err:'nomodal'};"
|
|
676
|
+
"var el=mm.querySelector(inp);"
|
|
677
|
+
"if(!el)return{ok:false,err:'noinput'};"
|
|
678
|
+
"var d=Object.getOwnPropertyDescriptor("
|
|
679
|
+
"HTMLInputElement.prototype,'value');"
|
|
680
|
+
"d.set.call(el,t);"
|
|
681
|
+
"el.dispatchEvent(new Event('input',{bubbles:true}));"
|
|
682
|
+
"var c=mm.querySelector(cs);"
|
|
683
|
+
"if(!c)return{ok:false,err:'noconfirm'};"
|
|
684
|
+
"c.click();return{ok:true};}",
|
|
685
|
+
modal_sel, target,
|
|
686
|
+
str(st.get("input_sel") or "input"),
|
|
687
|
+
str(st.get("confirm_sel") or "i.tomato-confirm"),
|
|
688
|
+
timeout=8)
|
|
689
|
+
if not (rr or {}).get("ok"):
|
|
690
|
+
raise FlowError("新建分卷「%s」失败:%s"
|
|
691
|
+
% (target, (rr or {}).get("err")))
|
|
692
|
+
time.sleep(settle)
|
|
693
|
+
exists = any(norm_volume_name(t) == norm_volume_name(target)
|
|
694
|
+
for t in vol_items())
|
|
695
|
+
if not exists:
|
|
696
|
+
raise FlowError("新建分卷后列表里没有「%s」——常见原因:"
|
|
697
|
+
"平台不允许连续两个无章节的空卷"
|
|
698
|
+
"(code -4054),等上一卷有章节再发"
|
|
699
|
+
% target)
|
|
700
|
+
known = values.get("_volumes")
|
|
701
|
+
if isinstance(known, list):
|
|
702
|
+
disp = next((t for t in vol_items()
|
|
703
|
+
if norm_volume_name(t) == norm_volume_name(target)), "")
|
|
704
|
+
if disp and disp not in known:
|
|
705
|
+
known.append(disp)
|
|
706
|
+
note(i, "已新建分卷「%s」" % target)
|
|
707
|
+
else:
|
|
708
|
+
note(i, "分卷「%s」已存在" % target)
|
|
709
|
+
# 收弹窗(取消=不动任何卷;确定会另起新草稿,都不影响归属——
|
|
710
|
+
# 新章由平台按卷自动归类)
|
|
711
|
+
page.call("(m)=>{var mm=document.querySelector(m);if(!mm)return{ok:false};"
|
|
712
|
+
"var b=mm.querySelectorAll('button');"
|
|
713
|
+
"for (var i=0;i<b.length;i++){"
|
|
714
|
+
" if((b[i].innerText||'').trim()==='取消'){b[i].click();return{ok:true};}}"
|
|
715
|
+
"return{ok:false};}", modal_sel, timeout=8)
|
|
716
|
+
time.sleep(0.6)
|
|
717
|
+
note(i, "分卷「%s」已就绪(平台按卷自动归类新章,当前卷 %s)"
|
|
718
|
+
% (target, hdr))
|
|
719
|
+
elif act == "url_any":
|
|
720
|
+
u = str(page.url() or "")
|
|
721
|
+
marks = st.get("any") or []
|
|
722
|
+
if not any(m in u for m in marks):
|
|
723
|
+
hint = ""
|
|
724
|
+
try:
|
|
725
|
+
hint = str(page.call(_page_error_text_js(), timeout=5) or "")
|
|
726
|
+
except Exception:
|
|
727
|
+
pass
|
|
728
|
+
msg = "当前页面 %s 不含预期标记 %s(可能未登录或改版)" % (u[:90], marks)
|
|
729
|
+
if hint:
|
|
730
|
+
msg += ";页面提示:%s" % hint[:150]
|
|
731
|
+
else:
|
|
732
|
+
msg += "(常见原因:表单校验未过,如简介字数不足)"
|
|
733
|
+
raise FlowError(msg)
|
|
734
|
+
note(i, "页面标记校验通过(%s)" % u[:80])
|
|
735
|
+
else:
|
|
736
|
+
raise FlowError("未知步骤类型:%s" % act)
|
|
737
|
+
except FlowError:
|
|
738
|
+
_fail_shot(shot, "step%d-%s" % (i, act))
|
|
739
|
+
raise
|
|
740
|
+
except BrowserError as e:
|
|
741
|
+
_fail_shot(shot, "step%d-%s" % (i, act))
|
|
742
|
+
raise FlowError("步骤%d(%s)失败:%s" % (i, act, e))
|
|
743
|
+
except KeyError as e:
|
|
744
|
+
raise FlowError("步骤%d 缺少字段 %s" % (i, e))
|
|
745
|
+
return len(steps)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _fail_shot(shot, name):
|
|
749
|
+
try:
|
|
750
|
+
if shot:
|
|
751
|
+
shot(name)
|
|
752
|
+
except Exception:
|
|
753
|
+
pass
|