codebee 0.1.96 → 0.1.98
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 +13 -0
- package/README.md +4 -2
- package/app/core/agent_context.py +21 -3
- package/app/core/board.py +39 -2
- package/app/core/builtin_agent.py +409 -34
- package/app/core/checkpoints.py +505 -0
- package/app/core/jobs.py +8 -1
- package/app/core/manager.py +109 -11
- package/app/core/mcp_client.py +200 -29
- package/app/core/pipeline.py +289 -40
- package/app/core/policy.py +36 -4
- package/app/core/project_memory.py +139 -0
- package/app/core/publish/auto.py +80 -19
- package/app/core/publish/browser.py +39 -0
- package/app/core/publish/fanqie.py +2 -0
- package/app/core/publish/flow.py +96 -17
- package/app/core/publish/flows-fanqie-calibrated.json +23 -11
- package/app/core/publish/ledger.py +38 -1
- package/app/core/runner.py +21 -11
- package/app/core/settings.py +13 -5
- package/app/core/settings_schema.py +14 -3
- package/app/core/store.py +21 -6
- package/app/core/tool_outputs.py +107 -0
- package/app/core/wxdigest.py +8 -1
- package/app/main.py +76 -2
- package/app/ui/app.js +356 -82
- package/app/ui/assets/workbench-reference.png +0 -0
- package/app/ui/board.css +451 -666
- package/app/ui/board.html +97 -94
- package/app/ui/board.js +316 -112
- package/app/ui/hive3d.js +242 -0
- package/app/ui/i18n.js +93 -10
- package/app/ui/index.html +31 -3
- package/app/ui/style.css +97 -111
- package/package.json +3 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Versioned, approval-gated project memory stored inside each workspace."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
MAX_ENTRY_CHARS = 4000
|
|
13
|
+
DEFAULT_TTL_DAYS = 180
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _file(workdir):
|
|
17
|
+
root = Path(workdir or "").expanduser().resolve(strict=True)
|
|
18
|
+
if not root.is_dir():
|
|
19
|
+
raise ValueError("工作目录不可用")
|
|
20
|
+
directory = root / ".codebee"
|
|
21
|
+
if directory.is_symlink() or (hasattr(directory, "is_junction") and directory.is_junction()):
|
|
22
|
+
raise ValueError("拒绝使用工作目录外的项目记忆路径")
|
|
23
|
+
if directory.exists() and not directory.is_dir():
|
|
24
|
+
raise ValueError("项目记忆目录路径不是目录")
|
|
25
|
+
resolved_dir = directory.resolve(strict=False)
|
|
26
|
+
if resolved_dir != root and root not in resolved_dir.parents:
|
|
27
|
+
raise ValueError("项目记忆目录超出工作目录")
|
|
28
|
+
target = directory / "project-memory.jsonl"
|
|
29
|
+
if target.is_symlink() or (hasattr(target, "is_junction") and target.is_junction()):
|
|
30
|
+
raise ValueError("拒绝符号链接项目记忆文件")
|
|
31
|
+
resolved_target = target.resolve(strict=False)
|
|
32
|
+
if resolved_target.parent != resolved_dir:
|
|
33
|
+
raise ValueError("项目记忆文件路径越界")
|
|
34
|
+
return target
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _append(path, row):
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
|
|
40
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
41
|
+
fd = os.open(str(path), flags, 0o600)
|
|
42
|
+
try:
|
|
43
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
44
|
+
raise ValueError("项目记忆路径不是普通文件")
|
|
45
|
+
payload = (json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
|
|
46
|
+
with os.fdopen(fd, "ab", closefd=False) as handle:
|
|
47
|
+
handle.write(payload)
|
|
48
|
+
handle.flush()
|
|
49
|
+
finally:
|
|
50
|
+
os.close(fd)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read(path):
|
|
54
|
+
latest = {}
|
|
55
|
+
try:
|
|
56
|
+
lines = path.read_text(encoding="utf-8").splitlines()[-5000:]
|
|
57
|
+
except OSError:
|
|
58
|
+
return latest
|
|
59
|
+
for line in lines:
|
|
60
|
+
try:
|
|
61
|
+
row = json.loads(line)
|
|
62
|
+
if isinstance(row, dict) and row.get("id"):
|
|
63
|
+
latest[row["id"]] = row
|
|
64
|
+
except (ValueError, TypeError):
|
|
65
|
+
continue
|
|
66
|
+
return latest
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def propose(workdir, *, source_run="", title="", facts=None, now=None):
|
|
70
|
+
"""Persist a candidate, never active until a human explicitly approves it."""
|
|
71
|
+
facts = [str(x).strip()[:500] for x in (facts or []) if str(x).strip()]
|
|
72
|
+
content = "\n".join("- " + x for x in facts)[:MAX_ENTRY_CHARS]
|
|
73
|
+
if not content:
|
|
74
|
+
return None
|
|
75
|
+
stamp = float(now if now is not None else time.time())
|
|
76
|
+
digest = hashlib.sha256((str(source_run) + "\0" + content).encode("utf-8")).hexdigest()
|
|
77
|
+
row = {"id": digest[:24], "source_run": str(source_run or "")[:100],
|
|
78
|
+
"title": str(title or "")[:200], "content": content,
|
|
79
|
+
"content_sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
80
|
+
"created_at": stamp, "expires_at": stamp + DEFAULT_TTL_DAYS * 86400,
|
|
81
|
+
"status": "pending", "version": 1, "updated_at": stamp}
|
|
82
|
+
path = _file(workdir)
|
|
83
|
+
latest = _read(path)
|
|
84
|
+
old = latest.get(row["id"])
|
|
85
|
+
if old:
|
|
86
|
+
return old
|
|
87
|
+
_append(path, row)
|
|
88
|
+
return row
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_entries(workdir, *, now=None):
|
|
92
|
+
stamp = float(now if now is not None else time.time())
|
|
93
|
+
rows = list(_read(_file(workdir)).values())
|
|
94
|
+
for row in rows:
|
|
95
|
+
raw = row.get("content") or ""
|
|
96
|
+
row["verified"] = hashlib.sha256(raw.encode("utf-8")).hexdigest() == row.get("content_sha256")
|
|
97
|
+
row["expired"] = float(row.get("expires_at") or 0) <= stamp
|
|
98
|
+
return sorted(rows, key=lambda x: (x.get("created_at", 0), x.get("id", "")), reverse=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def decide(workdir, entry_id, action, *, expected_version=None, now=None):
|
|
102
|
+
if action not in ("approve", "reject", "archive", "restore"):
|
|
103
|
+
raise ValueError("action 必须是 approve/reject/archive/restore")
|
|
104
|
+
path = _file(workdir)
|
|
105
|
+
latest = _read(path)
|
|
106
|
+
row = latest.get(str(entry_id or ""))
|
|
107
|
+
if not row:
|
|
108
|
+
raise KeyError("记忆条目不存在")
|
|
109
|
+
if expected_version is not None and int(expected_version) != int(row.get("version") or 1):
|
|
110
|
+
raise ValueError("记忆条目版本已变化,请刷新后重试")
|
|
111
|
+
stamp = float(now if now is not None else time.time())
|
|
112
|
+
raw = row.get("content") or ""
|
|
113
|
+
if hashlib.sha256(raw.encode("utf-8")).hexdigest() != row.get("content_sha256"):
|
|
114
|
+
raise ValueError("记忆内容指纹校验失败")
|
|
115
|
+
transitions = {"approve": "pending", "reject": "pending",
|
|
116
|
+
"archive": "approved", "restore": "archived"}
|
|
117
|
+
if row.get("status") != transitions[action]:
|
|
118
|
+
raise ValueError("当前记忆状态不允许此操作")
|
|
119
|
+
if action in ("approve", "restore") and float(row.get("expires_at") or 0) <= stamp:
|
|
120
|
+
raise ValueError("记忆已过期,不能启用;请重新生成候选")
|
|
121
|
+
row = dict(row, status={"approve": "approved", "reject": "rejected",
|
|
122
|
+
"archive": "archived", "restore": "approved"}[action],
|
|
123
|
+
version=int(row.get("version") or 1) + 1, updated_at=stamp,
|
|
124
|
+
reviewed_at=stamp)
|
|
125
|
+
_append(path, row)
|
|
126
|
+
return row
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def active_text(workdir, *, cap=4000, now=None):
|
|
130
|
+
stamp = float(now if now is not None else time.time())
|
|
131
|
+
items = []
|
|
132
|
+
for row in list_entries(workdir, now=stamp):
|
|
133
|
+
if row.get("status") != "approved" or row.get("expired") or not row.get("verified"):
|
|
134
|
+
continue
|
|
135
|
+
items.append("### %s\n%s" % (row.get("title") or "项目记忆", row.get("content") or ""))
|
|
136
|
+
text = "\n\n".join(items)
|
|
137
|
+
if len(text) > cap:
|
|
138
|
+
text = text[-cap:]
|
|
139
|
+
return ("## 已批准项目记忆(内容已校验,过期条目不注入)\n\n" + text + "\n\n") if text else ""
|
package/app/core/publish/auto.py
CHANGED
|
@@ -17,6 +17,7 @@ auto 管「一批章节」——枚举任务成品里的章节文件,减去台
|
|
|
17
17
|
"""
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import os
|
|
20
21
|
import threading
|
|
21
22
|
import time
|
|
22
23
|
|
|
@@ -31,6 +32,13 @@ _running = {} # task_id → {platform, at, done, total, status, e
|
|
|
31
32
|
_LOCK = threading.Lock()
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def _isdir(p):
|
|
36
|
+
try:
|
|
37
|
+
return os.path.isdir(p)
|
|
38
|
+
except (OSError, ValueError):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
34
42
|
def _book_ready(info):
|
|
35
43
|
return bool(info and (info.get("book_id") or info.get("source") == "manual"))
|
|
36
44
|
|
|
@@ -49,7 +57,7 @@ def daily_cap():
|
|
|
49
57
|
v = int(_settings().get("publish_daily_cap") or _CAP_DEFAULT)
|
|
50
58
|
except (TypeError, ValueError):
|
|
51
59
|
v = _CAP_DEFAULT
|
|
52
|
-
return max(
|
|
60
|
+
return max(0, min(50, v))
|
|
53
61
|
|
|
54
62
|
|
|
55
63
|
def fail_streak():
|
|
@@ -61,21 +69,32 @@ def fail_streak():
|
|
|
61
69
|
|
|
62
70
|
|
|
63
71
|
def guards(task_id, platform):
|
|
64
|
-
"""两道护栏:每日上限 + 连败退避。返回 (ok, 人话原因)。
|
|
72
|
+
"""两道护栏:每日上限 + 连败退避。返回 (ok, 人话原因)。
|
|
73
|
+
|
|
74
|
+
publish_daily_cap=0 视为不限制(2026-10-08 用户要求关闭每日上限)。"""
|
|
65
75
|
from . import ledger
|
|
66
|
-
used = ledger.today_count(task_id, platform)
|
|
67
76
|
cap = daily_cap()
|
|
68
|
-
if
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
if cap > 0:
|
|
78
|
+
used = ledger.today_count(task_id, platform)
|
|
79
|
+
if used >= cap:
|
|
80
|
+
return False, ("今日已发 %d 章(上限 %d),为防风控明天再发;"
|
|
81
|
+
"确需多发请在设置调 publish_daily_cap(0=不限制)"
|
|
82
|
+
% (used, cap))
|
|
71
83
|
streak = ledger.consecutive_failures(platform)
|
|
72
84
|
limit = fail_streak()
|
|
73
85
|
if streak >= limit:
|
|
74
|
-
return False, ("
|
|
75
|
-
"
|
|
86
|
+
return False, ("平台近 %s 内连续 %d 次发布失败(疑似风控或改版),自动发布"
|
|
87
|
+
"已暂停;代码问题修复后护栏自动解除,也可单章「发一章」"
|
|
88
|
+
"恢复" % (_fail_window_text(), streak))
|
|
76
89
|
return True, ""
|
|
77
90
|
|
|
78
91
|
|
|
92
|
+
def _fail_window_text():
|
|
93
|
+
from . import ledger
|
|
94
|
+
h = ledger.fail_window_h()
|
|
95
|
+
return ("%g 小时" % h) if h < 24 else ("%g 天" % (h / 24))
|
|
96
|
+
|
|
97
|
+
|
|
79
98
|
# ---------------------------------------------------------------- 待发枚举
|
|
80
99
|
# 文档目录里的编号文件不是章节稿:章纲/大纲按章编号(第85章-xxx.md、
|
|
81
100
|
# vol-1-ch-16.md),全按章号算会把待发清单撑出一堆「假章」——批量选择
|
|
@@ -83,27 +102,67 @@ def guards(task_id, platform):
|
|
|
83
102
|
# 个章号,其中 34 个来自 章纲/已成稿/作废稿 目录)。
|
|
84
103
|
_DOC_DIR_MARKS = ("章纲/", "大纲/", "设定/", "参考资料/", "已成稿/",
|
|
85
104
|
"作废稿", "docs/", "outline/")
|
|
105
|
+
_WALK_PRUNE = {"node_modules", "target", "build", "dist", "__pycache__"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _workdir_chapter_files(wd):
|
|
109
|
+
"""任务工作目录的**全量**章节稿(不看时间窗,2026-10-08 实案)。
|
|
110
|
+
|
|
111
|
+
run 成品口径的时间窗起点是「各自任务的首跑」——续写链上每个批次任务
|
|
112
|
+
只能看到自己出生以后新写的章节:今天的批次任务待发只剩 73-78,昨天
|
|
113
|
+
的 65-78,根任务才有全量 31-78。发布待发的正确口径是「工作目录里
|
|
114
|
+
还没发过的章节稿」,与哪个任务/何时跑无关,所以这里直接全树扫。"""
|
|
115
|
+
import os as _os
|
|
116
|
+
out = []
|
|
117
|
+
for dirpath, dirnames, filenames in _os.walk(wd):
|
|
118
|
+
dirnames[:] = [d for d in dirnames
|
|
119
|
+
if not d.startswith(".") and d not in _WALK_PRUNE]
|
|
120
|
+
for name in filenames:
|
|
121
|
+
if not name.lower().endswith((".md", ".txt")):
|
|
122
|
+
continue
|
|
123
|
+
fp = _os.path.join(dirpath, name)
|
|
124
|
+
rel = _os.path.relpath(fp, wd).replace("\\", "/")
|
|
125
|
+
if rel.startswith(_DOC_DIR_MARKS):
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
out.append({"name": rel, "size": _os.path.getsize(fp)})
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
return out
|
|
86
132
|
|
|
87
133
|
|
|
88
134
|
def pending(task_id, platform):
|
|
89
|
-
"""
|
|
135
|
+
"""待发章节清单:任务工作目录里的章节文件 − 已发章号,按章号升序。
|
|
90
136
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
137
|
+
已发口径连载链合并(含校准所得平台实况),所以任何一个批次任务上发
|
|
138
|
+
起发布,看到的都是整本书的待发清单。返回 (list, err);list 项
|
|
139
|
+
{chapter_no, file, size},file 为工作目录相对路径。章号解析不出的
|
|
140
|
+
文件不进自动发布(防同章多文件误发),API 单章发(manager 直调)
|
|
141
|
+
不受此限。
|
|
94
142
|
"""
|
|
95
143
|
from .. import store
|
|
96
144
|
from . import ledger
|
|
97
145
|
task = store.get_task(task_id)
|
|
98
146
|
if not task:
|
|
99
147
|
return [], "任务不存在"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
148
|
+
wd = str(task.get("workdir") or "").strip()
|
|
149
|
+
files = _workdir_chapter_files(wd) if wd and _isdir(wd) else []
|
|
150
|
+
if not files:
|
|
151
|
+
# workdir 缺失/异常:退回 run 成品口径(最新 run 优先)
|
|
152
|
+
for r in store.task_runs(task_id):
|
|
153
|
+
_wd, fs = store.run_artifacts(r.get("id") or "", limit=800)
|
|
154
|
+
if fs:
|
|
155
|
+
files = fs # 任一 run 的成品口径都从任务首跑起,取到即够
|
|
156
|
+
break
|
|
106
157
|
done = ledger.published_chapters(task_id, platform)
|
|
158
|
+
# 分卷标注(2026-10-08 用户需求:待发清单按卷展示):卷计划解析不出时
|
|
159
|
+
# volume 为空串,前端不分组平铺。
|
|
160
|
+
plan = []
|
|
161
|
+
try:
|
|
162
|
+
from . import volumes as _volumes
|
|
163
|
+
plan = _volumes.load_plan(wd)
|
|
164
|
+
except Exception:
|
|
165
|
+
plan = []
|
|
107
166
|
out, seen = [], set()
|
|
108
167
|
for f in files:
|
|
109
168
|
name = str(f.get("name") or "")
|
|
@@ -115,7 +174,9 @@ def pending(task_id, platform):
|
|
|
115
174
|
if n <= 0 or n in done or n in seen:
|
|
116
175
|
continue
|
|
117
176
|
seen.add(n)
|
|
118
|
-
|
|
177
|
+
vol = _volumes.volume_for(n, plan) if plan else None
|
|
178
|
+
out.append({"chapter_no": n, "file": name, "size": f.get("size") or 0,
|
|
179
|
+
"volume": (vol.get("name") or "") if vol else ""})
|
|
119
180
|
out.sort(key=lambda x: x["chapter_no"])
|
|
120
181
|
return out, ""
|
|
121
182
|
|
|
@@ -294,6 +294,8 @@ class Page:
|
|
|
294
294
|
except (WebSocketError, OSError) as e:
|
|
295
295
|
raise BrowserError("连接页面失败:%s" % e)
|
|
296
296
|
self._id = 0
|
|
297
|
+
self._port = int(m.group(2))
|
|
298
|
+
self._target_id = m.group(3).rsplit("/", 1)[-1]
|
|
297
299
|
self._events = []
|
|
298
300
|
try:
|
|
299
301
|
self.send("Runtime.enable")
|
|
@@ -335,6 +337,43 @@ class Page:
|
|
|
335
337
|
except BrowserError:
|
|
336
338
|
pass
|
|
337
339
|
|
|
340
|
+
# ------------------------------------------------------------ 页签
|
|
341
|
+
def neutralize_dialogs(self):
|
|
342
|
+
"""灭掉页面级 confirm/alert/prompt:原生弹窗会挂起渲染主线程,无人
|
|
343
|
+
值守流程没有人点「确定」(2026-10-08 番茄实案:编辑器加载/发布确认
|
|
344
|
+
阶段整页卡死,CDP 全线超时)。对当前文档立即生效,并注册到本页签
|
|
345
|
+
之后加载的所有新文档(addScriptToEvaluateOnNewDocument 在平台 JS
|
|
346
|
+
之前跑,加载期弹窗一并覆盖)。"""
|
|
347
|
+
stub = ("window.confirm=function(){return true;};window.alert=function(){};"
|
|
348
|
+
"window.prompt=function(){return null;};")
|
|
349
|
+
try:
|
|
350
|
+
self.send("Page.addScriptToEvaluateOnNewDocument",
|
|
351
|
+
{"source": stub}, timeout=5.0)
|
|
352
|
+
except BrowserError:
|
|
353
|
+
pass
|
|
354
|
+
try:
|
|
355
|
+
self.evaluate(stub, timeout=5.0)
|
|
356
|
+
except BrowserError:
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
def open_new_tab(self, url="about:blank"):
|
|
360
|
+
"""开新页签并返回新 Page(本页签原地不动)。
|
|
361
|
+
|
|
362
|
+
发布验证必须走这里:在编辑器页签里导航去管理页会触发 beforeunload
|
|
363
|
+
原生确认——渲染主线程挂起、导航超时、进行中的发布请求一并被取消
|
|
364
|
+
(2026-10-08 番茄实案:直发章全卡死在验证导航上)。"""
|
|
365
|
+
t = _http_json("http://127.0.0.1:%d/json/new?%s" % (self._port, url),
|
|
366
|
+
method="PUT")
|
|
367
|
+
return Page(t["webSocketDebuggerUrl"])
|
|
368
|
+
|
|
369
|
+
def close_tab(self):
|
|
370
|
+
"""关掉自己(target 级浏览器进程命令,渲染器卡死也关得掉)。"""
|
|
371
|
+
try:
|
|
372
|
+
_http_json("http://127.0.0.1:%d/json/close/%s"
|
|
373
|
+
% (self._port, self._target_id), timeout=8)
|
|
374
|
+
except Exception:
|
|
375
|
+
pass # 响应体是纯文本「Target is closing」非 JSON
|
|
376
|
+
|
|
338
377
|
# ------------------------------------------------------------ CDP 协议
|
|
339
378
|
def send(self, method, params=None, timeout=30.0):
|
|
340
379
|
"""发一条命令并等它的响应;期间到达的事件进缓存(wait_event 消费)。"""
|
|
@@ -106,6 +106,8 @@ UPLOAD_CHAPTER = [
|
|
|
106
106
|
{"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
|
|
107
107
|
{"do": "shot", "name": "chapter-filled"},
|
|
108
108
|
{"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
|
|
109
|
+
{"do": "js_click", "text": "提交", "scope": "button", "tries": 20,
|
|
110
|
+
"optional": True},
|
|
109
111
|
]
|
|
110
112
|
|
|
111
113
|
# 登录态探测:打开后台首页,URL 被踢到登录页 → 未登录
|
package/app/core/publish/flow.py
CHANGED
|
@@ -268,6 +268,9 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
268
268
|
values = values or {}
|
|
269
269
|
config = config or {}
|
|
270
270
|
log = log or (lambda s: None)
|
|
271
|
+
# 原生弹窗灭活(confirm/alert/prompt):无人值守流程没人点「确定」,
|
|
272
|
+
# 原生弹窗会挂起渲染主线程让后续 CDP 全线超时(2026-10-08 实案)
|
|
273
|
+
getattr(page, "neutralize_dialogs", lambda: None)()
|
|
271
274
|
|
|
272
275
|
def note(i, s):
|
|
273
276
|
log("步骤%d %s" % (i, s))
|
|
@@ -363,6 +366,9 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
363
366
|
elif act == "click_in":
|
|
364
367
|
# 先按 scope_text 定位容器(如书卡),再在容器内点 text 按钮。
|
|
365
368
|
# 解决「按钮与书名同卡片但不在彼此祖先链」的组合定位。
|
|
369
|
+
# real=true:容器内定位后走 CDP 真实鼠标(2026-10-08 番茄发布
|
|
370
|
+
# 提示弹窗实案——el.click() 点「提交」不触发发布请求,平台只认
|
|
371
|
+
# 真实输入事件);tries 可配(默认 10,弹窗要等云端保存)。
|
|
366
372
|
scope_t = str(st.get("scope_text") or "")
|
|
367
373
|
text = str(st.get("text") or "")
|
|
368
374
|
for k, v in (values or {}).items():
|
|
@@ -370,9 +376,9 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
370
376
|
text = text.replace("{%s}" % k, str(v))
|
|
371
377
|
note(i, "在含「%s」的卡片内点「%s」" % (scope_t, text))
|
|
372
378
|
r = None
|
|
373
|
-
for _try in range(10):
|
|
379
|
+
for _try in range(int(st.get("tries") or 10)):
|
|
374
380
|
r = page.call(
|
|
375
|
-
"(sc,t)=>{"
|
|
381
|
+
"(sc,t,real)=>{"
|
|
376
382
|
"const cards=[...document.querySelectorAll('div,li,section,tr')].filter(e=>{"
|
|
377
383
|
"const x=(e.innerText||'').trim();"
|
|
378
384
|
"return x.includes(sc)&&x.length<600&&e.getBoundingClientRect().width>0;});"
|
|
@@ -385,13 +391,27 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
385
391
|
"btns.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
|
|
386
392
|
"let el=btns[0];"
|
|
387
393
|
"const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
|
|
388
|
-
"act.scrollIntoView({block:'center'});
|
|
389
|
-
"
|
|
394
|
+
"act.scrollIntoView({block:'center'});"
|
|
395
|
+
"if(real){const rc=act.getBoundingClientRect();"
|
|
396
|
+
" const px=document.elementFromPoint(Math.round(rc.x+rc.width/2),Math.round(rc.y+rc.height/2));"
|
|
397
|
+
" if(!px||(!act.contains(px)&&!px.contains(act)))return{ok:false,err:'blocked'};"
|
|
398
|
+
" return{ok:true,x:Math.round(rc.x+rc.width/2),y:Math.round(rc.y+rc.height/2)};}"
|
|
399
|
+
"act.click();"
|
|
400
|
+
"return{ok:true};}", scope_t, text, bool(st.get("real")))
|
|
390
401
|
if (r or {}).get("ok"):
|
|
391
402
|
break
|
|
392
403
|
time.sleep(0.9)
|
|
393
404
|
if not (r or {}).get("ok"):
|
|
405
|
+
if st.get("optional"):
|
|
406
|
+
note(i, "含「%s」的卡片未出现,跳过(optional)" % scope_t)
|
|
407
|
+
continue
|
|
394
408
|
raise FlowError((r or {}).get("err") or "click_in 失败")
|
|
409
|
+
if st.get("real") and isinstance(r, dict) and "x" in r:
|
|
410
|
+
for _t in ("mousePressed", "mouseReleased"):
|
|
411
|
+
page.send("Input.dispatchMouseEvent",
|
|
412
|
+
{"type": _t, "x": r["x"], "y": r["y"],
|
|
413
|
+
"button": "left", "clickCount": 1}, timeout=8)
|
|
414
|
+
note(i, "真实鼠标点「%s」@(%d,%d)" % (text, r["x"], r["y"]))
|
|
395
415
|
elif act == "click_arrow":
|
|
396
416
|
# 展开下拉按钮组(番茄「下一步▾」):点目标按钮组右缘 10px
|
|
397
417
|
note(i, "展开下拉箭头")
|
|
@@ -543,6 +563,13 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
543
563
|
if shot:
|
|
544
564
|
shot("ready-manual-submit")
|
|
545
565
|
return i + 1
|
|
566
|
+
if st.get("gate"):
|
|
567
|
+
# 闸门型 submit:只做 manual 模式的停点,auto 模式不点击——
|
|
568
|
+
# 动作交给后续步骤(如 js_click)单次执行。真实鼠标+JS 双击
|
|
569
|
+
# 「下一步」会打断平台进行中的云端保存,弹窗永远不出现
|
|
570
|
+
# (2026-10-08 深夜实案)。
|
|
571
|
+
note(i, "跳过点击(gate:动作由后续步骤单次执行)")
|
|
572
|
+
continue
|
|
546
573
|
if dup_check:
|
|
547
574
|
# 事前预检:填表阶段平台实时校验(fill 派发过 change 事件,
|
|
548
575
|
# blur/输入即触发)喊了重名就别点了,点了也必被拒
|
|
@@ -587,26 +614,78 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
587
614
|
else:
|
|
588
615
|
raise FlowError("等待 %s 消失超时" % sel)
|
|
589
616
|
note(i, "已消失")
|
|
617
|
+
elif act == "js_click":
|
|
618
|
+
# JS el.click():headless/后台页签下 CDP 真实鼠标事件不触发
|
|
619
|
+
# 平台处理器(番茄编辑器实测:real mouse 点「下一步」无响应,
|
|
620
|
+
# DOM click 反而可靠),两路并存互为兜底。
|
|
621
|
+
text = str(st.get("text") or "")
|
|
622
|
+
for k, v in (values or {}).items():
|
|
623
|
+
text = text.replace("{%s}" % k, str(v))
|
|
624
|
+
if not text:
|
|
625
|
+
continue
|
|
626
|
+
scope = str(st.get("scope") or "button")
|
|
627
|
+
contains = bool(st.get("contains", True))
|
|
628
|
+
note(i, "JS点击「%s」" % text)
|
|
629
|
+
r = None
|
|
630
|
+
for _try in range(int(st.get("tries") or 10)):
|
|
631
|
+
r = page.call(
|
|
632
|
+
"(t,scope,c,gm)=>{"
|
|
633
|
+
"if(gm){var g=[...document.querySelectorAll('[class*=modal]')]"
|
|
634
|
+
".find(e=>e.getBoundingClientRect().width>0);"
|
|
635
|
+
"if(g)return{ok:false,err:'modal-open'};}"
|
|
636
|
+
"const vis=e=>e.getBoundingClientRect().width>0;"
|
|
637
|
+
"const els=[...document.querySelectorAll(scope||'button')].filter(vis);"
|
|
638
|
+
"let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
|
|
639
|
+
"return x&&(c?x.includes(t):x===t);});"
|
|
640
|
+
"if(!cands.length)return{ok:false,err:'nf'};"
|
|
641
|
+
"cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
|
|
642
|
+
"cands[0].click();return{ok:true};}",
|
|
643
|
+
text, scope, contains, bool(st.get("skip_if_modal")), timeout=8)
|
|
644
|
+
if (r or {}).get("ok"):
|
|
645
|
+
break
|
|
646
|
+
if (r or {}).get("err") == "modal-open":
|
|
647
|
+
note(i, "弹窗已开(真实点击已生效),跳过 JS点击「%s」" % text)
|
|
648
|
+
break
|
|
649
|
+
time.sleep(0.6)
|
|
650
|
+
if not (r or {}).get("ok"):
|
|
651
|
+
if st.get("optional"):
|
|
652
|
+
note(i, "「%s」未出现,跳过(optional)" % text)
|
|
653
|
+
continue
|
|
654
|
+
raise FlowError("JS点击「%s」失败" % text)
|
|
590
655
|
elif act == "verify":
|
|
591
656
|
# 上线验证:导航到验证页断言文本在场——防「流程完成但平台
|
|
592
657
|
# 静默未发布」的假成功(0 字草稿案)。url/any 支持 values 占位。
|
|
658
|
+
# new_tab=true 时开新页签验证、本页签原地不动:在编辑器页签
|
|
659
|
+
# 里导航会触发 beforeunload 原生确认(渲染挂起+发布请求被
|
|
660
|
+
# 取消,2026-10-08 实案),编辑器流程一律用新页签。
|
|
593
661
|
url = str(st.get("url") or "")
|
|
594
662
|
for k, v in (values or {}).items():
|
|
595
663
|
url = url.replace("{%s}" % k, str(v))
|
|
596
664
|
note(i, "验证 %s" % url[:60])
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
665
|
+
settle = float(st.get("settle") or 4)
|
|
666
|
+
new_tab = bool(st.get("new_tab"))
|
|
667
|
+
if new_tab:
|
|
668
|
+
vpage = page.open_new_tab(url)
|
|
669
|
+
time.sleep(settle)
|
|
670
|
+
else:
|
|
671
|
+
page.navigate(url, timeout=30)
|
|
672
|
+
time.sleep(settle)
|
|
673
|
+
vpage = page
|
|
674
|
+
try:
|
|
675
|
+
marks = [str(m) for m in (st.get("any") or [])]
|
|
676
|
+
body_txt = str(vpage.call(
|
|
677
|
+
"()=>(document.body.innerText||'')", timeout=15) or "")
|
|
678
|
+
for m in marks:
|
|
679
|
+
mk = m
|
|
680
|
+
for k, v in (values or {}).items():
|
|
681
|
+
mk = mk.replace("{%s}" % k, str(v))
|
|
682
|
+
if mk not in body_txt:
|
|
683
|
+
raise FlowError("上线验证失败:%s 页面上没有「%s」(章节未真正发布)"
|
|
684
|
+
% (url[:60], mk[:40]))
|
|
685
|
+
note(i, "验证通过")
|
|
686
|
+
finally:
|
|
687
|
+
if new_tab:
|
|
688
|
+
vpage.close_tab()
|
|
610
689
|
elif act == "volume":
|
|
611
690
|
# 发章带卷(2026-10-08 批量自动发布·自动分卷):确保目标卷
|
|
612
691
|
# 在平台上存在(缺则走「新建分卷」)。平台新章按卷自动归类、
|
|
@@ -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。0928 再加固(弹层没开连败案):签约/读者 radio 点击后断言选中态(expect_checked);两处标签触发器点完 wait .category-modal 存在替代盲 sleep;real_click_text 落点 elementFromPoint 守卫,找不到目标时报错自带定位范围可见项诊断。2026-10-08 发章三修:values 补 chapter_no/chapter_name 键(此前序号框从未被填、被平台以「章节序号只支持阿拉伯数字」打回);标题位改填 chapter_name(read_chapter 拆掉「第X章」前缀,序号/标题分开填);verify 标记改「第{chapter_no}章」或「{chapter_name}」任一命中(用户手工改过标题时按名对版必假失败)。。2026-10-08 批量自动发布·自动分卷:upload_chapter 增加 volume 步骤(values.volume_name 由 manager 按工作目录卷计划解析:分卷.json 优先,其次 大纲/series-outline.md 的「## 第X卷:名」+「依次48、56…章」;平台卷弹窗 = .publish-header-volume-name 开 → .editor-volume-list-item 按去前缀名匹配选中,缺卷走 .editor-volume-footer-add-volume 新建→行内 input 输名→i.tomato-confirm 落名→选中→确定;新建卷名会追加进 values._volumes 由 manager 回写 books.json
|
|
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 守卫,找不到目标时报错自带定位范围可见项诊断。2026-10-08 发章三修:values 补 chapter_no/chapter_name 键(此前序号框从未被填、被平台以「章节序号只支持阿拉伯数字」打回);标题位改填 chapter_name(read_chapter 拆掉「第X章」前缀,序号/标题分开填);verify 标记改「第{chapter_no}章」或「{chapter_name}」任一命中(用户手工改过标题时按名对版必假失败)。。2026-10-08 批量自动发布·自动分卷:upload_chapter 增加 volume 步骤(values.volume_name 由 manager 按工作目录卷计划解析:分卷.json 优先,其次 大纲/series-outline.md 的「## 第X卷:名」+「依次48、56…章」;平台卷弹窗 = .publish-header-volume-name 开 → .editor-volume-list-item 按去前缀名匹配选中,缺卷走 .editor-volume-footer-add-volume 新建→行内 input 输名→i.tomato-confirm 落名→选中→确定;新建卷名会追加进 values._volumes 由 manager 回写 books.json 缓存)。。2026-10-08 晚:平台发布确认 UI 已改——下一步(保存校验)后弹「发布提示」错别字确认框(取消/提交),不再有「定时发布」下拉组;流程撤 click_arrow/定时发布/发布,改点确认框「提交」(optional:无错别字时可能不弹,直接 verify)。。verify 改 new_tab(开新页签验证、编辑器页签不动):编辑器页签内导航触发 beforeunload 原生确认,渲染挂起+发布请求被取消——直发章全卡死在验证导航上的实案修法。。1008深夜终版:publish链=submit(下一步,gate)→js_click下一步兜底(skip_if_modal)→js_click仅基础检测(检测方式弹窗,tries60)→sleep5→js_click提交(错别字确认,tries75)→verify new_tab;真实鼠标在 headless/后台页签不触发平台处理器,DOM click 可靠。",
|
|
3
3
|
"check_login": [
|
|
4
4
|
{
|
|
5
5
|
"do": "navigate",
|
|
@@ -74,20 +74,31 @@
|
|
|
74
74
|
"tries": 15
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
|
-
"do": "
|
|
78
|
-
"
|
|
77
|
+
"do": "js_click",
|
|
78
|
+
"text": "下一步",
|
|
79
|
+
"scope": "button",
|
|
80
|
+
"tries": 10,
|
|
81
|
+
"optional": true,
|
|
82
|
+
"skip_if_modal": true
|
|
79
83
|
},
|
|
80
84
|
{
|
|
81
|
-
"do": "
|
|
82
|
-
"
|
|
83
|
-
"
|
|
85
|
+
"do": "click_in",
|
|
86
|
+
"scope_text": "请选择内容检测方式",
|
|
87
|
+
"text": "仅基础检测",
|
|
88
|
+
"real": true,
|
|
89
|
+
"tries": 20,
|
|
84
90
|
"optional": true
|
|
85
91
|
},
|
|
86
92
|
{
|
|
87
|
-
"do": "
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
93
|
+
"do": "click_in",
|
|
94
|
+
"scope_text": "发布提示",
|
|
95
|
+
"text": "提交",
|
|
96
|
+
"real": true,
|
|
97
|
+
"tries": 60
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"do": "sleep",
|
|
101
|
+
"s": 3
|
|
91
102
|
},
|
|
92
103
|
{
|
|
93
104
|
"do": "verify",
|
|
@@ -96,7 +107,8 @@
|
|
|
96
107
|
"第{chapter_no}章",
|
|
97
108
|
"{chapter_name}"
|
|
98
109
|
],
|
|
99
|
-
"settle": 6
|
|
110
|
+
"settle": 6,
|
|
111
|
+
"new_tab": true
|
|
100
112
|
}
|
|
101
113
|
],
|
|
102
114
|
"probe_form": [
|
|
@@ -301,18 +301,55 @@ def today_count(task_id, platform):
|
|
|
301
301
|
and r.get("day") == day)
|
|
302
302
|
|
|
303
303
|
|
|
304
|
+
def fail_window_h():
|
|
305
|
+
"""连败统计时间窗(小时,1-168,默认 12)。
|
|
306
|
+
|
|
307
|
+
「连续失败」只数窗口内的:失败原因若是代码 bug(2026-10-08 番茄序号空
|
|
308
|
+
实案,修好即愈),修好后的新发布不该被 9 天攒下的 17 连败永久卡死。"""
|
|
309
|
+
from .. import settings
|
|
310
|
+
try:
|
|
311
|
+
v = float((settings.load() or {}).get("publish_fail_window_h") or 12)
|
|
312
|
+
except Exception:
|
|
313
|
+
return 12.0
|
|
314
|
+
return max(1.0, min(168.0, v))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _rec_time(r):
|
|
318
|
+
"""台账记录的时间戳(epoch 秒);解析不了返回 None。"""
|
|
319
|
+
ts = str(r.get("ts") or "")
|
|
320
|
+
try:
|
|
321
|
+
return time.mktime(time.strptime(ts, "%Y-%m-%d %H:%M:%S"))
|
|
322
|
+
except (ValueError, TypeError, OverflowError):
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
304
326
|
def consecutive_failures(platform):
|
|
305
327
|
"""该平台最近连续失败的发布动作数(连败退避护栏,跨任务口径)。
|
|
306
328
|
|
|
307
329
|
记录时间序旧→新,倒着数到第一条成功为止;连败大概率是风控或改版,
|
|
308
330
|
此时继续自动重试只会火上浇油,应转人工检查。
|
|
309
|
-
|
|
331
|
+
只数时间窗内(fail_window_h,默认 12 小时)的失败:碰到窗口外的记录
|
|
332
|
+
即停——「连续」是时间上的连续,隔天的旧失败不进口径,否则一次代码
|
|
333
|
+
bug 的历史失败会在修好后永久卡死发布(2026-10-08 实案:序号 bug 修好
|
|
334
|
+
后 17 连败仍拦新发布,死锁)。"""
|
|
335
|
+
import datetime
|
|
336
|
+
cutoff = time.time() - fail_window_h() * 3600
|
|
310
337
|
n = 0
|
|
311
338
|
for r in reversed(list(_iter_records(7))):
|
|
312
339
|
if r.get("platform") != platform:
|
|
313
340
|
continue
|
|
314
341
|
if r.get("action") not in ("upload_chapter", "create_book"):
|
|
315
342
|
continue
|
|
343
|
+
t = _rec_time(r)
|
|
344
|
+
if t is None:
|
|
345
|
+
day = str(r.get("day") or "")
|
|
346
|
+
try:
|
|
347
|
+
t = time.mktime(datetime.datetime.strptime(
|
|
348
|
+
day, "%Y-%m-%d").timetuple()) + 86399
|
|
349
|
+
except ValueError:
|
|
350
|
+
continue # 无时间信息的记录不进连败口径
|
|
351
|
+
if t < cutoff:
|
|
352
|
+
break # 窗口外:再旧的成功/失败都不相干
|
|
316
353
|
if r.get("ok"):
|
|
317
354
|
break
|
|
318
355
|
n += 1
|