codebee 0.1.102 → 0.1.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -21
  3. package/README.md +4 -8
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/acp_client.py +457 -457
  13. package/app/core/aiflavor.py +197 -197
  14. package/app/core/analytics.py +38 -38
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -30
  19. package/app/core/backend_profiles.py +188 -188
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +102 -102
  23. package/app/core/bookmeta.py +1036 -1036
  24. package/app/core/bookmeta_catalog.py +180 -180
  25. package/app/core/branching.py +137 -137
  26. package/app/core/capability.py +144 -144
  27. package/app/core/chaptersafe.py +32 -32
  28. package/app/core/cleanup.py +303 -303
  29. package/app/core/command_auth.py +302 -302
  30. package/app/core/compaction.py +201 -201
  31. package/app/core/contracts.py +255 -255
  32. package/app/core/covergen.py +356 -356
  33. package/app/core/defectretro.py +43 -43
  34. package/app/core/diagnostics.py +114 -114
  35. package/app/core/dispatch.py +1 -1
  36. package/app/core/dispatch_log.py +128 -128
  37. package/app/core/env_scrub.py +85 -85
  38. package/app/core/errorlog.py +137 -137
  39. package/app/core/eval_matrix.py +91 -91
  40. package/app/core/evaluation.py +174 -174
  41. package/app/core/failure_notify.py +290 -290
  42. package/app/core/flow_graph.py +33 -33
  43. package/app/core/goal_service.py +167 -167
  44. package/app/core/health.py +389 -389
  45. package/app/core/history.py +38 -38
  46. package/app/core/hooks.py +162 -162
  47. package/app/core/knowledge_pipeline.py +86 -86
  48. package/app/core/manager.py +2385 -2385
  49. package/app/core/mocks.py +64 -64
  50. package/app/core/notify.py +201 -201
  51. package/app/core/novel_quality.py +181 -181
  52. package/app/core/openapi.py +217 -217
  53. package/app/core/operations.py +228 -228
  54. package/app/core/paihang.py +145 -145
  55. package/app/core/pipeline.py +2 -2
  56. package/app/core/portguard.py +111 -111
  57. package/app/core/presence.py +77 -77
  58. package/app/core/preview.py +293 -293
  59. package/app/core/project_memory.py +206 -206
  60. package/app/core/prompt_guard.py +78 -78
  61. package/app/core/publish/__init__.py +7 -7
  62. package/app/core/publish/auto.py +659 -659
  63. package/app/core/publish/chaprows.py +138 -138
  64. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  65. package/app/core/publish/manager.py +1185 -1185
  66. package/app/core/publish/qimao.py +193 -193
  67. package/app/core/publish/volumes.py +108 -108
  68. package/app/core/publish/ws.py +139 -139
  69. package/app/core/quality_gate.py +201 -201
  70. package/app/core/redact.py +38 -38
  71. package/app/core/repeat_guard.py +129 -129
  72. package/app/core/retrieval.py +137 -137
  73. package/app/core/sandbox_runtime.py +95 -95
  74. package/app/core/selfupdate.py +384 -384
  75. package/app/core/session_log.py +161 -161
  76. package/app/core/sessions.py +312 -312
  77. package/app/core/settings.py +215 -215
  78. package/app/core/settings_schema.py +282 -282
  79. package/app/core/share_page.py +114 -114
  80. package/app/core/skill_scan.py +139 -139
  81. package/app/core/skillpacks/market/character-bible.md +66 -66
  82. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  83. package/app/core/skillpacks/market/git-workflow.md +57 -57
  84. package/app/core/skillpacks/market/release-notes.md +72 -72
  85. package/app/core/skillpacks/market/weekly-report.md +71 -71
  86. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  87. package/app/core/step_runner.py +132 -132
  88. package/app/core/story_tracking.py +275 -275
  89. package/app/core/task_compile.py +199 -199
  90. package/app/core/telemetry.py +292 -292
  91. package/app/core/tlsctx.py +64 -64
  92. package/app/core/token_meter.py +147 -147
  93. package/app/core/tool_outputs.py +107 -107
  94. package/app/core/tracing.py +207 -207
  95. package/app/core/upstream.py +41 -41
  96. package/app/core/usage.py +1038 -1038
  97. package/app/core/volumes.py +389 -389
  98. package/app/core/webhooks.py +89 -89
  99. package/app/core/wxdigest.py +865 -865
  100. package/app/core/zentao.py +2692 -2692
  101. package/app/mcp_server.py +244 -244
  102. package/app/output/_createbook_body.json +1 -0
  103. package/app/output/_probe_fq_click.py +50 -0
  104. package/app/output/_probe_fq_form.py +42 -0
  105. package/app/output/_probe_fq_modal.py +55 -0
  106. package/app/output/_probe_fq_points.py +71 -0
  107. package/app/output/_probe_fq_submit.py +34 -0
  108. package/app/output/_probe_fq_watch.py +62 -0
  109. package/app/output/workbench-clean-preview.jpg +0 -0
  110. package/app/output/workbench-reference-render.png +0 -0
  111. package/app/output/workbench-shell.png +0 -0
  112. package/app/pick_dialog.py +110 -110
  113. package/app/ui/app.js +2 -3
  114. package/app/ui/artdiff.js +74 -74
  115. package/app/ui/i18n.js +0 -1
  116. package/app/ui/icons/bee.svg +79 -79
  117. package/app/ui/manifest.json +16 -16
  118. package/app/ui/qrcode.js +2297 -2297
  119. package/bin/tutti.js +147 -147
  120. package/package.json +1 -1
  121. package/tools/wx_reader.py +261 -261
