codebee 0.1.96 → 0.1.97

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.
@@ -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 ""
@@ -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(1, min(50, v))
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 used >= cap:
69
- return False, ("今日已发 %d 章(上限 %d),为防风控明天再发;"
70
- "确需多发请在设置调 publish_daily_cap" % (used, cap))
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, ("平台连续 %d 次发布失败(疑似风控或改版),自动发布已暂停,"
75
- "请人工检查后再试" % streak)
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
- 返回 (list, err);list 项 {chapter_no, file, size},file 为工作目录相对
92
- 路径。章号解析不出的文件不进自动发布(防同章多文件误发),API 单章
93
- 发(manager 直调)不受此限。
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
- files = []
101
- for r in store.task_runs(task_id):
102
- _wd, fs = store.run_artifacts(r.get("id") or "", limit=800)
103
- if fs:
104
- files = fs # 任一 run 的成品口径都从任务首跑起,取到即够
105
- break
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
- out.append({"chapter_no": n, "file": name, "size": f.get("size") or 0})
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 被踢到登录页 → 未登录
@@ -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'});act.click();"
389
- "return{ok:true};}", scope_t, text)
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
- 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, "验证通过")
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": "click_arrow",
78
- "target": "下一步"
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": "click_real",
82
- "text": "定时发布",
83
- "tries": 12,
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": "click_real",
88
- "text": "发布",
89
- "tries": 12,
90
- "optional": true
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