codebee 0.1.69 → 0.1.70
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 +37 -0
- package/README.md +4 -3
- package/app/browser-final-1440.png +0 -0
- package/app/browser-final-390.png +0 -0
- package/app/browser-google-fallback.png +0 -0
- package/app/browser-google-test.png +0 -0
- package/app/browser-workbench-1440.png +0 -0
- package/app/browser-workbench-390.png +0 -0
- package/app/browser-workspace-google.png +0 -0
- package/app/browser-workspace-smoke.png +0 -0
- package/app/browser-workspace.png +0 -0
- package/app/core/audit.py +30 -0
- package/app/core/automation.py +107 -12
- package/app/core/benchstore.py +95 -0
- package/app/core/bookmeta.py +9 -4
- package/app/core/builtin_agent.py +472 -33
- package/app/core/catalog.py +7 -1
- package/app/core/dispatch.py +20 -3
- package/app/core/error_codes.py +11 -2
- package/app/core/evalbench.py +720 -0
- package/app/core/flows.py +228 -3
- package/app/core/jobs.py +22 -1
- package/app/core/manager.py +21 -0
- package/app/core/market_remote.py +10 -0
- package/app/core/mcp_client.py +287 -0
- package/app/core/modelhub.py +185 -2
- package/app/core/notify.py +118 -23
- package/app/core/novel_quality.py +189 -0
- package/app/core/paths.py +4 -0
- package/app/core/pipeline.py +426 -28
- package/app/core/planner.py +243 -22
- package/app/core/plugins.py +525 -0
- package/app/core/publish/browser.py +129 -13
- package/app/core/publish/fanqie.py +6 -0
- package/app/core/publish/flow.py +154 -5
- package/app/core/publish/flows-fanqie-calibrated.json +11 -7
- package/app/core/publish/ledger.py +29 -3
- package/app/core/publish/manager.py +134 -2
- package/app/core/publish/qimao.py +5 -0
- package/app/core/remotesync.py +121 -0
- package/app/core/runner.py +80 -5
- package/app/core/settings.py +39 -2
- package/app/core/settings_schema.py +8 -0
- package/app/core/store.py +30 -8
- package/app/core/usage.py +187 -14
- package/app/core/zentao.py +153 -4
- package/app/current-browser.png +0 -0
- package/app/main.py +279 -15
- package/app/mcp_server.py +175 -0
- package/app/pet.py +82 -21
- package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
- package/app/plugins/code-review-helper/skills/review.md +16 -0
- package/app/ui/app.js +1380 -73
- package/app/ui/i18n.js +248 -6
- package/app/ui/index.html +277 -13
- package/app/ui/style.css +786 -0
- package/package.json +2 -1
|
@@ -78,27 +78,68 @@ def _free_port():
|
|
|
78
78
|
return port
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
def _win_edge_proc_lines():
|
|
82
|
+
"""(pid, 命令行) 清单。wmic 优先(快);Win11 24H2 起系统已移除
|
|
83
|
+
wmic——输出为空/报错时落 PowerShell CIM(冷启 ~2s,仅在必要时付)。"""
|
|
84
|
+
import subprocess as _sp
|
|
85
|
+
try:
|
|
86
|
+
out = _sp.check_output(
|
|
87
|
+
["wmic", "process", "where", "Name='msedge.exe'",
|
|
88
|
+
"get", "ProcessId,CommandLine"],
|
|
89
|
+
stderr=_sp.DEVNULL, timeout=12).decode("utf-8", "replace")
|
|
90
|
+
lines = []
|
|
91
|
+
for l in out.splitlines():
|
|
92
|
+
l = l.strip()
|
|
93
|
+
if not l or l.startswith("CommandLine"):
|
|
94
|
+
continue
|
|
95
|
+
m = re.match(r"^(.*\S)\s+(\d+)$", l) # wmic 列序固定:命令行在前 pid 在后
|
|
96
|
+
if m and "msedge" in m.group(1):
|
|
97
|
+
lines.append((m.group(2), m.group(1)))
|
|
98
|
+
if lines:
|
|
99
|
+
return lines
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
try:
|
|
103
|
+
ps = None
|
|
104
|
+
for cand in ("powershell",
|
|
105
|
+
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"):
|
|
106
|
+
try:
|
|
107
|
+
out = _sp.check_output(
|
|
108
|
+
[cand, "-NoProfile", "-Command",
|
|
109
|
+
"Get-CimInstance Win32_Process -Filter \"Name='msedge.exe'\" | "
|
|
110
|
+
"ForEach-Object { \"$($_.ProcessId)|$($_.CommandLine)\" }"],
|
|
111
|
+
stderr=_sp.DEVNULL, timeout=30).decode("utf-8", "replace")
|
|
112
|
+
break
|
|
113
|
+
except OSError:
|
|
114
|
+
continue # PATH 没带 System32\WindowsPowerShell 的环境
|
|
115
|
+
lines = []
|
|
116
|
+
for l in (out or "").splitlines():
|
|
117
|
+
pid, _, cmd = l.strip().partition("|")
|
|
118
|
+
if pid.isdigit() and cmd:
|
|
119
|
+
lines.append((pid, cmd))
|
|
120
|
+
return lines
|
|
121
|
+
except Exception:
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
|
|
81
125
|
def _debug_ports_for_profile(user_data_dir):
|
|
82
126
|
"""扫描本机浏览器进程命令行,返回使用该 profile 的主实例的调试端口。
|
|
83
127
|
|
|
84
|
-
跨平台:Windows 走 wmic
|
|
128
|
+
跨平台:Windows 走 wmic→PowerShell CIM 回退,POSIX 走 ps。只认主进程
|
|
85
129
|
(--type= 子进程没有调试端口)。路径按小写+正反斜杠归一后比对。"""
|
|
86
130
|
import subprocess as _sp
|
|
87
131
|
try:
|
|
88
132
|
if sys.platform == "win32":
|
|
89
|
-
|
|
90
|
-
["wmic", "process", "where", "Name='msedge.exe'", "get", "CommandLine"],
|
|
91
|
-
stderr=_sp.DEVNULL, timeout=12).decode("utf-8", "replace")
|
|
92
|
-
lines = out.splitlines()
|
|
133
|
+
lines = _win_edge_proc_lines()
|
|
93
134
|
else:
|
|
94
135
|
out = _sp.check_output(["ps", "-axo", "command"], timeout=12).decode()
|
|
95
|
-
lines = [l for l in out.splitlines()
|
|
136
|
+
lines = [(None, l) for l in out.splitlines()
|
|
96
137
|
if "msedge" in l or "chrome" in l or "chromium" in l]
|
|
97
138
|
except Exception:
|
|
98
139
|
return []
|
|
99
140
|
key = str(user_data_dir).replace("\\", "/").strip("/").lower()
|
|
100
141
|
ports = []
|
|
101
|
-
for ln in lines:
|
|
142
|
+
for _pid, ln in lines:
|
|
102
143
|
ln = ln.strip()
|
|
103
144
|
if not ln or "--type=" in ln or "--remote-debugging-port=" not in ln:
|
|
104
145
|
continue
|
|
@@ -123,6 +164,10 @@ class Browser:
|
|
|
123
164
|
args = [self.exe,
|
|
124
165
|
"--remote-debugging-port=%d" % self.port,
|
|
125
166
|
"--user-data-dir=%s" % self.user_data_dir,
|
|
167
|
+
# 窗口几何必须钉死:真实鼠标事件按视口坐标派发,窗口被 Edge
|
|
168
|
+
# 恢复成怪尺寸(2026-09-28 实案 921×920 方窗)时全部点击系统性
|
|
169
|
+
# 打偏,建书 radio/弹层一个都点不中还步步报成功
|
|
170
|
+
"--window-size=1366,900",
|
|
126
171
|
"--no-first-run", "--no-default-browser-check",
|
|
127
172
|
"--hide-crash-restore-bubble"] # 崩溃恢复气泡压掉即够;
|
|
128
173
|
# --restore-last-session 是无值开关,
|
|
@@ -166,10 +211,11 @@ class Browser:
|
|
|
166
211
|
def _wait_ready(self, timeout):
|
|
167
212
|
deadline = time.time() + timeout
|
|
168
213
|
while time.time() < deadline:
|
|
169
|
-
if self.proc.poll() is not None:
|
|
170
|
-
return False # 二开 profile 时新进程会把参数转交老实例后退出
|
|
171
214
|
if port_alive(self.port, timeout=1.0):
|
|
172
215
|
return True
|
|
216
|
+
# Edge 147 起 launcher 进程常先退(真身 detached 续跑、调试端口
|
|
217
|
+
# 晚几秒才开)——见 proc 退出不能收兵,要把端口窗口等满;真没起
|
|
218
|
+
# 来时上面的接管扫描(_debug_ports_for_profile)还兜得住
|
|
173
219
|
time.sleep(0.4)
|
|
174
220
|
return False
|
|
175
221
|
|
|
@@ -177,12 +223,26 @@ class Browser:
|
|
|
177
223
|
return port_alive(self.port, timeout=1.5)
|
|
178
224
|
|
|
179
225
|
def close(self):
|
|
180
|
-
"""杀掉浏览器进程树。登录态在 profile 里,杀掉不丢。
|
|
226
|
+
"""杀掉浏览器进程树。登录态在 profile 里,杀掉不丢。
|
|
227
|
+
|
|
228
|
+
Edge 147 起 launcher 常先退、真身 detached 续跑——taskkill 只打到
|
|
229
|
+
已退出的 launcher 时,按 profile 再扫一遍主进程补刀,否则孤儿实例
|
|
230
|
+
锁着 profile,下次 launch 误报「已在运行的会话」。"""
|
|
181
231
|
pid = getattr(self, "proc", None) and self.proc.pid
|
|
182
232
|
try:
|
|
183
233
|
if sys.platform == "win32" and pid:
|
|
184
234
|
subprocess.call(["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
185
235
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
236
|
+
if self.proc.poll() is not None and self.user_data_dir:
|
|
237
|
+
key = str(self.user_data_dir).replace("\\", "/").strip("/").lower()
|
|
238
|
+
for opid, cmd in _win_edge_proc_lines():
|
|
239
|
+
if "--type=" in cmd:
|
|
240
|
+
continue
|
|
241
|
+
norm = cmd.replace("\\\\", "/").replace("\\", "/").lower()
|
|
242
|
+
if key in norm:
|
|
243
|
+
subprocess.call(
|
|
244
|
+
["taskkill", "/F", "/T", "/PID", str(opid)],
|
|
245
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
186
246
|
elif pid:
|
|
187
247
|
# spawn 时 start_new_session,pgid==pid,连渲染进程带杀(runner 同款);
|
|
188
248
|
# SIGTERM 单杀主进程在浏览器挂死时留残余子进程锁 profile
|
|
@@ -239,10 +299,27 @@ class Page:
|
|
|
239
299
|
self.send("Runtime.enable")
|
|
240
300
|
self.send("Page.enable")
|
|
241
301
|
self._ua_override()
|
|
302
|
+
self._check_viewport()
|
|
242
303
|
except BrowserError:
|
|
243
304
|
self.ws.close()
|
|
244
305
|
raise
|
|
245
306
|
|
|
307
|
+
def _check_viewport(self):
|
|
308
|
+
"""视口几何告警:接管老实例(attach)时窗口尺寸不受我们控制,怪几何
|
|
309
|
+
会让真实点击坐标打偏——只告警不改状态,硬防护在 real_click_text 的
|
|
310
|
+
elementFromPoint 守卫。"""
|
|
311
|
+
try:
|
|
312
|
+
m = self.send("Page.getLayoutMetrics", timeout=5.0) or {}
|
|
313
|
+
css = m.get("cssLayoutViewport") or m.get("layoutViewport") or {}
|
|
314
|
+
w = int(css.get("clientWidth") or 0)
|
|
315
|
+
h = int(css.get("clientHeight") or 0)
|
|
316
|
+
if w and (w < 1000 or h < 600):
|
|
317
|
+
print("[publish] 视口异常 %dx%d(期望 ≥1000x600):真实点击坐标"
|
|
318
|
+
"可能打偏,建议关掉该平台浏览器窗口后重连"
|
|
319
|
+
% (w, h), flush=True)
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
|
|
246
323
|
def _ua_override(self):
|
|
247
324
|
"""UA 伪装成标准 Chrome:Edge 尾巴(Edg/x.y)会被部分站点(如七猫
|
|
248
325
|
建书页)的浏览器检测判「版本过低」整页替换。用 CDP Browser.getVersion
|
|
@@ -471,7 +548,9 @@ class Page:
|
|
|
471
548
|
qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
|
|
472
549
|
el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
|
|
473
550
|
y_min/y_max 限定元素纵向范围(区分同名 select、限制弹层内点击)。
|
|
474
|
-
|
|
551
|
+
派发前 elementFromPoint 验证落点真是目标:遮罩/浮层/窗口几何漂移会让
|
|
552
|
+
坐标系统性打偏且步步报成功(2026-09-28 建书弹层没开实案)。
|
|
553
|
+
返回 {ok, tag?, via};找不到/被挡/出视口返回 {ok: False, err}。"""
|
|
475
554
|
r = self.call(
|
|
476
555
|
"(t,scope,c,y0,y1)=>{"
|
|
477
556
|
"const vis=e=>e.getBoundingClientRect().width>0;"
|
|
@@ -485,13 +564,29 @@ class Page:
|
|
|
485
564
|
"cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
|
|
486
565
|
"const el=cands[0];el.scrollIntoView({block:'center'});"
|
|
487
566
|
"const rc=el.getBoundingClientRect();"
|
|
488
|
-
"
|
|
567
|
+
"const x=Math.round(rc.x+rc.width/2),y=Math.round(rc.y+rc.height/2);"
|
|
568
|
+
"const hit=document.elementFromPoint(x,y);"
|
|
569
|
+
"if(!hit)return{ok:false,err:'oob'};"
|
|
570
|
+
"if(hit!==el&&!el.contains(hit)&&!hit.contains(el))"
|
|
571
|
+
"return{ok:false,err:'blocked',by:hit.tagName+' '+"
|
|
572
|
+
"((hit.className||'')+'').slice(0,40)};"
|
|
573
|
+
"return{ok:true,x:x,y:y,"
|
|
489
574
|
"tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
|
|
490
575
|
str(text), scope or "", bool(contains),
|
|
491
576
|
int(y_min) if y_min is not None else None,
|
|
492
577
|
int(y_max) if y_max is not None else None)
|
|
493
578
|
if not (r or {}).get("ok"):
|
|
494
|
-
|
|
579
|
+
err = (r or {}).get("err")
|
|
580
|
+
if err == "blocked":
|
|
581
|
+
return {"ok": False,
|
|
582
|
+
"err": "「%s」的落点被 %s 挡住(遮罩/浮层未退场)"
|
|
583
|
+
% (text, (r or {}).get("by") or "未知元素")}
|
|
584
|
+
if err == "oob":
|
|
585
|
+
return {"ok": False,
|
|
586
|
+
"err": "「%s」的落点在视口外——浏览器窗口几何异常,"
|
|
587
|
+
"关掉该平台浏览器窗口后重试" % text}
|
|
588
|
+
return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素%s"
|
|
589
|
+
% (text, self._click_diag(scope))}
|
|
495
590
|
x, y = r["x"], r["y"]
|
|
496
591
|
self.send("Input.dispatchMouseEvent",
|
|
497
592
|
{"type": "mousePressed", "x": x, "y": y,
|
|
@@ -501,6 +596,27 @@ class Page:
|
|
|
501
596
|
"button": "left", "clickCount": 1}, timeout=8.0)
|
|
502
597
|
return {"ok": True, "tag": r.get("tag"), "via": "input"}
|
|
503
598
|
|
|
599
|
+
def _click_diag(self, scope):
|
|
600
|
+
"""点击目标找不到时的现场自证:定位范围里到底有没有可见元素、都是
|
|
601
|
+
什么——「弹层没开」和「分类表变了」从此一眼可分,不用再猜。"""
|
|
602
|
+
try:
|
|
603
|
+
r = self.call(
|
|
604
|
+
"(scope)=>{const vis=e=>e.getBoundingClientRect().width>0;"
|
|
605
|
+
"const els=[...document.querySelectorAll(scope||"
|
|
606
|
+
"'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
|
|
607
|
+
"const ts=[...new Set(els.map(e=>(e.innerText||'').trim())"
|
|
608
|
+
".filter(x=>x&&x.length<=10))].slice(0,8);"
|
|
609
|
+
"return{n:els.length,ts:ts};}", scope or "")
|
|
610
|
+
n = int((r or {}).get("n") or 0)
|
|
611
|
+
ts = "/".join(str(t) for t in ((r or {}).get("ts") or []))
|
|
612
|
+
if not n:
|
|
613
|
+
return "(定位范围 %s 内无可见元素:目标容器多半没打开)" \
|
|
614
|
+
% (str(scope or "默认")[:48])
|
|
615
|
+
return "(定位范围 %s 内可见项:%s)" \
|
|
616
|
+
% (str(scope or "默认")[:48], ts[:120])
|
|
617
|
+
except Exception:
|
|
618
|
+
return ""
|
|
619
|
+
|
|
504
620
|
def screenshot(self, fp):
|
|
505
621
|
"""整页截图存证:发布每步之后落一张,出错可回看卡在哪一步。"""
|
|
506
622
|
r = self.send("Page.captureScreenshot", {"format": "png", "fromSurface": True})
|
|
@@ -19,6 +19,12 @@ CONFIG = {
|
|
|
19
19
|
"home": "https://fanqienovel.com/main/writer/", # 实测 writer. 子域不存在(DNS 000);快照实抓为主站路径
|
|
20
20
|
# 导航后 URL 含任一标记 → 未登录(跳到了登录/通行证页)
|
|
21
21
|
"login_url_marks": ["login", "passport", "sso", "account/signin"],
|
|
22
|
+
# 章节管理页计数(2026-09-28 实机校准):Arco 表格,数据行=含 ≥4 个 td 的 tr
|
|
23
|
+
# (表头行是 th,天然排除)。逐行 innerText 交 manager 按状态关键词分桶。
|
|
24
|
+
"count_rows_js":
|
|
25
|
+
"()=>[...document.querySelectorAll('tr')]"
|
|
26
|
+
".filter(tr => tr.querySelectorAll(':scope > td').length >= 4)"
|
|
27
|
+
".map(tr => tr.innerText.replace(/\\s+/g, ' ').trim()).filter(Boolean)",
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
# 建书:入口 → 弹层/页面 → 文本字段直填;分类/签约模式/标签走文本点击。
|
package/app/core/publish/flow.py
CHANGED
|
@@ -87,6 +87,24 @@ def _click_text_js():
|
|
|
87
87
|
"hit.scrollIntoView({block:'center'});hit.click();return{ok:true};}")
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def _checked_js():
|
|
91
|
+
# radio/checkbox 点击闭环:点击派发成功 ≠ 选中成功(遮罩/几何打偏会静默
|
|
92
|
+
# 吞掉,2026-09-28 建书两 radio 全空实案)。点完回头验 checked 态:
|
|
93
|
+
# arco 系 label 挂 checked 类、原生 input 挂 checked 属性,双信号任一即算
|
|
94
|
+
return ("(t,scope)=>{"
|
|
95
|
+
"const els=[...document.querySelectorAll("
|
|
96
|
+
"scope||'label,[class*=radio],[class*=checkbox]')]"
|
|
97
|
+
".filter(e=>e.getBoundingClientRect().width>0);"
|
|
98
|
+
"const el=els.find(e=>((e.innerText||'').trim()).includes(t));"
|
|
99
|
+
"if(!el)return{ok:false,err:'nf'};"
|
|
100
|
+
"const box=el.closest('[class*=radio],[class*=checkbox]')||el;"
|
|
101
|
+
"const inp=box.querySelector('input[type=radio],input[type=checkbox]')||"
|
|
102
|
+
"el.querySelector('input[type=radio],input[type=checkbox]');"
|
|
103
|
+
"const ck=(inp&&inp.checked)||/checked|active/.test(box.className||'')"
|
|
104
|
+
"||/checked|active/.test(el.className||'');"
|
|
105
|
+
"return{ok:!!ck};}")
|
|
106
|
+
|
|
107
|
+
|
|
90
108
|
def _page_error_text_js():
|
|
91
109
|
# 抓页面上可见的报错/提示(toast、表单校验消息):提交被平台静默拒绝
|
|
92
110
|
# 时 url_any 只看得到「没跳转」,把页面自己的话带回来才不用瞎猜
|
|
@@ -101,6 +119,98 @@ def _page_error_text_js():
|
|
|
101
119
|
"return out.slice(0,5).join(' | ');}")
|
|
102
120
|
|
|
103
121
|
|
|
122
|
+
def _tag_sections_js():
|
|
123
|
+
# 标签弹层分组读取(七猫形态):div.tags-wrap[data-type-id] 每组一块,
|
|
124
|
+
# 组名在 .tags-tit(带前导空格须 trim),芯片名 .tags-con-name,
|
|
125
|
+
# 选中态 .tags-con.tag-selected。返回 null = 弹层无此结构。
|
|
126
|
+
return ("()=>{const wraps=[...document.querySelectorAll('.tags-wrap')];"
|
|
127
|
+
"if(!wraps.length)return null;"
|
|
128
|
+
"return wraps.map(w=>({"
|
|
129
|
+
"g:(((w.querySelector('.tags-tit')||{}).innerText)||'').trim(),"
|
|
130
|
+
"sel:[...w.querySelectorAll('.tags-box .tags-con.tag-selected .tags-con-name')]"
|
|
131
|
+
".map(e=>(e.innerText||'').trim())}));}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _tag_click_js():
|
|
135
|
+
# 组内精确定位芯片再点。四组选项同屏渲染、芯片点击永远落在其所在
|
|
136
|
+
# 分组(左侧组名导航只管高亮不管归属),且组名导航/全弹层 contains
|
|
137
|
+
# 搜索在滚动竞态下会假成功——el.click() 打在滚动中的芯片上被平台吞
|
|
138
|
+
# 掉也报 ok(0928 实案:前 9 个全中、第 10 个「都市」假成功,背景组
|
|
139
|
+
# 空选,「确定」被平台以「每个类型下至少选择1个标签」打回)。
|
|
140
|
+
# err=nogroup(弹层没这组)单独返回,供上层区分「没等到弹层」与
|
|
141
|
+
# 「组里没这个标签」(后者=平台目录漂移,必须立刻报错而不是去别组碰)。
|
|
142
|
+
return ("(g,t)=>{for(const w of document.querySelectorAll('.tags-wrap')){"
|
|
143
|
+
"const gname=(((w.querySelector('.tags-tit')||{}).innerText)||'').trim();"
|
|
144
|
+
"if(gname!==g)continue;"
|
|
145
|
+
"const hit=[...w.querySelectorAll('.tags-box .tags-con-name')]"
|
|
146
|
+
".find(e=>((e.innerText||'').trim())===t);"
|
|
147
|
+
"if(!hit)return{ok:false,err:'组「'+g+'」下没有标签「'+t+'」'};"
|
|
148
|
+
"hit.scrollIntoView({block:'center'});hit.click();return{ok:true};}"
|
|
149
|
+
"return{ok:false,err:'nogroup'};}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _tags_select_grouped(page, note, i, pairs):
|
|
153
|
+
"""分组定位点选:逐项点+回读校验(点不中就重新定位重试),收尾全组
|
|
154
|
+
对账——多选的再点一次剔除(芯片是开关)、缺的补点,两轮仍不齐才报
|
|
155
|
+
错,错误带组名+差集,别让人对着平台的「每个类型下至少选择1个标签」
|
|
156
|
+
toast 瞎猜。"""
|
|
157
|
+
def read():
|
|
158
|
+
return page.call(_tag_sections_js()) or []
|
|
159
|
+
|
|
160
|
+
def one(g, t):
|
|
161
|
+
for _try in range(4):
|
|
162
|
+
r = page.call(_tag_click_js(), g, t)
|
|
163
|
+
err = (r or {}).get("err")
|
|
164
|
+
if err == "nogroup":
|
|
165
|
+
time.sleep(0.8) # 弹层中途重渲染:等它回来
|
|
166
|
+
continue
|
|
167
|
+
if err:
|
|
168
|
+
raise FlowError(err) # 组里没有这个标签=目录漂移,去别组碰只会点错
|
|
169
|
+
time.sleep(0.5)
|
|
170
|
+
for s in read():
|
|
171
|
+
if s["g"] == g:
|
|
172
|
+
if t in s["sel"]:
|
|
173
|
+
return True
|
|
174
|
+
break
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
for g, t in pairs:
|
|
178
|
+
if not one(g, t):
|
|
179
|
+
raise FlowError("标签「%s」未能选入「%s」组(该组或已选满3个)" % (t, g))
|
|
180
|
+
want = {}
|
|
181
|
+
for g, t in pairs:
|
|
182
|
+
want.setdefault(g, set()).add(t)
|
|
183
|
+
bad = []
|
|
184
|
+
for _round in range(2):
|
|
185
|
+
bad = []
|
|
186
|
+
for s in read():
|
|
187
|
+
w = want.get(s["g"])
|
|
188
|
+
if w is None:
|
|
189
|
+
continue # 没让点的组不动它
|
|
190
|
+
extra = sorted(set(s["sel"]) - w)
|
|
191
|
+
miss = sorted(w - set(s["sel"]))
|
|
192
|
+
if extra or miss:
|
|
193
|
+
parts = []
|
|
194
|
+
if extra:
|
|
195
|
+
parts.append("多选 " + "、".join(extra))
|
|
196
|
+
if miss:
|
|
197
|
+
parts.append("缺 " + "、".join(miss))
|
|
198
|
+
bad.append("「%s」组%s" % (s["g"], ";".join(parts)))
|
|
199
|
+
if not bad:
|
|
200
|
+
note(i, "标签对账通过(%d 项)" % len(pairs))
|
|
201
|
+
return
|
|
202
|
+
for s in read(): # 多选:再点一次取消
|
|
203
|
+
for x in set(s["sel"]) - want.get(s["g"], set()):
|
|
204
|
+
page.call(_tag_click_js(), s["g"], x)
|
|
205
|
+
time.sleep(0.4)
|
|
206
|
+
for g, t in pairs: # 缺选:补点
|
|
207
|
+
sel = next((x["sel"] for x in read() if x["g"] == g), [])
|
|
208
|
+
if t not in sel:
|
|
209
|
+
page.call(_tag_click_js(), g, t)
|
|
210
|
+
time.sleep(0.4)
|
|
211
|
+
raise FlowError("标签对账未过:%s" % ";".join(bad))
|
|
212
|
+
|
|
213
|
+
|
|
104
214
|
def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
105
215
|
shot=None, log=None):
|
|
106
216
|
"""跑一个流程。values:fill 取值字典;config:平台 URL 等占位符来源。
|
|
@@ -176,18 +286,31 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
176
286
|
continue
|
|
177
287
|
note(i, "真实点击「%s」" % text)
|
|
178
288
|
r = None
|
|
179
|
-
|
|
289
|
+
verified = not st.get("expect_checked")
|
|
290
|
+
tries = int(st.get("tries") or 25)
|
|
291
|
+
for _try in range(tries):
|
|
180
292
|
r = page.real_click_text(text, st.get("scope") or
|
|
181
293
|
"a,button,[class*=btn]",
|
|
182
294
|
y_min=st.get("y_min"),
|
|
183
295
|
y_max=st.get("y_max"))
|
|
184
296
|
if (r or {}).get("ok"):
|
|
185
|
-
|
|
297
|
+
if verified:
|
|
298
|
+
break
|
|
299
|
+
# 点击成功≠选中成功:回头验 checked 态,不中重点
|
|
300
|
+
v = page.call(_checked_js(), text,
|
|
301
|
+
st.get("scope") or "")
|
|
302
|
+
if (v or {}).get("ok"):
|
|
303
|
+
verified = True
|
|
304
|
+
break
|
|
305
|
+
note(i, "「%s」已点但未见选中态,重试" % text)
|
|
186
306
|
time.sleep(0.4)
|
|
187
|
-
if not (r or {}).get("ok"):
|
|
307
|
+
if not verified or not (r or {}).get("ok"):
|
|
188
308
|
if st.get("optional"):
|
|
189
309
|
note(i, "「%s」未出现,跳过(optional)" % text)
|
|
190
310
|
continue
|
|
311
|
+
if (r or {}).get("ok") and st.get("expect_checked"):
|
|
312
|
+
raise FlowError("「%s」连点 %d 次仍未见选中态"
|
|
313
|
+
"(radio 被遮罩/改版拦下)" % (text, tries))
|
|
191
314
|
raise FlowError((r or {}).get("err") or text)
|
|
192
315
|
elif act == "click_in":
|
|
193
316
|
# 先按 scope_text 定位容器(如书卡),再在容器内点 text 按钮。
|
|
@@ -297,13 +420,39 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
297
420
|
raise FlowError((r or {}).get("err") or "单选点击失败")
|
|
298
421
|
elif act == "tags":
|
|
299
422
|
# 标签弹层逐个点选:manager 把标签清单放 values["_tags"]。
|
|
300
|
-
# 项为 [组名, 标签]
|
|
301
|
-
#
|
|
423
|
+
# 项为 [组名, 标签] 且弹层呈现分组结构(.tags-wrap)时走
|
|
424
|
+
# 「分组定位」:芯片在**它所属组的区块内**精确定位、点完即
|
|
425
|
+
# 回读该组选中集校验——点不中就重新定位重试,绝不带病前进。
|
|
426
|
+
# 弹层无此结构或清单含无组名项时回退旧路径(整页 contains
|
|
427
|
+
# 搜索:组名切换 + 直接点标签,历史平台兜底)。
|
|
302
428
|
tags = values.get("_tags") or []
|
|
303
429
|
if not tags:
|
|
304
430
|
note(i, "无标签可点,跳过")
|
|
305
431
|
continue
|
|
306
432
|
scope = st.get("scope") or "span,li,label,[class*=dialog] *,[class*=popper] *"
|
|
433
|
+
pairs = [(str(it[0]).strip(), str(it[1]).strip()) for it in tags
|
|
434
|
+
if not isinstance(it, (str, int))
|
|
435
|
+
and str(it[0] or "").strip() and str(it[1] or "").strip()]
|
|
436
|
+
sections = None
|
|
437
|
+
if pairs and len(pairs) == len(tags):
|
|
438
|
+
for _w in range(8): # 等弹层渲染出分组结构
|
|
439
|
+
sections = page.call(_tag_sections_js())
|
|
440
|
+
if sections:
|
|
441
|
+
break
|
|
442
|
+
time.sleep(1.0)
|
|
443
|
+
if sections:
|
|
444
|
+
missing = {g for g, _ in pairs} - {s["g"] for s in sections}
|
|
445
|
+
if missing:
|
|
446
|
+
# 弹层是分组结构但组名对不上=平台目录/组名改了——
|
|
447
|
+
# 回退旧路径只会全局乱点(本次事故根源形态),当场报错
|
|
448
|
+
raise FlowError(
|
|
449
|
+
"标签弹层分组与预期不符:缺 %s(弹层现有组:%s);"
|
|
450
|
+
"请重生成作品信息后再试"
|
|
451
|
+
% ("、".join(sorted(missing)),
|
|
452
|
+
"、".join(s["g"] for s in sections)))
|
|
453
|
+
note(i, "点选标签 %d 项(分组定位)" % len(pairs))
|
|
454
|
+
_tags_select_grouped(page, note, i, pairs)
|
|
455
|
+
continue
|
|
307
456
|
note(i, "点选标签 %d 项" % len(tags))
|
|
308
457
|
for item in tags:
|
|
309
458
|
grp, tg = ("", str(item)) if isinstance(item, (str, int)) \
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_note": "番茄真机校准(2026-09-19 定稿,2026-09-24 加固):编辑器=/main/writer/<book_id>/publish/ 直达(章节号 input.serial-input 纯数字;标题 div.serial-editor-input-hint input ≥5字/30上限;正文 .ProseMirror ≥1000字;「下一步」保存校验后按钮组右缘箭头展开下拉含「定时发布」)。建书=/main/writer/create 直达表单:radio 签约模式/目标读者(label 真实点击)→ 阅读标签 select(y<430)弹层 arco-anchor 切主分类/主题/角色/情节 + 标签云点击 + 确认 → 内容标签 select(y>430)弹层 情感/人设/世界观 同构 → 名称/主角1/主角2/简介 → 立即创建(submit 步骤:auto_submit=false 时填好即停,留给人工提交) → 跳 book-info/<id>。锚点切换渲染慢(0924 三连败):弹层打开后 settle,锚点 tries=12。",
|
|
2
|
+
"_note": "番茄真机校准(2026-09-19 定稿,2026-09-24 加固):编辑器=/main/writer/<book_id>/publish/ 直达(章节号 input.serial-input 纯数字;标题 div.serial-editor-input-hint input ≥5字/30上限;正文 .ProseMirror ≥1000字;「下一步」保存校验后按钮组右缘箭头展开下拉含「定时发布」)。建书=/main/writer/create 直达表单:radio 签约模式/目标读者(label 真实点击)→ 阅读标签 select(y<430)弹层 arco-anchor 切主分类/主题/角色/情节 + 标签云点击 + 确认 → 内容标签 select(y>430)弹层 情感/人设/世界观 同构 → 名称/主角1/主角2/简介 → 立即创建(submit 步骤:auto_submit=false 时填好即停,留给人工提交) → 跳 book-info/<id>。锚点切换渲染慢(0924 三连败):弹层打开后 settle,锚点 tries=12。0928 再加固(弹层没开连败案):签约/读者 radio 点击后断言选中态(expect_checked);两处标签触发器点完 wait .category-modal 存在替代盲 sleep;real_click_text 落点 elementFromPoint 守卫,找不到目标时报错自带定位范围可见项诊断。",
|
|
3
3
|
"check_login": [
|
|
4
4
|
{
|
|
5
5
|
"do": "navigate",
|
|
@@ -139,13 +139,15 @@
|
|
|
139
139
|
"do": "click_real",
|
|
140
140
|
"text": "{signing_mode}",
|
|
141
141
|
"scope": "label.arco-radio,span.arco-radio,[class*=serial-radio]",
|
|
142
|
-
"tries": 8
|
|
142
|
+
"tries": 8,
|
|
143
|
+
"expect_checked": true
|
|
143
144
|
},
|
|
144
145
|
{
|
|
145
146
|
"do": "click_real",
|
|
146
147
|
"text": "{target_reader}",
|
|
147
148
|
"scope": "label.arco-radio,span.arco-radio,[class*=serial-radio]",
|
|
148
|
-
"tries": 8
|
|
149
|
+
"tries": 8,
|
|
150
|
+
"expect_checked": true
|
|
149
151
|
},
|
|
150
152
|
{
|
|
151
153
|
"do": "click_real",
|
|
@@ -162,8 +164,9 @@
|
|
|
162
164
|
"tries": 10
|
|
163
165
|
},
|
|
164
166
|
{
|
|
165
|
-
"do": "
|
|
166
|
-
"
|
|
167
|
+
"do": "wait",
|
|
168
|
+
"sel": ".category-modal",
|
|
169
|
+
"timeout": 15
|
|
167
170
|
},
|
|
168
171
|
{
|
|
169
172
|
"do": "click_real",
|
|
@@ -231,8 +234,9 @@
|
|
|
231
234
|
"tries": 15
|
|
232
235
|
},
|
|
233
236
|
{
|
|
234
|
-
"do": "
|
|
235
|
-
"
|
|
237
|
+
"do": "wait",
|
|
238
|
+
"sel": ".category-modal",
|
|
239
|
+
"timeout": 15
|
|
236
240
|
},
|
|
237
241
|
{
|
|
238
242
|
"do": "click_real",
|
|
@@ -117,16 +117,42 @@ def load_books():
|
|
|
117
117
|
|
|
118
118
|
|
|
119
119
|
def save_book(task_id, platform, info):
|
|
120
|
-
"""登记/更新任务在某平台的作品绑定。info: {book_id, title, url?}。
|
|
120
|
+
"""登记/更新任务在某平台的作品绑定。info: {book_id, title, url?}。
|
|
121
|
+
|
|
122
|
+
合并语义:只覆写登记四键,保留条目上的其他字段(如校准所得 remote_*),
|
|
123
|
+
否则发章链里找回 book_id 的一次 save_book 会把校准结果抹掉。"""
|
|
121
124
|
with LOCK:
|
|
122
125
|
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
123
126
|
books = load_books()
|
|
124
|
-
books.setdefault(str(task_id), {})
|
|
127
|
+
books.setdefault(str(task_id), {}).setdefault(str(platform), {}).update({
|
|
125
128
|
"book_id": str(info.get("book_id") or ""),
|
|
126
129
|
"title": str(info.get("title") or "")[:120],
|
|
127
130
|
"url": str(info.get("url") or "")[:300],
|
|
128
131
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
129
|
-
}
|
|
132
|
+
})
|
|
133
|
+
tmp = _BOOKS_FILE.with_suffix(".tmp")
|
|
134
|
+
tmp.write_text(json.dumps(books, ensure_ascii=False, indent=1),
|
|
135
|
+
encoding="utf-8")
|
|
136
|
+
tmp.replace(_BOOKS_FILE)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def update_book(task_id, platform, **fields):
|
|
140
|
+
"""原地合并更新登记条目(如平台校准数 remote_*),不碰 book_id/title 等既有键。
|
|
141
|
+
|
|
142
|
+
save_book 是整条覆写语义(建书/找回 id 用),校准字段走这里才不会被
|
|
143
|
+
下一次 save_book 冲掉。"""
|
|
144
|
+
if not fields:
|
|
145
|
+
return
|
|
146
|
+
with LOCK:
|
|
147
|
+
books = load_books()
|
|
148
|
+
ent = (books.setdefault(str(task_id), {}).setdefault(str(platform), {}))
|
|
149
|
+
if not ent:
|
|
150
|
+
return # 没登记过的书不凭空造条目
|
|
151
|
+
for k, v in fields.items():
|
|
152
|
+
if v is None:
|
|
153
|
+
ent.pop(k, None) # None=删键(换绑书时作废旧校准数)
|
|
154
|
+
else:
|
|
155
|
+
ent[k] = v
|
|
130
156
|
tmp = _BOOKS_FILE.with_suffix(".tmp")
|
|
131
157
|
tmp.write_text(json.dumps(books, ensure_ascii=False, indent=1),
|
|
132
158
|
encoding="utf-8")
|