@@ -1,659 +1,659 @@
1
- # -*- coding: utf-8 -*-
2
- """自动发布:枚举任务待发章节,按护栏顺序发布(P2 护栏层)。
3
-
4
- 与 manager 的分工:manager 管「一次动作」(连接/建书/发一章),
5
- auto 管「一批章节」——枚举任务成品里的章节文件,减去台账已发章号,
6
- 逐章调 manager 发,每章之间隔一段防风控节奏。
7
-
8
- 护栏(每章发起前复查,发布中途护栏状态变了也拦得住):
9
- - 每日上限:ledger.today_count >= settings.publish_daily_cap(默认 10)
10
- - 连败退避:ledger.consecutive_failures >= settings.publish_fail_streak(默认 3)
11
- ——连续失败说明疑似风控/改版,自动发布暂停转人工;
12
- - 幂等:已成功发布的章号跳过(manager 内还有第二道闸);
13
- - 单飞:同任务同时只有一个自动发布线程。
14
-
15
- 发布确认闸沿 manager 口径:auto_submit=False(默认)时每章只填好表单,
16
- 提交权留给用户在浏览器窗口里人工点——自动发布≠自动直发。
17
- """
18
- from __future__ import annotations
19
-
20
- import os
21
- import threading
22
- import time
23
-
24
- PACE_S = 45 # 章间间隔(防风控节奏),测试里可置 0
25
- IDLE_POLL_S = 2 # 等 manager busy 结束的轮询步长
26
- IDLE_TIMEOUT_S = 420 # 单章最长等待(含浏览器操作与人工确认窗口)
27
- TRANSIENT_RETRY_S = 5 # 连接类瞬断的重试前静默(等浏览器缓过来)
28
-
29
- _CAP_DEFAULT = 10
30
- _STREAK_DEFAULT = 3
31
-
32
- _running = {} # task_id → {platform, at, done, total, status, error}
33
- _LOCK = threading.Lock()
34
-
35
-
36
- def _isdir(p):
37
- try:
38
- return os.path.isdir(p)
39
- except (OSError, ValueError):
40
- return False
41
-
42
-
43
- def _book_ready(info):
44
- return bool(info and (info.get("book_id") or info.get("source") == "manual"))
45
-
46
-
47
- # ---------------------------------------------------------------- 护栏配置
48
- def _settings():
49
- from .. import settings
50
- try:
51
- return settings.load() or {}
52
- except Exception:
53
- return {}
54
-
55
-
56
- def daily_cap():
57
- try:
58
- v = int(_settings().get("publish_daily_cap") or _CAP_DEFAULT)
59
- except (TypeError, ValueError):
60
- v = _CAP_DEFAULT
61
- return max(0, min(50, v))
62
-
63
-
64
- def fail_streak():
65
- try:
66
- v = int(_settings().get("publish_fail_streak") or _STREAK_DEFAULT)
67
- except (TypeError, ValueError):
68
- v = _STREAK_DEFAULT
69
- return max(1, min(10, v))
70
-
71
-
72
- def guards(task_id, platform):
73
- """两道护栏:每日上限 + 连败退避。返回 (ok, 人话原因)。
74
-
75
- publish_daily_cap=0 视为不限制(2026-10-08 用户要求关闭每日上限)。"""
76
- from . import ledger
77
- cap = daily_cap()
78
- if cap > 0:
79
- used = ledger.today_count(task_id, platform)
80
- if used >= cap:
81
- return False, ("今日已发 %d 章(上限 %d),为防风控明天再发;"
82
- "确需多发请在设置调 publish_daily_cap(0=不限制)"
83
- % (used, cap))
84
- streak = ledger.consecutive_failures(platform)
85
- limit = fail_streak()
86
- if streak >= limit:
87
- return False, ("平台近 %s 内连续 %d 次发布失败(疑似风控或改版),自动发布"
88
- "已暂停;代码问题修复后护栏自动解除,也可单章「发一章」"
89
- "恢复" % (_fail_window_text(), streak))
90
- return True, ""
91
-
92
-
93
- def _fail_window_text():
94
- from . import ledger
95
- h = ledger.fail_window_h()
96
- return ("%g 小时" % h) if h < 24 else ("%g 天" % (h / 24))
97
-
98
-
99
- # 连接类瞬断标记(2026-10-10 存草稿实案:45 章 fill 时「连接被关闭」整链
100
- # 停摆)——命中即对该章重试一次再弃,不因浏览器抖动废掉整批
101
- _TRANSIENT_MARKS = ("连接断开", "连接被关闭", "10053", "10054", "等响应超时",
102
- "已中止", "目标已关闭")
103
- # 幂等拒起标记(manager 层台账/实况对账的拒绝话术)——命中即跳过该章,
104
- # 不是失败
105
- _IDEMPOTENT_MARKS = ("已成功发布过", "已存过草稿")
106
-
107
-
108
- def _is_transient(err):
109
- e = str(err or "")
110
- return any(m in e for m in _TRANSIENT_MARKS)
111
-
112
-
113
- def _is_idempotent_reject(err):
114
- e = str(err or "")
115
- return any(m in e for m in _IDEMPOTENT_MARKS)
116
-
117
-
118
- def other_batch_running(task_id):
119
- """除本任务外是否还有别的自动发布/存草稿批次在跑。命中返回对方
120
- task_id,否则空串——草稿链与之互斥(共用平台浏览器会互踩页签,
121
- 2026-10-10 实案:连载发章占着浏览器,草稿流 fill 被掐断连接)。"""
122
- with _LOCK:
123
- for tid, ent in _running.items():
124
- if tid != str(task_id) and ent.get("status") == "running":
125
- return str(tid)
126
- return ""
127
-
128
-
129
- # ---------------------------------------------------------------- 待发枚举
130
- # 文档目录里的编号文件不是章节稿:章纲/大纲按章编号(第85章-xxx.md、
131
- # vol-1-ch-16.md),全按章号算会把待发清单撑出一堆「假章」——批量选择
132
- # 发布时一旦选中就会把章纲当正文填进编辑器(2026-10-08 实案:全书树 88
133
- # 个章号,其中 34 个来自 章纲/已成稿/作废稿 目录)。
134
- _DOC_DIR_MARKS = ("章纲/", "大纲/", "设定/", "参考资料/", "已成稿/",
135
- "作废稿", "docs/", "outline/")
136
- _WALK_PRUNE = {"node_modules", "target", "build", "dist", "__pycache__"}
137
-
138
-
139
- def _workdir_chapter_files(wd):
140
- """任务工作目录的**全量**章节稿(不看时间窗,2026-10-08 实案)。
141
-
142
- run 成品口径的时间窗起点是「各自任务的首跑」——续写链上每个批次任务
143
- 只能看到自己出生以后新写的章节:今天的批次任务待发只剩 73-78,昨天
144
- 的 65-78,根任务才有全量 31-78。发布待发的正确口径是「工作目录里
145
- 还没发过的章节稿」,与哪个任务/何时跑无关,所以这里直接全树扫。"""
146
- import os as _os
147
- out = []
148
- for dirpath, dirnames, filenames in _os.walk(wd):
149
- dirnames[:] = [d for d in dirnames
150
- if not d.startswith(".") and d not in _WALK_PRUNE]
151
- for name in filenames:
152
- if not name.lower().endswith((".md", ".txt")):
153
- continue
154
- fp = _os.path.join(dirpath, name)
155
- rel = _os.path.relpath(fp, wd).replace("\\", "/")
156
- if rel.startswith(_DOC_DIR_MARKS):
157
- continue
158
- try:
159
- out.append({"name": rel, "size": _os.path.getsize(fp)})
160
- except OSError:
161
- pass
162
- return out
163
-
164
-
165
- def pending(task_id, platform):
166
- """待发章节清单:任务工作目录里的章节文件 − 已发章号,按章号升序。
167
-
168
- 已发口径连载链合并(含校准所得平台实况),所以任何一个批次任务上发
169
- 起发布,看到的都是整本书的待发清单。已存草稿的章同样剔除(内容已在
170
- 平台草稿箱,再跑只会造重复稿;提交发布走平台草稿箱由用户手工完成)。
171
- 返回 (list, err);list 项
172
- {chapter_no, file, size},file 为工作目录相对路径。章号解析不出的
173
- 文件不进自动发布(防同章多文件误发),API 单章发(manager 直调)
174
- 不受此限。
175
- """
176
- from .. import store
177
- from . import ledger
178
- task = store.get_task(task_id)
179
- if not task:
180
- return [], "任务不存在"
181
- wd = str(task.get("workdir") or "").strip()
182
- files = _workdir_chapter_files(wd) if wd and _isdir(wd) else []
183
- if not files:
184
- # workdir 缺失/异常:退回 run 成品口径(最新 run 优先)
185
- for r in store.task_runs(task_id):
186
- _wd, fs = store.run_artifacts(r.get("id") or "", limit=800)
187
- if fs:
188
- files = fs # 任一 run 的成品口径都从任务首跑起,取到即够
189
- break
190
- done = ledger.published_chapters(task_id, platform) \
191
- | ledger.drafted_chapters(task_id, platform)
192
- # 分卷标注(2026-10-08 用户需求:待发清单按卷展示):卷计划解析不出时
193
- # volume 为空串,前端不分组平铺。
194
- plan = []
195
- try:
196
- from . import volumes as _volumes
197
- plan = _volumes.load_plan(wd)
198
- except Exception:
199
- plan = []
200
- out, seen = [], set()
201
- for f in files:
202
- name = str(f.get("name") or "")
203
- if not name.lower().endswith((".md", ".txt")):
204
- continue
205
- if name.startswith(_DOC_DIR_MARKS):
206
- continue
207
- n = ledger.parse_chapter_no(name)
208
- if n <= 0 or n in done or n in seen:
209
- continue
210
- seen.add(n)
211
- vol = _volumes.volume_for(n, plan) if plan else None
212
- out.append({"chapter_no": n, "file": name, "size": f.get("size") or 0,
213
- "volume": (vol.get("name") or "") if vol else ""})
214
- out.sort(key=lambda x: x["chapter_no"])
215
- return out, ""
216
-
217
-
218
- def status(task_id):
219
- """前端视图:待发清单 + 护栏状态 + 自动发布进度。
220
-
221
- 护栏分两层给前端:guard_ok/guard_reason=完整发布闸(硬护栏+质量闸),
222
- 管「发布」类按钮;draft_ok/draft_reason=硬护栏(每日上限/连败退避/建书
223
- 确认),管「存草稿」按钮——草稿不上线,质量闸拦它只会堵死「先落平台
224
- 再人工把关」的通路;quality_blockers 单独带出去做提示性展示。"""
225
- from .. import store
226
- from . import ledger, manager
227
- ent = ledger.books_for(str(task_id)) # 沿连载链继承:续写批次同书同账
228
- task = store.get_task(task_id)
229
- books = []
230
- for plat, info in ent.items():
231
- pend, err = pending(task_id, plat)
232
- hard_ok, hard_why = guards(task_id, plat)
233
- if not _book_ready(info):
234
- hard_ok, hard_why = False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
235
- blockers = []
236
- if task and _book_ready(info):
237
- quality = manager._quality_release_guard(task, plat, action="publish")
238
- if not quality.get("allowed"):
239
- blockers = [str(b) for b in (quality.get("blockers") or [])]
240
- ok, why = hard_ok, hard_why
241
- if ok and blockers:
242
- ok, why = False, "质量门禁拦截:%s" % ";".join(blockers)
243
- books.append({"platform": plat, "bound": True,
244
- "title": info.get("title") or "",
245
- "pending": len(pend),
246
- "items": pend[:200],
247
- "guard_ok": ok, "guard_reason": why,
248
- "draft_ok": hard_ok, "draft_reason": hard_why,
249
- "quality_blockers": blockers,
250
- "drafted": len(ledger.drafted_chapters(task_id, plat)),
251
- "calibrated": calibrated(plat)})
252
- run = _running.get(task_id) or None
253
- if run:
254
- run = dict(run)
255
- return {"books": books, "running": run}
256
-
257
-
258
- # ---------------------------------------------------------------- 顺序发布
259
- def _wait_idle(platform, timeout=IDLE_TIMEOUT_S):
260
- """等 manager 的平台动作结束(busy → 其他)。人工确认模式下用户在
261
- 浏览器里点提交的时间也算在内,超时给足。"""
262
- from . import manager
263
- deadline = time.time() + timeout
264
- while time.time() < deadline:
265
- try:
266
- st = (manager.view().get("platforms") or {}).get(platform) or {}
267
- if st.get("status") != "busy":
268
- return True
269
- except Exception:
270
- return False # manager 异常:别傻等
271
- time.sleep(IDLE_POLL_S)
272
- return False
273
-
274
-
275
- def calibrated(platform):
276
- """该平台的发布流程是否已校准(data/publish/flows-<plat>.json 在场)。
277
-
278
- 内置默认表的选择器是「合理推测」;auto_submit 无人值守直发必须先经
279
- 真机校准(探测→写 flows 覆盖文件),否则填错表单还会自动提交出去。"""
280
- from .. import paths
281
- return (paths.PUBLISH_DIR / ("flows-%s.json" % platform)).is_file()
282
-
283
-
284
- def flow_calibrated(plat, action):
285
- """某动作的步骤表是否有真机校准来源(用户校准文件或仓库校准模板里
286
- 有该动作的表)。内置推测表不算——存草稿虽不上线,批量乱点照样惹
287
- 风控/攒一箱废稿,无人值守跑之前必须有验证过的步骤表。"""
288
- import json as _json
289
- from pathlib import Path as _Path
290
- from .. import paths as _paths
291
- for fp in (_paths.PUBLISH_DIR / ("flows-%s.json" % plat),
292
- _Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
293
- try:
294
- data = _json.loads(fp.read_text(encoding="utf-8"))
295
- if isinstance(data, dict) and isinstance(data.get(action), list):
296
- return True
297
- except Exception:
298
- pass
299
- return False
300
-
301
-
302
- def publish_pending_async(task_id, platform, auto_submit=False, only=None,
303
- force=False, force_confirmed=False, force_reason="",
304
- as_draft=False):
305
- """把任务的待发章节按章号顺序发出(后台线程)。返回 (ok, err)。
306
-
307
- only=章号清单(批量选择发布):从待发清单里挑出所选章号,保持章号
308
- 升序;所选全都不在待发清单里(已发过/解析不出章号)时报错不空跑。
309
- auto_submit=True(直发)逐章提交走完全程;False(人工确认)每轮只填
310
- **一章**就停在 manual_pause——表单填好后提交权在用户,walker 若直接
311
- 填下一章会导航离开未提交的编辑器,把上一章内容丢掉(平台草稿自动
312
- 保存不可依赖)。用户在浏览器提交后再次发起即发下一章。
313
- as_draft=True(全部发草稿,2026-10-09):逐章自动填稿并点「存草稿」,
314
- 不上线、不受质量闸拦、无需人工确认,一章接一章连跑到清空待发清单
315
- (章间仍按 PACE_S 防风控节奏);提交发布由用户到平台草稿箱手工完成,
316
- 届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。
317
- 草稿链三条专属护栏(2026-10-10 假成功案):起跑前先 attach-only 对账
318
- 平台实况并重算待发(已发布/已存稿剔除,对不了账就拒起);跑批期间
319
- 检测到另一批自动发布即让位停止(平台浏览器互斥)。"""
320
- from .. import store
321
- from . import ledger, manager
322
- if platform not in manager.PLATFORMS:
323
- return False, "未知平台"
324
- if as_draft:
325
- auto_submit = False # 草稿模式没有「提交」语义
326
- if not flow_calibrated(platform, "upload_chapter_draft"):
327
- return False, ("存草稿模式需要该平台有 upload_chapter_draft 步骤表"
328
- "(仓库校准模板或 data/publish/flows-%s.json);"
329
- "未校准不许无人值守批量动表单" % platform)
330
- elif auto_submit and not calibrated(platform):
331
- return False, ("自动提交模式需要先校准该平台发布流程:用「探测」按钮 dump "
332
- "表单后把真实步骤写进 data/publish/flows-%s.json(缺省选择器"
333
- "只是推测,未校准不许无人值守直发)" % platform)
334
- with _LOCK:
335
- cur = _running.get(task_id) or {}
336
- if cur.get("status") == "running":
337
- return False, "该任务已有自动发布进行中,请等本轮结束"
338
- if as_draft:
339
- other = other_batch_running(task_id)
340
- if other:
341
- return False, ("检测到另一批自动发布/存草稿进行中(任务 %s)——两边"
342
- "共用平台浏览器会互踩,请等它跑完再存草稿" % other)
343
- task = store.get_task(task_id)
344
- if not task:
345
- return False, "任务不存在"
346
- from .. import contracts
347
- contract = contracts.get(task_id)
348
- if ((contract and contract.get("approval_required")) or task.get("approval_required")) \
349
- and not contracts.release_allowed(task_id):
350
- return False, "任务需要先完成审批,才能发布产物"
351
- book = ledger.book_for(task_id, platform)
352
- if not book:
353
- return False, "该任务尚未在此平台建书,请先「创建作品」"
354
- if not _book_ready(book):
355
- return False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
356
- if as_draft:
357
- # 草稿不上线:质量闸不适用(同 manager 口径),硬护栏照查
358
- quality = {"allowed": True, "status": "draft_only"}
359
- else:
360
- quality = manager._quality_release_guard(
361
- task, platform, action="publish", force=force,
362
- force_confirmed=force_confirmed, force_reason=force_reason)
363
- if not quality.get("allowed"):
364
- return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
365
- ok, why = guards(task_id, platform)
366
- if not ok:
367
- return False, why
368
- pend, err = pending(task_id, platform)
369
- if err:
370
- return False, err
371
- want = None
372
- if only is not None:
373
- try:
374
- want = {int(n) for n in (only or []) if n}
375
- except (TypeError, ValueError):
376
- return False, "chapters 必须是章号数组"
377
- pend = [p for p in pend if p["chapter_no"] in want]
378
- if not pend:
379
- return False, ("所选章节都不在待发清单里(已发过,或成品里没有"
380
- "对应章节文件);可先点「校准」对齐平台实况")
381
- if not pend:
382
- return False, "没有待发章节(全部已发布,或成品里没有可识别的章节文件)"
383
-
384
- from pathlib import Path
385
- wd = task.get("workdir") or ""
386
- st = {"platform": platform, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
387
- "done": 0, "total": len(pend), "status": "running", "error": "",
388
- "auto_submit": bool(auto_submit), "as_draft": bool(as_draft),
389
- "last_chapter": 0, "skipped": 0, "notes": []}
390
- with _LOCK:
391
- _running[task_id] = st
392
-
393
- def run():
394
- try:
395
- items = pend
396
- if as_draft:
397
- # 起草前对账(2026-10-10 假成功案):台账只记得自己发的章,
398
- # 手工补交的只有平台知道——attach-only 刷一次校准再重算待发,
399
- # 已发布/已存稿章就地剔除,不去撞序号重复。对不了账(浏览器
400
- # 未连接/忙)整个拒起:存草稿本来就需要平台浏览器,宁等空窗
401
- # 不盲跑;平台没配章节计数时跳过对账维持旧路径。
402
- ok_r, why_r = manager.sync_published(task_id, platform,
403
- manual=False)
404
- if not ok_r and "未配置章节计数" not in str(why_r or ""):
405
- st["status"] = "error"
406
- st["error"] = ("存草稿前对账失败:%s。为防把已发布章重复存稿,"
407
- "本次未起草;请连接平台浏览器并等它空闲后重试"
408
- % why_r)
409
- return
410
- fresh, _ = pending(task_id, platform)
411
- if want is not None:
412
- fresh = [p for p in fresh if p["chapter_no"] in want]
413
- if not fresh:
414
- st["status"] = "done"
415
- st["message"] = ("对账后没有可存草稿的章节(所选均已发布或"
416
- "已存稿);确需重存请先在平台删旧稿")
417
- return
418
- if len(fresh) != st["total"]:
419
- st["notes"].append("对账剔除 %d 章已发布/已存稿,实存 %d 章"
420
- % (st["total"] - len(fresh), len(fresh)))
421
- st["total"] = len(fresh)
422
- items = fresh
423
- for item in items:
424
- if as_draft:
425
- other = other_batch_running(task_id)
426
- if other:
427
- st["status"] = "error"
428
- st["error"] = ("检测到另一批自动发布已启动(任务 %s),"
429
- "存草稿让位停止;已存 %d 章落平台草稿箱,"
430
- "等对方跑完再续" % (other, st["done"]))
431
- return
432
- g_ok, why = guards(task_id, platform) # 每章前复查(中途也能拦)
433
- if not g_ok:
434
- st["status"] = "error"
435
- st["error"] = "第 %d 章前护栏拦截:%s" % (item["chapter_no"], why)
436
- return
437
- upload_kwargs = {"auto_submit": auto_submit, "as_draft": as_draft}
438
- if force or force_confirmed or force_reason:
439
- upload_kwargs.update(force=force,
440
- force_confirmed=force_confirmed,
441
- force_reason=force_reason)
442
- ok2, err2 = manager.upload_chapter_async(
443
- task_id, platform, str(Path(wd) / item["file"]),
444
- **upload_kwargs)
445
- if not ok2 and as_draft and _is_idempotent_reject(err2):
446
- # 幂等拒起=这章已发布/已存过稿:跳过,不是失败——
447
- # 整链不该因「没活干」停摆
448
- st["skipped"] += 1
449
- st["notes"].append("第 %d 章跳过:%s"
450
- % (item["chapter_no"], str(err2)[:60]))
451
- continue
452
- if not ok2 and as_draft and _is_transient(err2):
453
- # 连接类瞬断(连载流互踩/浏览器抖动):重试一次再弃
454
- st["notes"].append("第 %d 章连接中断,%d 秒后重试一次"
455
- % (item["chapter_no"],
456
- TRANSIENT_RETRY_S))
457
- time.sleep(TRANSIENT_RETRY_S)
458
- ok2, err2 = manager.upload_chapter_async(
459
- task_id, platform, str(Path(wd) / item["file"]),
460
- **upload_kwargs)
461
- if not ok2:
462
- st["status"] = "error"
463
- st["error"] = "第 %d 章发起失败:%s" % (item["chapter_no"], err2)
464
- return
465
- if not _wait_idle(platform):
466
- st["status"] = "error"
467
- if as_draft:
468
- st["error"] = ("第 %d 章存草稿等待超时(%.0f 分钟),后续"
469
- "章节未存" % (item["chapter_no"],
470
- IDLE_TIMEOUT_S / 60))
471
- else:
472
- st["error"] = ("第 %d 章发布等待超时(%.0f 分钟);若在等人工提交,"
473
- "请提交后重跑剩余章节" % (item["chapter_no"],
474
- IDLE_TIMEOUT_S / 60))
475
- return
476
- if as_draft:
477
- # manager 走完草稿流程(含平台侧验证)才返回;台账落了
478
- # upload_chapter_draft 才算数,防「流程跑完但平台没存上」。
479
- if item["chapter_no"] not in ledger.drafted_chapters(
480
- task_id, platform):
481
- st["status"] = "error"
482
- st["error"] = ("第 %d 章存草稿未确认(台账未落草稿账),"
483
- "后续章节未存;截图存证见发布台账,若实际"
484
- "已保存成功,把页面反馈文案照截图校准进"
485
- "流程表 expect_text 步骤"
486
- % item["chapter_no"])
487
- return
488
- st["done"] += 1
489
- st["last_chapter"] = item["chapter_no"]
490
- if st["done"] + st["skipped"] < st["total"]:
491
- time.sleep(PACE_S)
492
- continue
493
- if not auto_submit:
494
- # The manager stopped before the submit step. There is no
495
- # remote success receipt until the user clicks submit.
496
- st["last_chapter"] = item["chapter_no"]
497
- st["status"] = "manual_pause"
498
- st["message"] = ("第 %d 章已填好,请在浏览器里确认提交;"
499
- "提交后请重新校准,再选择下一章(剩 %d 章)"
500
- % (item["chapter_no"], st["total"] - st["done"]))
501
- return
502
- if item["chapter_no"] not in ledger.published_chapters(
503
- task_id, platform):
504
- st["status"] = "error"
505
- st["error"] = ("第 %d 章发布失败,后续章节未发(详见发布台账与"
506
- "截图存证)" % item["chapter_no"])
507
- return
508
- st["done"] += 1
509
- st["last_chapter"] = item["chapter_no"]
510
- if st["done"] < st["total"]:
511
- time.sleep(PACE_S)
512
- st["status"] = "done"
513
- if as_draft:
514
- st["message"] = ("已存草稿 %d 章%s(未发布,不受质量闸拦不代表可"
515
- "直发);请到平台章节管理/草稿箱逐章检查后提交"
516
- "发布"
517
- % (st["done"], ("、跳过 %d 章" % st["skipped"])
518
- if st["skipped"] else ""))
519
- elif not auto_submit:
520
- st["message"] = "第 %d 章已填好,请在浏览器里确认提交,再重新校准" % st["last_chapter"]
521
- except Exception as e: # 线程内绝不能悬挂无终态
522
- st["status"] = "error"
523
- st["error"] = "自动发布异常:%s" % e
524
- finally:
525
- st["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
526
-
527
- threading.Thread(target=run, daemon=True,
528
- name="pub-auto-%s" % platform).start()
529
- return True, ""
530
-
531
-
532
- # ---------------------------------------------------------------- 定时联动(P2.5)
533
- # 任务级标记 task.auto_publish = {enabled, platform, time:"HH:MM", auto_submit}
534
- # automation._tick 每 25s 调 fire_due():到点且今日未触发 → publish_pending_async。
535
- # 「今日已触发」记在 data/publish/auto_publish.json(task_id → YYYY-MM-DD):
536
- # 触发过就不再重试当日(护栏/单飞自身也防重),成败都等明天——连败退避
537
- # 场景下避免到点后每 25s 撞一次护栏。
538
- _AP_FILE = None # paths.PUBLISH_DIR / "auto_publish.json"(导入惰性定)
539
- _AP_FIRED = None # 内存缓存 {task_id: "YYYY-MM-DD"}
540
- _AP_LOCK = threading.RLock() # 可重入:_mark_fired 持锁内调 _load_fired 再进同锁
541
-
542
-
543
- def _ap_file():
544
- global _AP_FILE
545
- if _AP_FILE is None:
546
- from .. import paths
547
- _AP_FILE = paths.PUBLISH_DIR / "auto_publish.json"
548
- return _AP_FILE
549
-
550
-
551
- def _load_fired():
552
- global _AP_FIRED
553
- with _AP_LOCK:
554
- if _AP_FIRED is None:
555
- try:
556
- import json
557
- d = json.loads(_ap_file().read_text(encoding="utf-8"))
558
- _AP_FIRED = d if isinstance(d, dict) else {}
559
- except Exception:
560
- _AP_FIRED = {}
561
- return _AP_FIRED
562
-
563
-
564
- def _mark_fired(task_id, day):
565
- import json
566
- with _AP_LOCK:
567
- fired = _load_fired()
568
- fired[str(task_id)] = day
569
- try:
570
- _ap_file().parent.mkdir(parents=True, exist_ok=True)
571
- tmp = _ap_file().with_suffix(".tmp")
572
- tmp.write_text(json.dumps(fired, ensure_ascii=False, indent=1),
573
- encoding="utf-8")
574
- tmp.replace(_ap_file())
575
- except Exception:
576
- pass # 记录失败:最坏是当日重复触发,护栏会拦
577
-
578
-
579
- def norm_auto_publish(ap):
580
- """校验并归一 auto_publish 配置。非法返回 (None, 人话原因)。"""
581
- from . import manager
582
- if not isinstance(ap, dict):
583
- return None, "auto_publish 必须是对象"
584
- platform = str(ap.get("platform") or "").strip()
585
- if platform not in manager.PLATFORMS:
586
- return None, "platform 必须是 fanqie 或 qimao"
587
- hhmm = str(ap.get("time") or "").strip()
588
- import re
589
- m = re.match(r"^(\d{1,2}):(\d{2})$", hhmm)
590
- if not m:
591
- return None, "time 必须是 24 小时制 HH:MM"
592
- h, mi = int(m.group(1)), int(m.group(2))
593
- if not (0 <= h <= 23 and 0 <= mi <= 59):
594
- return None, "time 超出 0-23:00-59"
595
- return {"enabled": bool(ap.get("enabled")),
596
- "platform": platform,
597
- "time": "%02d:%02d" % (h, mi),
598
- "auto_submit": bool(ap.get("auto_submit")),
599
- "at": time.strftime("%Y-%m-%d %H:%M:%S")}, ""
600
-
601
-
602
- def due_tasks(now=None):
603
- """到期待触发的定时发布任务清单:[(task, auto_publish)]。
604
-
605
- 条件:enabled + 已在该平台建书 + 今日未触发 + 当前时间已过当日 time。
606
- 未建书的任务跳过(触发也只会被 publish_pending_async 拒绝,白记一次
607
- fired 反而把当天额度烧掉)。"""
608
- from .. import store
609
- from . import ledger
610
- now = now or time.localtime()
611
- today = time.strftime("%Y-%m-%d", now)
612
- hhmm_now = time.strftime("%H:%M", now)
613
- fired = _load_fired()
614
- out = []
615
- for task in store.list_tasks(limit=10 ** 9):
616
- ap = task.get("auto_publish")
617
- if not isinstance(ap, dict) or not ap.get("enabled"):
618
- continue
619
- if ap.get("auto_submit"):
620
- from .. import contracts
621
- contract = contracts.get(task["id"])
622
- if ((contract and contract.get("approval_required")) or
623
- task.get("approval_required")) and not contracts.release_allowed(task["id"]):
624
- continue
625
- if str(ap.get("time") or "") <= hhmm_now and fired.get(task["id"]) != today:
626
- plat = ap.get("platform")
627
- if plat and _book_ready(ledger.book_for(task["id"], plat)):
628
- out.append((task, ap))
629
- return out
630
-
631
-
632
- def fire_due(now=None):
633
- """tick 入口:把所有到期的定时发布触发一轮。返回触发条数;单条异常不拖累其余。"""
634
- from . import ledger # 惰性导入(同模块惯例):漏了会 NameError
635
- now = now or time.localtime() # 且被双层 except 双重静默成幽灵 0
636
- today = time.strftime("%Y-%m-%d", now)
637
- n = 0
638
- for task, ap in due_tasks(now=now):
639
- try:
640
- ok, err = publish_pending_async(
641
- task["id"], ap.get("platform"),
642
- auto_submit=bool(ap.get("auto_submit")))
643
- _mark_fired(task["id"], today) # 成败都记:当日不重试
644
- if ok:
645
- ledger.record(ap.get("platform"), "auto_fire", task_id=task["id"],
646
- title="定时触发 %s" % (ap.get("time") or ""), ok=True)
647
- n += 1
648
- else:
649
- ledger.record(ap.get("platform"), "auto_fire", task_id=task["id"],
650
- title="定时触发 %s" % (ap.get("time") or ""),
651
- ok=False, error=str(err)[:200])
652
- except Exception as e:
653
- try:
654
- _mark_fired(task["id"], today)
655
- ledger.record(ap.get("platform"), "auto_fire", task_id=task["id"],
656
- ok=False, error=str(e)[:200])
657
- except Exception:
658
- pass
659
- return n
1
+ # -*- coding: utf-8 -*-
2
+ """自动发布:枚举任务待发章节,按护栏顺序发布(P2 护栏层)。
3
+
4
+ 与 manager 的分工:manager 管「一次动作」(连接/建书/发一章),
5
+ auto 管「一批章节」——枚举任务成品里的章节文件,减去台账已发章号,
6
+ 逐章调 manager 发,每章之间隔一段防风控节奏。
7
+
8
+ 护栏(每章发起前复查,发布中途护栏状态变了也拦得住):
9
+ - 每日上限:ledger.today_count >= settings.publish_daily_cap(默认 10)
10
+ - 连败退避:ledger.consecutive_failures >= settings.publish_fail_streak(默认 3)
11
+ ——连续失败说明疑似风控/改版,自动发布暂停转人工;
12
+ - 幂等:已成功发布的章号跳过(manager 内还有第二道闸);
13
+ - 单飞:同任务同时只有一个自动发布线程。
14
+
15
+ 发布确认闸沿 manager 口径:auto_submit=False(默认)时每章只填好表单,
16
+ 提交权留给用户在浏览器窗口里人工点——自动发布≠自动直发。
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import threading
22
+ import time
23
+
24
+ PACE_S = 45 # 章间间隔(防风控节奏),测试里可置 0
25
+ IDLE_POLL_S = 2 # 等 manager busy 结束的轮询步长
26
+ IDLE_TIMEOUT_S = 420 # 单章最长等待(含浏览器操作与人工确认窗口)
27
+ TRANSIENT_RETRY_S = 5 # 连接类瞬断的重试前静默(等浏览器缓过来)
28
+
29
+ _CAP_DEFAULT = 10
30
+ _STREAK_DEFAULT = 3
31
+
32
+ _running = {} # task_id → {platform, at, done, total, status, error}
33
+ _LOCK = threading.Lock()
34
+
35
+
36
+ def _isdir(p):
37
+ try:
38
+ return os.path.isdir(p)
39
+ except (OSError, ValueError):
40
+ return False
41
+
42
+
43
+ def _book_ready(info):
44
+ return bool(info and (info.get("book_id") or info.get("source") == "manual"))
45
+
46
+
47
+ # ---------------------------------------------------------------- 护栏配置
48
+ def _settings():
49
+ from .. import settings
50
+ try:
51
+ return settings.load() or {}
52
+ except Exception:
53
+ return {}
54
+
55
+
56
+ def daily_cap():
57
+ try:
58
+ v = int(_settings().get("publish_daily_cap") or _CAP_DEFAULT)
59
+ except (TypeError, ValueError):
60
+ v = _CAP_DEFAULT
61
+ return max(0, min(50, v))
62
+
63
+
64
+ def fail_streak():
65
+ try:
66
+ v = int(_settings().get("publish_fail_streak") or _STREAK_DEFAULT)
67
+ except (TypeError, ValueError):
68
+ v = _STREAK_DEFAULT
69
+ return max(1, min(10, v))
70
+
71
+
72
+ def guards(task_id, platform):
73
+ """两道护栏:每日上限 + 连败退避。返回 (ok, 人话原因)。
74
+
75
+ publish_daily_cap=0 视为不限制(2026-10-08 用户要求关闭每日上限)。"""
76
+ from . import ledger
77
+ cap = daily_cap()
78
+ if cap > 0:
79
+ used = ledger.today_count(task_id, platform)
80
+ if used >= cap:
81
+ return False, ("今日已发 %d 章(上限 %d),为防风控明天再发;"
82
+ "确需多发请在设置调 publish_daily_cap(0=不限制)"
83
+ % (used, cap))
84
+ streak = ledger.consecutive_failures(platform)
85
+ limit = fail_streak()
86
+ if streak >= limit:
87
+ return False, ("平台近 %s 内连续 %d 次发布失败(疑似风控或改版),自动发布"
88
+ "已暂停;代码问题修复后护栏自动解除,也可单章「发一章」"
89
+ "恢复" % (_fail_window_text(), streak))
90
+ return True, ""
91
+
92
+
93
+ def _fail_window_text():
94
+ from . import ledger
95
+ h = ledger.fail_window_h()
96
+ return ("%g 小时" % h) if h < 24 else ("%g 天" % (h / 24))
97
+
98
+
99
+ # 连接类瞬断标记(2026-10-10 存草稿实案:45 章 fill 时「连接被关闭」整链
100
+ # 停摆)——命中即对该章重试一次再弃,不因浏览器抖动废掉整批
101
+ _TRANSIENT_MARKS = ("连接断开", "连接被关闭", "10053", "10054", "等响应超时",
102
+ "已中止", "目标已关闭")
103
+ # 幂等拒起标记(manager 层台账/实况对账的拒绝话术)——命中即跳过该章,
104
+ # 不是失败
105
+ _IDEMPOTENT_MARKS = ("已成功发布过", "已存过草稿")
106
+
107
+
108
+ def _is_transient(err):
109
+ e = str(err or "")
110
+ return any(m in e for m in _TRANSIENT_MARKS)
111
+
112
+
113
+ def _is_idempotent_reject(err):
114
+ e = str(err or "")
115
+ return any(m in e for m in _IDEMPOTENT_MARKS)
116
+
117
+
118
+ def other_batch_running(task_id):
119
+ """除本任务外是否还有别的自动发布/存草稿批次在跑。命中返回对方
120
+ task_id,否则空串——草稿链与之互斥(共用平台浏览器会互踩页签,
121
+ 2026-10-10 实案:连载发章占着浏览器,草稿流 fill 被掐断连接)。"""
122
+ with _LOCK:
123
+ for tid, ent in _running.items():
124
+ if tid != str(task_id) and ent.get("status") == "running":
125
+ return str(tid)
126
+ return ""
127
+
128
+
129
+ # ---------------------------------------------------------------- 待发枚举
130
+ # 文档目录里的编号文件不是章节稿:章纲/大纲按章编号(第85章-xxx.md、
131
+ # vol-1-ch-16.md),全按章号算会把待发清单撑出一堆「假章」——批量选择
132
+ # 发布时一旦选中就会把章纲当正文填进编辑器(2026-10-08 实案:全书树 88
133
+ # 个章号,其中 34 个来自 章纲/已成稿/作废稿 目录)。
134
+ _DOC_DIR_MARKS = ("章纲/", "大纲/", "设定/", "参考资料/", "已成稿/",
135
+ "作废稿", "docs/", "outline/")
136
+ _WALK_PRUNE = {"node_modules", "target", "build", "dist", "__pycache__"}
137
+
138
+
139
+ def _workdir_chapter_files(wd):
140
+ """任务工作目录的**全量**章节稿(不看时间窗,2026-10-08 实案)。
141
+
142
+ run 成品口径的时间窗起点是「各自任务的首跑」——续写链上每个批次任务
143
+ 只能看到自己出生以后新写的章节:今天的批次任务待发只剩 73-78,昨天
144
+ 的 65-78,根任务才有全量 31-78。发布待发的正确口径是「工作目录里
145
+ 还没发过的章节稿」,与哪个任务/何时跑无关,所以这里直接全树扫。"""
146
+ import os as _os
147
+ out = []
148
+ for dirpath, dirnames, filenames in _os.walk(wd):
149
+ dirnames[:] = [d for d in dirnames
150
+ if not d.startswith(".") and d not in _WALK_PRUNE]
151
+ for name in filenames:
152
+ if not name.lower().endswith((".md", ".txt")):
153
+ continue
154
+ fp = _os.path.join(dirpath, name)
155
+ rel = _os.path.relpath(fp, wd).replace("\\", "/")
156
+ if rel.startswith(_DOC_DIR_MARKS):
157
+ continue
158
+ try:
159
+ out.append({"name": rel, "size": _os.path.getsize(fp)})
160
+ except OSError:
161
+ pass
162
+ return out
163
+
164
+
165
+ def pending(task_id, platform):
166
+ """待发章节清单:任务工作目录里的章节文件 − 已发章号,按章号升序。
167
+
168
+ 已发口径连载链合并(含校准所得平台实况),所以任何一个批次任务上发
169
+ 起发布,看到的都是整本书的待发清单。已存草稿的章同样剔除(内容已在
170
+ 平台草稿箱,再跑只会造重复稿;提交发布走平台草稿箱由用户手工完成)。
171
+ 返回 (list, err);list 项
172
+ {chapter_no, file, size},file 为工作目录相对路径。章号解析不出的
173
+ 文件不进自动发布(防同章多文件误发),API 单章发(manager 直调)
174
+ 不受此限。
175
+ """
176
+ from .. import store
177
+ from . import ledger
178
+ task = store.get_task(task_id)
179
+ if not task:
180
+ return [], "任务不存在"
181
+ wd = str(task.get("workdir") or "").strip()
182
+ files = _workdir_chapter_files(wd) if wd and _isdir(wd) else []
183
+ if not files:
184
+ # workdir 缺失/异常:退回 run 成品口径(最新 run 优先)
185
+ for r in store.task_runs(task_id):
186
+ _wd, fs = store.run_artifacts(r.get("id") or "", limit=800)
187
+ if fs:
188
+ files = fs # 任一 run 的成品口径都从任务首跑起,取到即够
189
+ break
190
+ done = ledger.published_chapters(task_id, platform) \
191
+ | ledger.drafted_chapters(task_id, platform)
192
+ # 分卷标注(2026-10-08 用户需求:待发清单按卷展示):卷计划解析不出时
193
+ # volume 为空串,前端不分组平铺。
194
+ plan = []
195
+ try:
196
+ from . import volumes as _volumes
197
+ plan = _volumes.load_plan(wd)
198
+ except Exception:
199
+ plan = []
200
+ out, seen = [], set()
201
+ for f in files:
202
+ name = str(f.get("name") or "")
203
+ if not name.lower().endswith((".md", ".txt")):
204
+ continue
205
+ if name.startswith(_DOC_DIR_MARKS):
206
+ continue
207
+ n = ledger.parse_chapter_no(name)
208
+ if n <= 0 or n in done or n in seen:
209
+ continue
210
+ seen.add(n)
211
+ vol = _volumes.volume_for(n, plan) if plan else None
212
+ out.append({"chapter_no": n, "file": name, "size": f.get("size") or 0,
213
+ "volume": (vol.get("name") or "") if vol else ""})
214
+ out.sort(key=lambda x: x["chapter_no"])
215
+ return out, ""
216
+
217
+
218
+ def status(task_id):
219
+ """前端视图:待发清单 + 护栏状态 + 自动发布进度。
220
+
221
+ 护栏分两层给前端:guard_ok/guard_reason=完整发布闸(硬护栏+质量闸),
222
+ 管「发布」类按钮;draft_ok/draft_reason=硬护栏(每日上限/连败退避/建书
223
+ 确认),管「存草稿」按钮——草稿不上线,质量闸拦它只会堵死「先落平台
224
+ 再人工把关」的通路;quality_blockers 单独带出去做提示性展示。"""
225
+ from .. import store
226
+ from . import ledger, manager
227
+ ent = ledger.books_for(str(task_id)) # 沿连载链继承:续写批次同书同账
228
+ task = store.get_task(task_id)
229
+ books = []
230
+ for plat, info in ent.items():
231
+ pend, err = pending(task_id, plat)
232
+ hard_ok, hard_why = guards(task_id, plat)
233
+ if not _book_ready(info):
234
+ hard_ok, hard_why = False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
235
+ blockers = []
236
+ if task and _book_ready(info):
237
+ quality = manager._quality_release_guard(task, plat, action="publish")
238
+ if not quality.get("allowed"):
239
+ blockers = [str(b) for b in (quality.get("blockers") or [])]
240
+ ok, why = hard_ok, hard_why
241
+ if ok and blockers:
242
+ ok, why = False, "质量门禁拦截:%s" % ";".join(blockers)
243
+ books.append({"platform": plat, "bound": True,
244
+ "title": info.get("title") or "",
245
+ "pending": len(pend),
246
+ "items": pend[:200],
247
+ "guard_ok": ok, "guard_reason": why,
248
+ "draft_ok": hard_ok, "draft_reason": hard_why,
249
+ "quality_blockers": blockers,
250
+ "drafted": len(ledger.drafted_chapters(task_id, plat)),
251
+ "calibrated": calibrated(plat)})
252
+ run = _running.get(task_id) or None
253
+ if run:
254
+ run = dict(run)
255
+ return {"books": books, "running": run}
256
+
257
+
258
+ # ---------------------------------------------------------------- 顺序发布
259
+ def _wait_idle(platform, timeout=IDLE_TIMEOUT_S):
260
+ """等 manager 的平台动作结束(busy → 其他)。人工确认模式下用户在
261
+ 浏览器里点提交的时间也算在内,超时给足。"""
262
+ from . import manager
263
+ deadline = time.time() + timeout
264
+ while time.time() < deadline:
265
+ try:
266
+ st = (manager.view().get("platforms") or {}).get(platform) or {}
267
+ if st.get("status") != "busy":
268
+ return True
269
+ except Exception:
270
+ return False # manager 异常:别傻等
271
+ time.sleep(IDLE_POLL_S)
272
+ return False
273
+
274
+
275
+ def calibrated(platform):
276
+ """该平台的发布流程是否已校准(data/publish/flows-<plat>.json 在场)。
277
+
278
+ 内置默认表的选择器是「合理推测」;auto_submit 无人值守直发必须先经
279
+ 真机校准(探测→写 flows 覆盖文件),否则填错表单还会自动提交出去。"""
280
+ from .. import paths
281
+ return (paths.PUBLISH_DIR / ("flows-%s.json" % platform)).is_file()
282
+
283
+
284
+ def flow_calibrated(plat, action):
285
+ """某动作的步骤表是否有真机校准来源(用户校准文件或仓库校准模板里
286
+ 有该动作的表)。内置推测表不算——存草稿虽不上线,批量乱点照样惹
287
+ 风控/攒一箱废稿,无人值守跑之前必须有验证过的步骤表。"""
288
+ import json as _json
289
+ from pathlib import Path as _Path
290
+ from .. import paths as _paths
291
+ for fp in (_paths.PUBLISH_DIR / ("flows-%s.json" % plat),
292
+ _Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
293
+ try:
294
+ data = _json.loads(fp.read_text(encoding="utf-8"))
295
+ if isinstance(data, dict) and isinstance(data.get(action), list):
296
+ return True
297
+ except Exception:
298
+ pass
299
+ return False
300
+
301
+
302
+ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
303
+ force=False, force_confirmed=False, force_reason="",
304
+ as_draft=False):
305
+ """把任务的待发章节按章号顺序发出(后台线程)。返回 (ok, err)。
306
+
307
+ only=章号清单(批量选择发布):从待发清单里挑出所选章号,保持章号
308
+ 升序;所选全都不在待发清单里(已发过/解析不出章号)时报错不空跑。
309
+ auto_submit=True(直发)逐章提交走完全程;False(人工确认)每轮只填
310
+ **一章**就停在 manual_pause——表单填好后提交权在用户,walker 若直接
311
+ 填下一章会导航离开未提交的编辑器,把上一章内容丢掉(平台草稿自动
312
+ 保存不可依赖)。用户在浏览器提交后再次发起即发下一章。
313
+ as_draft=True(全部发草稿,2026-10-09):逐章自动填稿并点「存草稿」,
314
+ 不上线、不受质量闸拦、无需人工确认,一章接一章连跑到清空待发清单
315
+ (章间仍按 PACE_S 防风控节奏);提交发布由用户到平台草稿箱手工完成,
316
+ 届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。
317
+ 草稿链三条专属护栏(2026-10-10 假成功案):起跑前先 attach-only 对账
318
+ 平台实况并重算待发(已发布/已存稿剔除,对不了账就拒起);跑批期间
319
+ 检测到另一批自动发布即让位停止(平台浏览器互斥)。"""
320
+ from .. import store
321
+ from . import ledger, manager
322
+ if platform not in manager.PLATFORMS:
323
+ return False, "未知平台"
324
+ if as_draft:
325
+ auto_submit = False # 草稿模式没有「提交」语义
326
+ if not flow_calibrated(platform, "upload_chapter_draft"):
327
+ return False, ("存草稿模式需要该平台有 upload_chapter_draft 步骤表"
328
+ "(仓库校准模板或 data/publish/flows-%s.json);"
329
+ "未校准不许无人值守批量动表单" % platform)
330
+ elif auto_submit and not calibrated(platform):
331
+ return False, ("自动提交模式需要先校准该平台发布流程:用「探测」按钮 dump "
332
+ "表单后把真实步骤写进 data/publish/flows-%s.json(缺省选择器"
333
+ "只是推测,未校准不许无人值守直发)" % platform)
334
+ with _LOCK:
335
+ cur = _running.get(task_id) or {}
336
+ if cur.get("status") == "running":
337
+ return False, "该任务已有自动发布进行中,请等本轮结束"
338
+ if as_draft:
339
+ other = other_batch_running(task_id)
340
+ if other:
341
+ return False, ("检测到另一批自动发布/存草稿进行中(任务 %s)——两边"
342
+ "共用平台浏览器会互踩,请等它跑完再存草稿" % other)
343
+ task = store.get_task(task_id)
344
+ if not task:
345
+ return False, "任务不存在"
346
+ from .. import contracts
347
+ contract = contracts.get(task_id)
348
+ if ((contract and contract.get("approval_required")) or task.get("approval_required")) \
349
+ and not contracts.release_allowed(task_id):
350
+ return False, "任务需要先完成审批,才能发布产物"
351
+ book = ledger.book_for(task_id, platform)
352
+ if not book:
353
+ return False, "该任务尚未在此平台建书,请先「创建作品」"
354
+ if not _book_ready(book):
355
+ return False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
356
+ if as_draft:
357
+ # 草稿不上线:质量闸不适用(同 manager 口径),硬护栏照查
358
+ quality = {"allowed": True, "status": "draft_only"}
359
+ else:
360
+ quality = manager._quality_release_guard(
361
+ task, platform, action="publish", force=force,
362
+ force_confirmed=force_confirmed, force_reason=force_reason)
363
+ if not quality.get("allowed"):
364
+ return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
365
+ ok, why = guards(task_id, platform)
366
+ if not ok:
367
+ return False, why
368
+ pend, err = pending(task_id, platform)
369
+ if err:
370
+ return False, err
371
+ want = None
372
+ if only is not None:
373
+ try:
374
+ want = {int(n) for n in (only or []) if n}
375
+ except (TypeError, ValueError):
376
+ return False, "chapters 必须是章号数组"
377
+ pend = [p for p in pend if p["chapter_no"] in want]
378
+ if not pend:
379
+ return False, ("所选章节都不在待发清单里(已发过,或成品里没有"
380
+ "对应章节文件);可先点「校准」对齐平台实况")
381
+ if not pend:
382
+ return False, "没有待发章节(全部已发布,或成品里没有可识别的章节文件)"
383
+
384
+ from pathlib import Path
385
+ wd = task.get("workdir") or ""
386
+ st = {"platform": platform, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
387
+ "done": 0, "total": len(pend), "status": "running", "error": "",
388
+ "auto_submit": bool(auto_submit), "as_draft": bool(as_draft),
389
+ "last_chapter": 0, "skipped": 0, "notes": []}
390
+ with _LOCK:
391
+ _running[task_id] = st
392
+
393
+ def run():
394
+ try:
395
+ items = pend
396
+ if as_draft:
397
+ # 起草前对账(2026-10-10 假成功案):台账只记得自己发的章,
398
+ # 手工补交的只有平台知道——attach-only 刷一次校准再重算待发,
399
+ # 已发布/已存稿章就地剔除,不去撞序号重复。对不了账(浏览器
400
+ # 未连接/忙)整个拒起:存草稿本来就需要平台浏览器,宁等空窗
401
+ # 不盲跑;平台没配章节计数时跳过对账维持旧路径。
402
+ ok_r, why_r = manager.sync_published(task_id, platform,
403
+ manual=False)
404
+ if not ok_r and "未配置章节计数" not in str(why_r or ""):
405
+ st["status"] = "error"
406
+ st["error"] = ("存草稿前对账失败:%s。为防把已发布章重复存稿,"
407
+ "本次未起草;请连接平台浏览器并等它空闲后重试"
408
+ % why_r)
409
+ return
410
+ fresh, _ = pending(task_id, platform)
411
+ if want is not None:
412
+ fresh = [p for p in fresh if p["chapter_no"] in want]
413
+ if not fresh:
414
+ st["status"] = "done"
415
+ st["message"] = ("对账后没有可存草稿的章节(所选均已发布或"
416
+ "已存稿);确需重存请先在平台删旧稿")
417
+ return
418
+ if len(fresh) != st["total"]:
419
+ st["notes"].append("对账剔除 %d 章已发布/已存稿,实存 %d 章"
420
+ % (st["total"] - len(fresh), len(fresh)))
421
+ st["total"] = len(fresh)
422
+ items = fresh
423
+ for item in items:
424
+ if as_draft:
425
+ other = other_batch_running(task_id)
426
+ if other:
427
+ st["status"] = "error"
428
+ st["error"] = ("检测到另一批自动发布已启动(任务 %s),"
429
+ "存草稿让位停止;已存 %d 章落平台草稿箱,"
430
+ "等对方跑完再续" % (other, st["done"]))
431
+ return
432
+ g_ok, why = guards(task_id, platform) # 每章前复查(中途也能拦)
433
+ if not g_ok:
434
+ st["status"] = "error"
435
+ st["error"] = "第 %d 章前护栏拦截:%s" % (item["chapter_no"], why)
436
+ return
437
+ upload_kwargs = {"auto_submit": auto_submit, "as_draft": as_draft}
438
+ if force or force_confirmed or force_reason:
439
+ upload_kwargs.update(force=force,
440
+ force_confirmed=force_confirmed,
441
+ force_reason=force_reason)
442
+ ok2, err2 = manager.upload_chapter_async(
443
+ task_id, platform, str(Path(wd) / item["file"]),
444
+ **upload_kwargs)
445
+ if not ok2 and as_draft and _is_idempotent_reject(err2):
446
+ # 幂等拒起=这章已发布/已存过稿:跳过,不是失败——
447
+ # 整链不该因「没活干」停摆
448
+ st["skipped"] += 1
449
+ st["notes"].append("第 %d 章跳过:%s"
450
+ % (item["chapter_no"], str(err2)[:60]))
451
+ continue
452
+ if not ok2 and as_draft and _is_transient(err2):
453
+ # 连接类瞬断(连载流互踩/浏览器抖动):重试一次再弃
454
+ st["notes"].append("第 %d 章连接中断,%d 秒后重试一次"
455
+ % (item["chapter_no"],
456
+ TRANSIENT_RETRY_S))
457
+ time.sleep(TRANSIENT_RETRY_S)
458
+ ok2, err2 = manager.upload_chapter_async(
459
+ task_id, platform, str(Path(wd) / item["file"]),
460
+ **upload_kwargs)
461
+ if not ok2:
462
+ st["status"] = "error"
463
+ st["error"] = "第 %d 章发起失败:%s" % (item["chapter_no"], err2)
464
+ return
465
+ if not _wait_idle(platform):
466
+ st["status"] = "error"
467
+ if as_draft:
468
+ st["error"] = ("第 %d 章存草稿等待超时(%.0f 分钟),后续"
469
+ "章节未存" % (item["chapter_no"],
470
+ IDLE_TIMEOUT_S / 60))
471
+ else:
472
+ st["error"] = ("第 %d 章发布等待超时(%.0f 分钟);若在等人工提交,"
473
+ "请提交后重跑剩余章节" % (item["chapter_no"],
474
+ IDLE_TIMEOUT_S / 60))
475
+ return
476
+ if as_draft:
477
+ # manager 走完草稿流程(含平台侧验证)才返回;台账落了
478
+ # upload_chapter_draft 才算数,防「流程跑完但平台没存上」。
479
+ if item["chapter_no"] not in ledger.drafted_chapters(
480
+ task_id, platform):
481
+ st["status"] = "error"
482
+ st["error"] = ("第 %d 章存草稿未确认(台账未落草稿账),"
483
+ "后续章节未存;截图存证见发布台账,若实际"
484
+ "已保存成功,把页面反馈文案照截图校准进"
485
+ "流程表 expect_text 步骤"
486
+ % item["chapter_no"])
487
+ return
488
+ st["done"] += 1
489
+ st["last_chapter"] = item["chapter_no"]
490
+ if st["done"] + st["skipped"] < st["total"]:
491
+ time.sleep(PACE_S)
492
+ continue
493
+ if not auto_submit:
494
+ # The manager stopped before the submit step. There is no
495
+ # remote success receipt until the user clicks submit.
496
+ st["last_chapter"] = item["chapter_no"]
497
+ st["status"] = "manual_pause"
498
+ st["message"] = ("第 %d 章已填好,请在浏览器里确认提交;"
499
+ "提交后请重新校准,再选择下一章(剩 %d 章)"
500
+ % (item["chapter_no"], st["total"] - st["done"]))
501
+ return
502
+ if item["chapter_no"] not in ledger.published_chapters(
503
+ task_id, platform):
504
+ st["status"] = "error"
505
+ st["error"] = ("第 %d 章发布失败,后续章节未发(详见发布台账与"
506
+ "截图存证)" % item["chapter_no"])
507
+ return
508
+ st["done"] += 1
509
+ st["last_chapter"] = item["chapter_no"]
510
+ if st["done"] < st["total"]:
511
+ time.sleep(PACE_S)
512
+ st["status"] = "done"
513
+ if as_draft:
514
+ st["message"] = ("已存草稿 %d 章%s(未发布,不受质量闸拦不代表可"
515
+ "直发);请到平台章节管理/草稿箱逐章检查后提交"
516
+ "发布"
517
+ % (st["done"], ("、跳过 %d 章" % st["skipped"])
518
+ if st["skipped"] else ""))
519
+ elif not auto_submit:
520
+ st["message"] = "第 %d 章已填好,请在浏览器里确认提交,再重新校准" % st["last_chapter"]
521
+ except Exception as e: # 线程内绝不能悬挂无终态
522
+ st["status"] = "error"
523
+ st["error"] = "自动发布异常:%s" % e
524
+ finally:
525
+ st["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
526
+
527
+ threading.Thread(target=run, daemon=True,
528
+ name="pub-auto-%s" % platform).start()
529
+ return True, ""
530
+
531
+
532
+ # ---------------------------------------------------------------- 定时联动(P2.5)
533
+ # 任务级标记 task.auto_publish = {enabled, platform, time:"HH:MM", auto_submit}
534
+ # automation._tick 每 25s 调 fire_due():到点且今日未触发 → publish_pending_async。
535
+ # 「今日已触发」记在 data/publish/auto_publish.json(task_id → YYYY-MM-DD):
536
+ # 触发过就不再重试当日(护栏/单飞自身也防重),成败都等明天——连败退避
537
+ # 场景下避免到点后每 25s 撞一次护栏。
538
+ _AP_FILE = None # paths.PUBLISH_DIR / "auto_publish.json"(导入惰性定)
539
+ _AP_FIRED = None # 内存缓存 {task_id: "YYYY-MM-DD"}
540
+ _AP_LOCK = threading.RLock() # 可重入:_mark_fired 持锁内调 _load_fired 再进同锁
541
+
542
+
543
+ def _ap_file():
544
+ global _AP_FILE
545
+ if _AP_FILE is None:
546
+ from .. import paths
547
+ _AP_FILE = paths.PUBLISH_DIR / "auto_publish.json"
548
+ return _AP_FILE
549
+
550
+
551
+ def _load_fired():
552
+ global _AP_FIRED
553
+ with _AP_LOCK:
554
+ if _AP_FIRED is None:
555
+ try:
556
+ import json
557
+ d = json.loads(_ap_file().read_text(encoding="utf-8"))
558
+ _AP_FIRED = d if isinstance(d, dict) else {}
559
+ except Exception:
560
+ _AP_FIRED = {}
561
+ return _AP_FIRED
562
+
563
+
564
+ def _mark_fired(task_id, day):
565
+ import json
566
+ with _AP_LOCK:
567
+ fired = _load_fired()
568
+ fired[str(task_id)] = day
569
+ try:
570
+ _ap_file().parent.mkdir(parents=True, exist_ok=True)
571
+ tmp = _ap_file().with_suffix(".tmp")
572
+ tmp.write_text(json.dumps(fired, ensure_ascii=False, indent=1),
573
+ encoding="utf-8")
574
+ tmp.replace(_ap_file())
575
+ except Exception:
576
+ pass # 记录失败:最坏是当日重复触发,护栏会拦
577
+
578
+
579
+ def norm_auto_publish(ap):
580
+ """校验并归一 auto_publish 配置。非法返回 (None, 人话原因)。"""
581
+ from . import manager
582
+ if not isinstance(ap, dict):
583
+ return None, "auto_publish 必须是对象"
584
+ platform = str(ap.get("platform") or "").strip()
585
+ if platform not in manager.PLATFORMS:
586
+ return None, "platform 必须是 fanqie 或 qimao"
587
+ hhmm = str(ap.get("time") or "").strip()
588
+ import re
589
+ m = re.match(r"^(\d{1,2}):(\d{2})$", hhmm)
590
+ if not m:
591
+ return None, "time 必须是 24 小时制 HH:MM"
592
+ h, mi = int(m.group(1)), int(m.group(2))
593
+ if not (0 <= h <= 23 and 0 <= mi <= 59):
594
+ return None, "time 超出 0-23:00-59"
595
+ return {"enabled": bool(ap.get("enabled")),
596
+ "platform": platform,
597
+ "time": "%02d:%02d" % (h, mi),
598
+ "auto_submit": bool(ap.get("auto_submit")),
599
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}, ""
600
+
601
+
602
+ def due_tasks(now=None):
603
+ """到期待触发的定时发布任务清单:[(task, auto_publish)]。
604
+
605
+ 条件:enabled + 已在该平台建书 + 今日未触发 + 当前时间已过当日 time。
606
+ 未建书的任务跳过(触发也只会被 publish_pending_async 拒绝,白记一次
607
+ fired 反而把当天额度烧掉)。"""
608
+ from .. import store
609
+ from . import ledger
610
+ now = now or time.localtime()
611
+ today = time.strftime("%Y-%m-%d", now)
612
+ hhmm_now = time.strftime("%H:%M", now)
613
+ fired = _load_fired()
614
+ out = []
615
+ for task in store.list_tasks(limit=10 ** 9):
616
+ ap = task.get("auto_publish")
617
+ if not isinstance(ap, dict) or not ap.get("enabled"):
618
+ continue
619
+ if ap.get("auto_submit"):
620
+ from .. import contracts
621
+ contract = contracts.get(task["id"])
622
+ if ((contract and contract.get("approval_required")) or
623
+ task.get("approval_required")) and not contracts.release_allowed(task["id"]):
624
+ continue
625
+ if str(ap.get("time") or "") <= hhmm_now and fired.get(task["id"]) != today:
626
+ plat = ap.get("platform")
627
+ if plat and _book_ready(ledger.book_for(task["id"], plat)):
628
+ out.append((task, ap))
629
+ return out
630
+
631
+
632
+ def fire_due(now=None):
633
+ """tick 入口:把所有到期的定时发布触发一轮。返回触发条数;单条异常不拖累其余。"""
634
+ from . import ledger # 惰性导入(同模块惯例):漏了会 NameError
635
+ now = now or time.localtime() # 且被双层 except 双重静默成幽灵 0
636
+ today = time.strftime("%Y-%m-%d", now)
637
+ n = 0
638
+ for task, ap in due_tasks(now=now):
639
+ try:
640
+ ok, err = publish_pending_async(
641
+ task["id"], ap.get("platform"),
642
+ auto_submit=bool(ap.get("auto_submit")))
643
+ _mark_fired(task["id"], today) # 成败都记:当日不重试
644
+ if ok:
645
+ ledger.record(ap.get("platform"), "auto_fire", task_id=task["id"],
646
+ title="定时触发 %s" % (ap.get("time") or ""), ok=True)
647
+ n += 1
648
+ else:
649
+ ledger.record(ap.get("platform"), "auto_fire", task_id=task["id"],
650
+ title="定时触发 %s" % (ap.get("time") or ""),
651
+ ok=False, error=str(err)[:200])
652
+ except Exception as e:
653
+ try:
654
+ _mark_fired(task["id"], today)
655
+ ledger.record(ap.get("platform"), "auto_fire", task_id=task["id"],
656
+ ok=False, error=str(e)[:200])
657
+ except Exception:
658
+ pass
659
+ return n