codebee 0.1.95 → 0.1.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +725 -720
  2. package/LICENSE +21 -21
  3. package/README.md +515 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +100 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +209 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +1680 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +143 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mocks.py +64 -64
  46. package/app/core/notify.py +201 -201
  47. package/app/core/novel_quality.py +181 -189
  48. package/app/core/openapi.py +177 -154
  49. package/app/core/operations.py +228 -228
  50. package/app/core/paihang.py +145 -145
  51. package/app/core/pipeline.py +43 -19
  52. package/app/core/planner.py +17 -11
  53. package/app/core/policy.py +52 -0
  54. package/app/core/portguard.py +111 -111
  55. package/app/core/presence.py +77 -77
  56. package/app/core/preview.py +293 -293
  57. package/app/core/prompt_guard.py +78 -78
  58. package/app/core/publish/__init__.py +7 -7
  59. package/app/core/publish/auto.py +26 -3
  60. package/app/core/publish/browser.py +655 -655
  61. package/app/core/publish/chaprows.py +138 -0
  62. package/app/core/publish/fanqie.py +210 -165
  63. package/app/core/publish/flow.py +753 -640
  64. package/app/core/publish/flows-fanqie-calibrated.json +349 -336
  65. package/app/core/publish/ledger.py +13 -1
  66. package/app/core/publish/manager.py +1150 -1118
  67. package/app/core/publish/qimao.py +176 -176
  68. package/app/core/publish/volumes.py +108 -0
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/quality_gate.py +194 -194
  71. package/app/core/redact.py +38 -38
  72. package/app/core/repeat_guard.py +129 -129
  73. package/app/core/retrieval.py +137 -137
  74. package/app/core/router.py +398 -398
  75. package/app/core/runner.py +2238 -2238
  76. package/app/core/selfupdate.py +384 -384
  77. package/app/core/session_log.py +161 -161
  78. package/app/core/sessions.py +312 -312
  79. package/app/core/settings.py +20 -0
  80. package/app/core/settings_schema.py +270 -270
  81. package/app/core/share_page.py +114 -114
  82. package/app/core/skill_scan.py +139 -139
  83. package/app/core/skillpacks/fanqie-novel.md +8 -6
  84. package/app/core/skillpacks/market/character-bible.md +66 -66
  85. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  86. package/app/core/skillpacks/market/git-workflow.md +57 -57
  87. package/app/core/skillpacks/market/release-notes.md +72 -72
  88. package/app/core/skillpacks/market/weekly-report.md +71 -71
  89. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  90. package/app/core/skillpacks/qimao-signing.md +14 -11
  91. package/app/core/skills.py +32 -21
  92. package/app/core/step_runner.py +132 -132
  93. package/app/core/store.py +9 -0
  94. package/app/core/story_tracking.py +275 -275
  95. package/app/core/task_compile.py +199 -199
  96. package/app/core/telemetry.py +292 -292
  97. package/app/core/tlsctx.py +64 -64
  98. package/app/core/token_meter.py +147 -147
  99. package/app/core/tracing.py +207 -144
  100. package/app/core/upstream.py +41 -41
  101. package/app/core/usage.py +1038 -1038
  102. package/app/core/volumes.py +389 -389
  103. package/app/core/webhooks.py +89 -0
  104. package/app/core/wxdigest.py +858 -858
  105. package/app/core/zentao.py +2692 -2692
  106. package/app/main.py +104 -9
  107. package/app/mcp_server.py +244 -244
  108. package/app/pick_dialog.py +110 -110
  109. package/app/ui/app.js +286 -18
  110. package/app/ui/artdiff.js +74 -74
  111. package/app/ui/assets/community-qr.png +0 -0
  112. package/app/ui/assets/donate-alipay.jpg +0 -0
  113. package/app/ui/assets/donate-qr.jpg +0 -0
  114. package/app/ui/board.html +162 -162
  115. package/app/ui/board.js +305 -305
  116. package/app/ui/i18n.js +33 -0
  117. package/app/ui/icons/bee.svg +79 -79
  118. package/app/ui/index.html +32 -3
  119. package/app/ui/manifest.json +16 -16
  120. package/app/ui/qrcode.js +2297 -2297
  121. package/app/ui/style.css +196 -2
  122. package/bin/tutti.js +147 -147
  123. package/package.json +48 -48
  124. package/tools/wx_reader.py +261 -261
  125. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  126. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  127. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  128. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  129. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  131. package/app/.playwright-cli/run-detail-after.png +0 -0
  132. package/app/output/_createbook_body.json +0 -1
  133. package/app/output/_probe_fq_click.py +0 -50
  134. package/app/output/_probe_fq_form.py +0 -42
  135. package/app/output/_probe_fq_modal.py +0 -55
  136. package/app/output/_probe_fq_points.py +0 -71
  137. package/app/output/_probe_fq_submit.py +0 -34
  138. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,1118 +1,1150 @@
1
- # -*- coding: utf-8 -*-
2
- """发布管理:平台会话(attach-or-launch)、登录状态机、建书/发章后台线程。
3
-
4
- 状态机(data/publish/state.json 的 platforms.<id>.status):
5
- none 从未连接
6
- waiting_login 浏览器已开等用户扫码(connect 后,轮询 15 分钟)
7
- connected 登录检测通过
8
- busy 发布动作进行中(结束回 connected / error)
9
- error 最后一次动作失败(error 字段带人话原因,可重试)
10
-
11
- 浏览器生命周期:每平台一个持久化 profile(~/.codebee/publish_profiles/<id>,
12
- 仓库外——GB 级浏览器运行时数据不进仓库目录),登录态落在 profile 里。
13
- 服务重启后按 state.json 记住的调试端口 attach 旧实例;实例已死才重新
14
- launch——用户登录一次,之后无感。
15
-
16
- 与 bookmeta.generate_async 同款线程纪律:动作起后台线程即返回,前端靠
17
- view()(SSE/轮询)看进度;线程内任何异常都落终态,绝不悬挂。
18
- """
19
- from __future__ import annotations
20
-
21
- import json
22
- import re
23
- import threading
24
- import time
25
-
26
- from .. import operations, paths, quality_gate
27
- from . import fanqie, flow, ledger, qimao
28
- from .browser import Browser, BrowserError, Page
29
-
30
- PLATFORMS = {"fanqie": fanqie, "qimao": qimao}
31
- LOGIN_WAIT_S = 15 * 60 # 扫码等待窗口
32
- STATUS_FILE = paths.PUBLISH_DIR / "state.json"
33
-
34
- LOCK = threading.RLock()
35
- _state = {"platforms": {}} # {plat: {status, port, at, error, last_login, last_action}}
36
- _browsers = {} # plat → Browser(进程内缓存,alive 校验兜底)
37
-
38
-
39
- # ---------------------------------------------------------------- 状态
40
- def _load():
41
- global _state
42
- try:
43
- d = json.loads(STATUS_FILE.read_text(encoding="utf-8"))
44
- if isinstance(d, dict) and isinstance(d.get("platforms"), dict):
45
- _state = d
46
- return
47
- except Exception:
48
- pass
49
- _state = {"platforms": {}}
50
-
51
-
52
- def _save():
53
- with LOCK:
54
- try:
55
- paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
56
- tmp = STATUS_FILE.with_suffix(".tmp")
57
- tmp.write_text(json.dumps(_state, ensure_ascii=False, indent=1),
58
- encoding="utf-8")
59
- tmp.replace(STATUS_FILE)
60
- except Exception:
61
- pass
62
-
63
-
64
- def _set(plat, **kv):
65
- with LOCK:
66
- ent = _state["platforms"].setdefault(plat, {})
67
- ent.update(kv)
68
- ent["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
69
- _save()
70
-
71
-
72
- def _st(plat):
73
- return (_state.get("platforms") or {}).get(plat) or {}
74
-
75
-
76
- def _quality_release_guard(task, plat, action="publish", target_chapter=None,
77
- force=False, force_confirmed=False, force_reason=""):
78
- """Return a release decision backed by the latest completed review.
79
-
80
- Non-fiction/code tasks keep their existing publish workflow; novel release
81
- actions must carry a verifiable quality verdict. This keeps the gate
82
- focused on the signing failure mode without breaking generic file publish.
83
- """
84
- if str(task.get("type") or "").lower() not in ("novel", "serial_novel") \
85
- and (task.get("engine") or "") != "review":
86
- return {"allowed": True, "status": "not_applicable", "blockers": [],
87
- "warnings": [], "forced": False}
88
- from .. import store
89
- verdict = None
90
- for run in sorted(store.task_runs(task.get("id")) or [],
91
- key=lambda item: str(item.get("id") or ""), reverse=True):
92
- if run.get("status") == "done" and isinstance(run.get("verdict"), dict):
93
- verdict = run["verdict"]
94
- break
95
- decision = quality_gate.evaluate_release(
96
- verdict or {}, action=action, target_chapter=target_chapter,
97
- platform_version=ledger.book_for(task.get("id"), plat) or {},
98
- force=force, force_confirmed=force_confirmed, force_reason=force_reason)
99
- decision["version_snapshot"] = quality_gate.local_version(task.get("workdir") or "")
100
- decision["platform_snapshot"] = ledger.book_for(task.get("id"), plat) or {}
101
- if verdict:
102
- decision["quality_status"] = verdict.get("quality_status") or "continue_only"
103
- decision["review_run_id"] = run.get("id")
104
- return decision
105
-
106
-
107
- def view():
108
- """前端状态视图:平台状态 + 是否找到浏览器 + 作品登记。"""
109
- from .browser import find_browser
110
- out = {}
111
- for pid, mod in PLATFORMS.items():
112
- s = _st(pid)
113
- out[pid] = {"label": mod.CONFIG["label"], "status": s.get("status") or "none",
114
- "at": s.get("at") or "", "error": s.get("error") or "",
115
- "last_action": s.get("last_action") or "",
116
- "profile": str(_profile_dir(pid))}
117
- return {"platforms": out, "browser_found": bool(find_browser())}
118
-
119
-
120
- def recover_orphans():
121
- """启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。
122
-
123
- 升级自愈:旧版「等扫码窗口一关就冤判超时」留下的 error(error 文案带
124
- 「等待登录超时」)排队后台复核——profile 登录态还在的直接翻 connected,
125
- 别让升级完还挂着旧冤案;attach 不到活实例就维持原样(用户点重连时
126
- 新终审逻辑自会兜住)。"""
127
- _load()
128
- n = 0
129
- stale = []
130
- for plat in PLATFORMS:
131
- s = _st(plat)
132
- if s.get("status") in ("waiting_login", "busy"):
133
- _set(plat, status="error", error="上次操作随服务重启中断,请重试")
134
- n += 1
135
- elif s.get("status") == "error" and "等待登录超时" in (s.get("error") or ""):
136
- stale.append(plat)
137
- if stale:
138
- threading.Thread(target=_recheck_stale_errors, args=(stale,),
139
- daemon=True, name="pub-stale-recheck").start()
140
- return n
141
-
142
-
143
- def _recheck_stale_errors(plats):
144
- """旧版假超时的后台复核:只 attach 还活着的浏览器实例(绝不 launch——
145
- 升级重启时静默弹窗口吓人),登录态复核过了翻 connected;否则不动。"""
146
- for plat in plats:
147
- if _st(plat).get("status") != "error":
148
- continue # 用户已先行操作(重连/断开),别覆盖
149
- port = _st(plat).get("port") or 0
150
- if not port:
151
- continue
152
- try:
153
- b = Browser.attach(int(port))
154
- except Exception:
155
- continue # 实例已死:不 launch,维持原错误
156
- with LOCK:
157
- _browsers[plat] = b
158
- page = b.first_page(create=False)
159
- if page is None:
160
- continue
161
- try:
162
- ok, _u = _check_login(plat, page)
163
- except Exception:
164
- continue
165
- if ok and _st(plat).get("status") == "error":
166
- _set(plat, status="connected", error="",
167
- last_login=time.strftime("%m-%d %H:%M"))
168
- ledger.record(plat, "connect", ok=True)
169
-
170
-
171
- # ---------------------------------------------------------------- 浏览器会话
172
- def _profiles_base():
173
- """profile 存放根:用户主目录 ~/.codebee/publish_profiles(仓库外)。
174
-
175
- 浏览器 profile 是 GB 级运行时数据(缓存/扩展/字体),放仓库 data/ 里
176
- 会拖垮静态扫描/备份(639M→986M 实测淹没整仓安全扫描),且登录态
177
- cookie 没必要进任何仓库周边流程。"""
178
- from pathlib import Path as _P
179
- return _P.home() / ".codebee" / "publish_profiles"
180
-
181
-
182
- def _migrate_legacy_profiles():
183
- """一次性迁移:老位置 data/publish/profiles/<plat> 整体搬到新根(保登录态)。
184
-
185
- 新位置已有同名平台目录时跳过(老数据视为已废弃);搬家失败静默——
186
- 大不了用户重扫一次码。"""
187
- from pathlib import Path as _P
188
- legacy = _P(paths.PUBLISH_DIR) / "profiles"
189
- if not legacy.is_dir():
190
- return
191
- base = _profiles_base()
192
- try:
193
- base.mkdir(parents=True, exist_ok=True)
194
- except OSError:
195
- return
196
- for plat_dir in legacy.iterdir():
197
- if not plat_dir.is_dir():
198
- continue
199
- dst = base / plat_dir.name
200
- if dst.exists():
201
- continue
202
- try:
203
- import shutil
204
- shutil.move(str(plat_dir), str(dst)) # 跨盘(仓库盘→系统盘)也能搬
205
- except OSError:
206
- continue
207
- try:
208
- legacy.rmdir() # 空了才删得掉;还有残留就留给下次
209
- except OSError:
210
- pass
211
-
212
-
213
- def _profile_dir(plat):
214
- return _profiles_base() / plat
215
-
216
-
217
- def _ensure_browser(plat):
218
- """attach-or-launch:进程内缓存 → 记住的端口 → 全新 launch。"""
219
- with LOCK:
220
- b = _browsers.get(plat)
221
- if b and b.alive():
222
- return b
223
- port = _st(plat).get("port") or 0
224
- if port:
225
- try:
226
- b = Browser.attach(int(port))
227
- _browsers[plat] = b
228
- return b
229
- except BrowserError:
230
- pass # 旧实例已死:走 launch
231
- b = Browser(_profile_dir(plat)) # 有窗口:用户要扫码/人工确认
232
- _browsers[plat] = b
233
- _set(plat, port=b.port)
234
- return b
235
-
236
-
237
- def _open_page(plat):
238
- b = _ensure_browser(plat)
239
- # 单标签纪律:平台点击(如「上传章节」)会开新 tab,流程引擎的 page
240
- # 对象可能留在旧 tab 上填错页面(0 字草稿案的乱源)。每次动作前收拢
241
- # 到一个 tab——发布是串行作业,多 tab 只会串台。
242
- try:
243
- for t in b.pages()[1:]:
244
- try:
245
- from .browser import _http_json
246
- _http_json("http://127.0.0.1:%d/json/close/%s" % (b.port, t["id"]))
247
- except Exception:
248
- pass
249
- except Exception:
250
- pass
251
- return b, b.first_page(create=True)
252
-
253
-
254
- def _check_login(plat, page):
255
- """打开后台首页看 URL 是否被踢到登录页。返回 (ok, 当前url)。
256
-
257
- 导航失败页(chrome-error://)不含登录标记,曾把「域名打不开」误判成
258
- 已登录——假 connected 的来源,先排除。"""
259
- mod = PLATFORMS[plat]
260
- try:
261
- page.navigate(mod.CONFIG["home"], timeout=30)
262
- except BrowserError as e:
263
- return False, str(e)
264
- url = str(page.url() or "")
265
- if url.startswith("chrome-error://") or url.startswith("about:"):
266
- return False, url # 页面没打开:网络/域名问题,不是登录态
267
- if any(m in url for m in mod.CONFIG["login_url_marks"]):
268
- return False, url
269
- return True, url
270
-
271
-
272
- # ---------------------------------------------------------------- 流程加载
273
- def load_flow(plat, action):
274
- """流程表三层:data/publish/flows-<plat>.json(用户校准)→ 仓库校准模板
275
- flows-<plat>-calibrated.json(真机验证过的基线,随代码分发)→ 平台模块
276
- 内置默认(待校准推测)。"""
277
- from pathlib import Path
278
- for fp in (paths.PUBLISH_DIR / ("flows-%s.json" % plat),
279
- Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
280
- try:
281
- data = json.loads(fp.read_text(encoding="utf-8"))
282
- if isinstance(data, dict) and isinstance(data.get(action), list):
283
- return data[action]
284
- except Exception:
285
- pass
286
- return PLATFORMS[plat].FLOWS[action]
287
-
288
-
289
- # ---------------------------------------------------------------- 动作:连接
290
- def connect(plat):
291
- """开浏览器到平台首页,起后台线程轮询登录态(等扫码)。"""
292
- if plat not in PLATFORMS:
293
- return False, "未知平台"
294
- try:
295
- b, page = _open_page(plat)
296
- except BrowserError as e:
297
- _set(plat, status="error", error=str(e))
298
- return False, str(e)
299
- page.navigate(PLATFORMS[plat].CONFIG["home"])
300
- _set(plat, status="waiting_login", error="")
301
-
302
- def wait_login():
303
- mod = PLATFORMS[plat]
304
- deadline = time.time() + LOGIN_WAIT_S
305
- while time.time() < deadline:
306
- try:
307
- url = str(page.url() or "")
308
- if url and not any(m in url for m in mod.CONFIG["login_url_marks"]) \
309
- and "about:blank" not in url and "chrome-error" not in url:
310
- # 用户登录完成(离开登录页)。再主动开一次首页做复核,
311
- # 复核被踢回登录页说明只是中间跳转,继续等。
312
- ok, _u = _check_login(plat, page)
313
- if ok:
314
- _set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
315
- ledger.record(plat, "connect", ok=True)
316
- return
317
- except (BrowserError, Exception):
318
- pass # 页面被用户关掉等:继续等到超时
319
- time.sleep(5)
320
- _finalize_login_wait(plat)
321
-
322
- threading.Thread(target=wait_login, daemon=True,
323
- name="pub-login-%s" % plat).start()
324
- return True, ""
325
-
326
-
327
- def _finalize_login_wait(plat):
328
- """等扫码超时后的终审:用户可能已在本窗口登录后把窗口关了(profile
329
- 里登录态还在),attach-or-launch 重拉页面再复核一次,别急着冤判
330
- 超时——「明明登录着却报超时」是发布卡死感的最大来源。复核也过不了
331
- 才落超时错误。返回 True 表示复核通过(已判 connected)。"""
332
- try:
333
- _b, page = _open_page(plat)
334
- ok, _u = _check_login(plat, page)
335
- except Exception:
336
- ok = False
337
- if ok:
338
- _set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
339
- ledger.record(plat, "connect", ok=True)
340
- else:
341
- _set(plat, status="error", error="等待登录超时(15 分钟),请重新点连接")
342
- return ok
343
-
344
-
345
- def disconnect(plat):
346
- b = _browsers.pop(plat, None)
347
- if b:
348
- b.close()
349
- _set(plat, status="none", error="")
350
- return True
351
-
352
-
353
- # ---------------------------------------------------------------- 动作:探测
354
- def probe_form_async(plat):
355
- """「探测表单」:跑 probe 流程把页面真实可交互元素 dump 进台账 log。"""
356
- if plat not in PLATFORMS:
357
- return False, "未知平台"
358
- if _st(plat).get("status") not in ("connected", "error"):
359
- return False, "请先连接并登录平台"
360
- _set(plat, status="busy", last_action="probe", error="")
361
-
362
- logs = []
363
-
364
- def run():
365
- try:
366
- b, page = _open_page(plat)
367
- flow.run_flow(page, load_flow(plat, "probe_form"),
368
- values={}, config=PLATFORMS[plat].CONFIG,
369
- shot=lambda n: page.screenshot(ledger.shot_path(plat, "probe", n)),
370
- log=logs.append)
371
- _set(plat, status="connected")
372
- ledger.record(plat, "probe", ok=True,
373
- error="\n".join(logs)[:2000])
374
- except Exception as e:
375
- _set(plat, status="error", error="探测失败:%s" % e)
376
- ledger.record(plat, "probe", ok=False, error=str(e)[:300])
377
- finally:
378
- _save()
379
-
380
- threading.Thread(target=run, daemon=True, name="pub-probe-%s" % plat).start()
381
- return True, ""
382
-
383
-
384
- # ---------------------------------------------------------------- 动作:建书
385
- _ALT_COUNT = 3 # 撞名后自动换名最多试 3 个候选
386
- _ALT_TIMEOUT = 90 # 候选书名一次轻调用的墙钟(发布线程内,不能久等)
387
- _TITLE_MAX = 15 # 两平台书名统一截 15 字(bookmeta._norm 同口径)
388
-
389
-
390
- def _mech_titles(base):
391
- """机械变体兜底(模型不可用时的最后手段):最小改动、不重构词——
392
- 同音字(了→啦)、去「我的」前缀、加「纪事」尾。丑但能建成书,
393
- 烧在发布线程里的几万字稿费比书名门面贵得多。"""
394
- out, seen = [], {base}
395
- for v in (base.replace("了", "啦", 1),
396
- base[2:] if base.startswith("我的") else "",
397
- (base + "纪事") if len(base) <= 13 else ""):
398
- v = str(v or "").strip()[:_TITLE_MAX]
399
- if len(v) >= 4 and v not in seen:
400
- out.append(v)
401
- seen.add(v)
402
- return out
403
-
404
-
405
- def _alt_titles(plat, data, task, logs):
406
- """撞名候选队列:编排者基于原名+简介一次轻调用重出书名,失败静默
407
- 降级机械变体——候选生成绝不能阻断建书主流程。"""
408
- base = str(data.get("book_name") or "").strip()
409
- if not base:
410
- return []
411
- out = []
412
- try:
413
- from .. import modelhub, planner, runner
414
- orch = modelhub.resolve_orchestrator()
415
- if orch:
416
- prov, model = orch
417
- prompt = (
418
- "你是网文编辑。原书名《%s》在平台重名被拒,请为同一本书重起"
419
- " %d 个新书名:每个 15 字内;保持原题材气质与钩子感;避开原名"
420
- "的核心词组合与「我的XX藏不住」式高频套路组合;书名里带一个本"
421
- "书独有的专名(人名/金手指/设定词)最不易再撞。只输出一个 "
422
- "```json 字符串数组,如 [\"书名1\",\"书名2\"],不要输出其它内容。"
423
- "\n\n## 简介(截选)\n%s"
424
- % (base, _ALT_COUNT + 2, str(data.get("summary") or "")[:300]))
425
- res = modelhub.chat(prov["id"], model, prompt,
426
- max_tokens=600, timeout=_ALT_TIMEOUT)
427
- planner._log_usage("publish", "create_book", task or {"id": ""}, res,
428
- model=model,
429
- provider=prov.get("name", prov.get("id", "")),
430
- provider_id=prov.get("id", ""))
431
- if res["ok"]:
432
- arr = runner.extract_json(res.get("text") or "")
433
- if isinstance(arr, dict):
434
- arr = arr.get("titles") or arr.get("candidates") or []
435
- if isinstance(arr, list):
436
- for x in arr:
437
- t = str(x or "").strip().replace("\n", " ")[:_TITLE_MAX]
438
- if len(t) >= 2 and t != base and t not in out:
439
- out.append(t)
440
- if out:
441
- logs.append("候选书名(编排者 %s):%s"
442
- % (model, "、".join(out[:5])))
443
- except Exception as e:
444
- logs.append("候选书名生成降级(%s)" % str(e)[:100])
445
- for v in _mech_titles(base):
446
- if v not in out:
447
- out.append(v)
448
- return out[:_ALT_COUNT + 2]
449
-
450
-
451
- def _sync_renamed_book(task_id, plat, task, data, new_name, logs):
452
- """建书撞名自动换名后的全链路同步。
453
-
454
- 只换建书表单里的字、bookmeta/大纲/归档还留旧名的话:下轮发章按旧书名
455
- 对账、用户点「重生成作品信息」又把旧名改回去——马甲案同款错位复发。
456
- 这里把 bookmeta data、归档 Markdown、最新批次大纲的 book_title 统一到
457
- 新名(台账 title 由调用方 save_book 落)。任一环节失败只记日志,
458
- 不拦建书成功主流程。"""
459
- try:
460
- from pathlib import Path as _Path
461
- from .. import bookmeta as _bm
462
- from .. import store as _store
463
- data["book_name"] = new_name
464
- cur = _store.get_task(task_id)
465
- prev = ((cur or {}).get("book_meta") or {}).get(plat) or {}
466
- if isinstance(prev.get("data"), dict):
467
- payload = dict(prev)
468
- payload["data"] = dict(prev["data"], book_name=new_name)
469
- _store.set_book_meta(task_id, plat, payload)
470
- fp = _Path(task.get("workdir") or "") / _bm.PLATFORMS[plat]["file"]
471
- if str(fp.parent) and fp.parent.is_dir():
472
- fp.write_text(_bm.render_markdown(cur, plat, payload["data"]),
473
- encoding="utf-8")
474
- logs.append("作品信息与归档已同步新书名《%s》" % new_name)
475
- # 大纲书名:续写链继承与「一键重生成」素材都读最新批次大纲,
476
- # 不同步会让下次重生成把书名改回旧名
477
- for r in _store.task_runs(task_id):
478
- o = r.get("outline")
479
- if isinstance(o, dict) and o.get("chapters") and o.get("book_title"):
480
- _store.update_run(r["id"], outline=dict(o, book_title=new_name))
481
- logs.append("大纲书名已同步为《%s》" % new_name)
482
- break
483
- except Exception as e:
484
- logs.append("书名同步部分失败(不拦建书):%s" % str(e)[:120])
485
-
486
-
487
- def _create_preflight(plat, data):
488
- """建书前置闸:平台表单对必填字段有硬校验(番茄简介 50-500 字),过不了
489
- 校验时页面不跳转、流程只能误报「未登录或改版」——开浏览器之前先拦下,
490
- 把人话原因还给用户。返回错误文案,空串=放行。"""
491
- placeholders = {"待补充", "(待补充)", "(待补充)"}
492
- title = str(data.get("book_name") or "").strip()
493
- if not title or title in placeholders:
494
- return "作品名称为空,请先补书名再点「创建作品」"
495
- summary = str(data.get("summary") or "").strip()
496
- if plat == "fanqie":
497
- if len(summary) < 50 or len(summary) > 500:
498
- return ("作品简介 %d 字,番茄建书表单要求 50-500 字;"
499
- "请先补简介再点「创建作品」" % len(summary))
500
- if not str(data.get("category") or "").strip():
501
- return "作品分类为空,请先重生成作品信息再建书"
502
- for key in ("tags_theme", "tags_role", "tags_plot"):
503
- if not (data.get(key) or []):
504
- return "作品标签(%s)为空,请先重生成作品信息再建书" % key
505
- elif plat == "qimao":
506
- if not summary or summary in placeholders:
507
- return "作品简介为空,请先补简介再点「创建作品」"
508
- for key in ("category_main", "category_sub"):
509
- if not str(data.get(key) or "").strip():
510
- return "作品分类为空,请先重生成作品信息再建书"
511
- for key in ("tags_style", "tags_role", "tags_plot", "tags_bg"):
512
- if not (data.get(key) or []):
513
- return "作品标签(%s)为空,请先重生成作品信息再建书" % key
514
- return ""
515
-
516
-
517
- def create_book_async(task_id, plat, auto_submit=False, force=False,
518
- force_confirmed=False, force_reason=""):
519
- """按任务 book_meta 的资料在平台建书。返回 (ok, err)。"""
520
- from .. import store
521
- if plat not in PLATFORMS:
522
- return False, "未知平台"
523
- task = store.get_task(task_id)
524
- if not task:
525
- return False, "任务不存在"
526
- # 连载链沿链继承(都是用第一个):续写批次从自己身上建书也用首批生成的资料
527
- meta = store.inherited_book_meta(task, plat)
528
- if meta.get("status") != "done" or not meta.get("data"):
529
- return False, "请先生成该平台的作品信息"
530
- if _st(plat).get("status") == "busy":
531
- return False, "该平台有操作正在进行中"
532
- existing = ledger.book_for(task_id, plat)
533
- # A title-only manual registration is usable as a binding. For legacy
534
- # entries without source metadata, only a recorded failed create-book
535
- # attempt is safe to retry; otherwise it may be a real manual binding.
536
- if existing:
537
- if existing.get("book_id") or existing.get("source") == "manual":
538
- return False, "该任务已在此平台登记过作品,请直接发章"
539
- failed_create = any(
540
- r.get("action") == "create_book" and not r.get("ok")
541
- for r in ledger.recent(task_id=task_id, platform=plat, limit=50))
542
- if not failed_create:
543
- return False, "该任务已有作品登记但未确认,请登记已有作品或先在平台核对"
544
- why = _create_preflight(plat, meta["data"])
545
- if why:
546
- return False, why
547
- quality = _quality_release_guard(
548
- task, plat, action="publish", force=force,
549
- force_confirmed=force_confirmed, force_reason=force_reason)
550
- if not quality.get("allowed"):
551
- return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
552
- ok_login, why = _login_guard(plat)
553
- if not ok_login:
554
- return False, why
555
- _set(plat, status="busy", last_action="create_book", error="")
556
-
557
- data = meta["data"]
558
- mod = PLATFORMS[plat]
559
- logs = []
560
- book_name = (data.get("book_name") or "").strip()
561
- operation_id = operations.begin(
562
- "publish:%s:create_book" % plat,
563
- {"task_id": task_id, "title": book_name}, task_id=task_id,
564
- metadata={"platform": plat, "action": "create_book"})
565
-
566
- def run():
567
- final_name = book_name
568
- try:
569
- b, page = _open_page(plat)
570
- # 书名全平台唯一(2026-09-30 马甲案):撞名是建书常见死法,撞了
571
- # 原样报「疑似未建成」会白烧几万字稿费。试名队列=原名+自动候选
572
- # (编排者重出→机械变体兜底);同名书恰在本账号下时对账复用不换
573
- # 名。人工提交模式(auto_submit=false)不自动换名——用户在页面
574
- # 上自己点提交,撞名他自己看得见。
575
- names = [book_name]
576
- cands_ready = False
577
- book_id = ""
578
- i = 0
579
- while i < len(names) and i < 1 + _ALT_COUNT + 2:
580
- name = names[i]
581
- i += 1
582
- final_name = name
583
- if i > 1:
584
- logs.append("第 %d 次尝试,书名《%s》" % (i, name))
585
- values = mod.values_create_book(dict(data, book_name=name))
586
- steps = _with_tag_steps(load_flow(plat, "create_book"),
587
- mod.tag_groups(data), values, mod)
588
- try:
589
- flow.run_flow(page, steps, values=values, config=mod.CONFIG,
590
- auto_submit=auto_submit, dup_check=auto_submit,
591
- shot=lambda n: page.screenshot(
592
- ledger.shot_path(plat, task_id, n)),
593
- log=logs.append)
594
- except flow.TitleDupError as e:
595
- # 事前预检命中:平台实时校验已喊重名,点提交必被拒
596
- logs.append(str(e))
597
- bid = _resolve_book_id(plat, page, {"title": name})
598
- if bid:
599
- book_id, final_name = bid, name
600
- logs.append("同名书就在本账号下,已复用 book_id=%s" % bid)
601
- break
602
- if not cands_ready:
603
- names = names + _alt_titles(plat, data, task, logs)
604
- cands_ready = True
605
- if i >= len(names):
606
- break # 候选没准备出来(模型+机械全空)
607
- continue
608
- # book_id:创建成功后平台跳书籍详情/编辑器,从 URL 提取
609
- # (番茄 book-info/<id>;七猫 information?id=<id>)。url_any 过了
610
- # 但页面还在跳转链上时再等几轮;提取落空把最终 URL 记进日志,
611
- # 别再静默登记空 id(发章只能兜底找书,直达编辑器就废了)。
612
- try:
613
- for _try in range(6):
614
- m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
615
- str(page.url() or ""))
616
- if m_url:
617
- book_id = m_url.group(1) or m_url.group(2)
618
- break
619
- time.sleep(0.8)
620
- if not book_id:
621
- logs.append("book_id 提取落空,最终页面:%s"
622
- % str(page.url() or "")[:120])
623
- except Exception:
624
- pass
625
- if book_id:
626
- break
627
- if not auto_submit:
628
- # 人工提交模式:按书名对账兜底即止,不自动换名
629
- bid = _resolve_book_id(plat, page, {"title": name})
630
- if bid:
631
- book_id = bid
632
- logs.append("book_id 提取落空,已按书名在平台对账找回 %s" % bid)
633
- break
634
- # 自动提交被平台静默拒绝(不跳转)时,toast 常喊重名——抓
635
- # 回来判定,别落进「疑似未建成」的糊涂账
636
- hint = ""
637
- for _try in range(3):
638
- hint = flow.dup_hint(page)
639
- if hint:
640
- break
641
- time.sleep(1.0)
642
- if hint:
643
- logs.append("页面提示:%s" % hint[:120])
644
- bid = _resolve_book_id(plat, page, {"title": name})
645
- if bid:
646
- book_id, final_name = bid, name
647
- logs.append("同名书就在本账号下,已复用 book_id=%s" % bid)
648
- break
649
- if not cands_ready:
650
- names = names + _alt_titles(plat, data, task, logs)
651
- cands_ready = True
652
- continue
653
- # 非撞名失败:按书名对账兜底(原有逻辑),找不回就明说未建成
654
- bid = _resolve_book_id(plat, page, {"title": name})
655
- if bid:
656
- book_id = bid
657
- logs.append("book_id 提取落空,已按书名在平台对账找回 %s" % bid)
658
- break
659
- if book_id and final_name != book_name:
660
- # 换名成功(或复用同名旧书):书名全链路同步到新名
661
- _sync_renamed_book(task_id, plat, task, data, final_name, logs)
662
- operation_status = "confirmed" if book_id else "unknown"
663
- if book_id:
664
- operations.confirm(operation_id, remote_receipt=book_id,
665
- metadata={"platform": plat, "action": "create_book"})
666
- else:
667
- operations.mark_unknown(
668
- operation_id,
669
- "建书流程走完,但平台作品列表按书名未找到《%s》,书很可能未建成;"
670
- "请到平台作品管理确认,若已存在可人工登记" % final_name,
671
- metadata={"platform": plat, "action": "create_book"})
672
- ledger.record(plat, "create_book", task_id=task_id, title=final_name,
673
- book_id=book_id, ok=bool(book_id),
674
- error=((("" if book_id else
675
- "建书流程走完但未取得 book_id,且平台按书名未找到"
676
- "《%s》,疑似未建成\n" % final_name)
677
- + "\n".join(logs))[:2000] or None),
678
- shot=str(ledger.shot_path(plat, task_id, "")),
679
- operation_id=operation_id, operation_status=operation_status,
680
- remote_receipt=book_id)
681
- if book_id:
682
- # 未取得 id 不落台账:空条目会让界面亮「已建书」、校准盲跑,
683
- # 而发章/下次建书各自有按书名对账的兜底,空条目只剩害处。
684
- ledger.save_book(task_id, plat, {"book_id": book_id, "title": final_name,
685
- "source": "create"})
686
- _set(plat, status="connected", error="")
687
- except Exception as e:
688
- operations.finish_exception(operation_id, e)
689
- operation_status = (operations.get(operation_id) or {}).get("status") or "failed"
690
- _set(plat, status="error", error="建书失败:%s" % e)
691
- ledger.record(plat, "create_book", task_id=task_id, title=book_name,
692
- ok=False, error=str(e)[:300],
693
- shot=str(ledger.shot_path(plat, task_id, "")),
694
- operation_id=operation_id, operation_status=operation_status)
695
- finally:
696
- _save()
697
-
698
- threading.Thread(target=run, daemon=True,
699
- name="pub-book-%s" % plat).start()
700
- return True, ""
701
-
702
-
703
- # ---------------------------------------------------------------- 动作:登记已有作品
704
- def register_book(task_id, plat, title, book_id=""):
705
- """人工登记平台已有作品:建书流程没走通、或用户纯手工在平台上建的
706
- 书,补进台账让卡片翻到「已建书·发一章」,避免再点「创建作品」造出
707
- 重复书。只动本地台账不碰浏览器;发章按书名找书,title 必填,
708
- book_id 选填(有直达 URL 时填)。已登记会覆盖更新(兼纠错口)。"""
709
- from .. import store
710
- if plat not in PLATFORMS:
711
- return False, "未知平台"
712
- if not store.get_task(task_id):
713
- return False, "任务不存在"
714
- title = (title or "").strip()
715
- if not title:
716
- return False, "作品名必填(发章按作品名在平台找书)"
717
- # 换绑另一本书(book_id/title 变了)时旧书的校准数作废,防张冠李戴
718
- old = ledger.book_for(task_id, plat) or {}
719
- if old and (old.get("book_id") != str(book_id or "").strip()
720
- or old.get("title") != title[:120]):
721
- ledger.update_book(task_id, plat, remote_total=None, remote_published=None,
722
- remote_review=None, remote_rejected=None,
723
- remote_synced_at=None, remote_error=None)
724
- ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
725
- "title": title[:120], "source": "manual"})
726
- return True, ""
727
-
728
-
729
- def _with_tag_steps(steps, groups, values, mod=None):
730
- """标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
731
- "tags" 步骤按组切换点选。组显示名映射来自**对应平台模块**的
732
- TAG_GROUP_LABELS(曾硬编码 qimao——番茄任务 key 撞名被套上七猫组名,
733
- 插桩 text 变 list repr)。兼容旧流程表(无 tags 步骤时插桩);流程表
734
- 已用 {tag_x} 占位(番茄式 click_real)时不插桩——占位由 values 自答。"""
735
- flat = []
736
- labels = getattr(mod, "TAG_GROUP_LABELS", {}) if mod else {}
737
- for key, tags in groups or []:
738
- grp = labels.get(key, "")
739
- flat.extend([grp, str(t)] if grp and str(t).strip() else str(t)
740
- for t in tags if str(t).strip())
741
- if not flat:
742
- return steps
743
- if any(st.get("do") == "tags" for st in steps):
744
- values["_tags"] = flat
745
- return steps
746
- if any("{tag_" in str(st.get("text") or "") for st in steps if st.get("text")):
747
- return steps # 流程表自带标签占位(番茄式):values 已就绪
748
- tag_steps = [{"do": "click_text", "text": str(t), "contains": False,
749
- "scope": "[class*=tag] li,span,label,[class*=label]"}
750
- for t in flat]
751
- out, inserted = list(steps), False
752
- for i, st in enumerate(out):
753
- if st.get("do") in ("submit", "shot") and not inserted:
754
- out[i:i] = tag_steps
755
- inserted = True
756
- if not inserted:
757
- out += tag_steps
758
- return out
759
-
760
-
761
- def _login_guard(plat):
762
- """动作前的登录前置:状态 connected 放行;否则现场复核一次。"""
763
- s = _st(plat)
764
- if s.get("status") == "connected":
765
- return True, ""
766
- try:
767
- _b, page = _open_page(plat)
768
- ok, url = _check_login(plat, page)
769
- if ok:
770
- _set(plat, status="connected")
771
- return True, ""
772
- return False, "平台未登录(当前页面 %s),请先点「连接平台」扫码" % url[:80]
773
- except BrowserError as e:
774
- return False, "浏览器不可用:%s" % e
775
-
776
-
777
- def _url_values(mod, book):
778
- """平台模块提供的 URL 占位值(chapter_manage_url/draft_url/editor_url),
779
- 逐键取、缺哪个跳哪个。曾按序连赋 + except AttributeError 兜底:draft_url
780
- 只有七猫有,番茄走到第二行即断,editor_url 永远赋不上——{editor_url}
781
- 残进 navigate,页面停在 about:blank 误报「未登录或改版」(0924 三连败)。"""
782
- out = {}
783
- for key in ("chapter_manage_url", "draft_url", "editor_url"):
784
- fn = getattr(mod, key, None)
785
- if callable(fn):
786
- out[key] = fn(book)
787
- return out
788
-
789
-
790
- def _resolve_book_id(plat, page, book):
791
- """登记缺 book_id 时按书名在作家后台找回(平台模块可选提供
792
- resolve_book_id)。找不回返回空串:流程按无 id 兜底路径继续,让后续
793
- 步骤如实报错,绝不在这里拦死。找回后回写 books.json,下次直达。"""
794
- fn = getattr(PLATFORMS[plat], "resolve_book_id", None)
795
- if not callable(fn) or str((book or {}).get("book_id") or "").strip():
796
- return ""
797
- try:
798
- return str(fn(page, book) or "").strip()
799
- except Exception:
800
- return ""
801
-
802
-
803
- # ---------------------------------------------------------------- 动作:发章
804
- def read_chapter(fp):
805
- """读章节文件 → (章号, 标题, 正文, 错误)。UTF-8→GBK 回退(章稿乱码教训)。
806
-
807
- 标题取首个「# 」标题行,没有则用文件名;章号从标题/文件名的
808
- 「第X章」解析,解析不出记 0(台账不按章号幂等,只按标题留痕)。"""
809
- from pathlib import Path
810
- from .. import runner
811
- p = Path(fp)
812
- if not p.is_file():
813
- return 0, "", "", "章节文件不存在:%s" % p
814
- try:
815
- text = runner.read_text_any_enc(p)
816
- except Exception as e:
817
- return 0, "", "", "读章节文件失败:%s" % e
818
- lines = text.strip().splitlines()
819
- title = ""
820
- body_start = 0
821
- for i, ln in enumerate(lines[:5]):
822
- s = ln.strip()
823
- if s.startswith("#"): # 只认 markdown 标题行
824
- s = s.lstrip("#").strip()
825
- if s:
826
- title, body_start = s, i + 1
827
- break
828
- if not title:
829
- title = p.stem
830
- body = "\n".join(lines[body_start:]).strip()
831
- ch_no = ledger.parse_chapter_no(title) or ledger.parse_chapter_no(p.name)
832
- return ch_no, title, body, ""
833
-
834
-
835
- def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
836
- force=False, force_confirmed=False, force_reason=""):
837
- """把一章发到平台(或填好待人工确认)。幂等:已成功发布的章号拒绝重发。"""
838
- from .. import store
839
- if plat not in PLATFORMS:
840
- return False, "未知平台"
841
- task = store.get_task(task_id)
842
- if not task:
843
- return False, "任务不存在"
844
- book = ledger.book_for(task_id, plat)
845
- if not book:
846
- return False, "该任务尚未在此平台建书,请先「创建作品」"
847
- if not book.get("book_id") and book.get("source") != "manual":
848
- return False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
849
- # 闭包中需要在找回 book_id 后更新书籍引用;使用独立副本避免
850
- # Python 将 book 误判为 run() 的局部变量,导致前置解析触发
851
- # UnboundLocalError。
852
- book_ref = dict(book)
853
- if _st(plat).get("status") == "busy":
854
- return False, "该平台有操作正在进行中"
855
- ch_no, title, body, err = read_chapter(chapter_file)
856
- if err:
857
- return False, err
858
- quality = _quality_release_guard(
859
- task, plat, action="publish", target_chapter=ch_no or None,
860
- force=force, force_confirmed=force_confirmed, force_reason=force_reason)
861
- if not quality.get("allowed"):
862
- return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
863
- n_chars = len(body.replace("\n", "").replace(" ", ""))
864
- if n_chars < 100:
865
- return False, "正文过短(%d 字),疑似未完成章节" % n_chars
866
- # 平台级下限(如七猫编辑器明示「最少 1000 字」:不足时发布被静默拦截)
867
- min_chars = int(PLATFORMS[plat].CONFIG.get("min_chapter_chars") or 100)
868
- if n_chars < min_chars:
869
- return False, ("正文 %d 字未达该平台下限(%d 字),补足后再发"
870
- % (n_chars, min_chars))
871
- if n_chars > 30000:
872
- return False, "正文超长(%d 字),平台单章上限一般 2 万字" % n_chars
873
- done = ledger.published_chapters(task_id, plat)
874
- if ch_no and ch_no in done:
875
- return False, "第 %d 章已成功发布过(台账幂等拦截);确需重发请手工处理" % ch_no
876
- ok_login, why = _login_guard(plat)
877
- if not ok_login:
878
- return False, why
879
- _set(plat, status="busy", last_action="upload_chapter", error="")
880
-
881
- mod = PLATFORMS[plat]
882
- values = {"chapter_title": title, "chapter_body": body,
883
- "book_name": book_ref.get("title") or ""}
884
- values.update(_url_values(mod, book_ref)) # verify/draft/editor 页 URL(流程占位)
885
- logs = []
886
- operation_id = operations.begin(
887
- "publish:%s:upload_chapter" % plat,
888
- {"task_id": task_id, "chapter_no": ch_no, "title": title}, task_id=task_id,
889
- metadata={"platform": plat, "action": "upload_chapter"})
890
-
891
- def run():
892
- try:
893
- b, page = _open_page(plat)
894
- bid = _resolve_book_id(plat, page, book_ref)
895
- if bid: # 建书时没提上 id 的书,发章前补账
896
- book_ref.update(book_id=bid)
897
- values.update(_url_values(mod, book_ref))
898
- ledger.save_book(task_id, plat, book_ref)
899
- logs.append("已按书名找回 book_id=%s 并更新登记" % bid)
900
- elif not book_ref.get("book_id"):
901
- raise RuntimeError("未按作品名找到远端作品,未打开章节编辑器;"
902
- "请检查平台作品名或补填作品 ID")
903
- flow.run_flow(page, load_flow(plat, "upload_chapter"), values=values,
904
- config=mod.CONFIG, auto_submit=auto_submit,
905
- shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
906
- log=logs.append)
907
- if auto_submit:
908
- operations.confirm(operation_id, metadata={"platform": plat,
909
- "action": "upload_chapter",
910
- "chapter_no": ch_no})
911
- ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
912
- book_id=book_ref.get("book_id") or "", title=title, ok=True,
913
- operation_id=operation_id, operation_status="confirmed")
914
- else:
915
- # run_flow stops immediately before the submit step. Keep the
916
- # operation pending and never count the filled form as published.
917
- ledger.record(plat, "upload_chapter_pending", task_id=task_id,
918
- chapter_no=ch_no, book_id=book_ref.get("book_id") or "",
919
- title=title, ok=False, error="等待人工提交",
920
- operation_id=operation_id, operation_status="pending")
921
- _set(plat, status="connected", error="")
922
- except Exception as e:
923
- operations.finish_exception(operation_id, e)
924
- operation_status = (operations.get(operation_id) or {}).get("status") or "failed"
925
- _set(plat, status="error", error="发章失败:%s" % e)
926
- ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
927
- book_id=book_ref.get("book_id") or "", title=title,
928
- ok=False, error=str(e)[:300], operation_id=operation_id,
929
- operation_status=operation_status)
930
- finally:
931
- _save()
932
-
933
- threading.Thread(target=run, daemon=True,
934
- name="pub-ch-%s" % plat).start()
935
- return True, ""
936
-
937
-
938
- def confirm_manual_chapter(task_id, plat, chapter_no):
939
- """Record an explicit user confirmation after submitting in the browser."""
940
- from .. import store
941
- if plat not in PLATFORMS:
942
- return False, "未知平台"
943
- if not store.get_task(task_id):
944
- return False, "任务不存在"
945
- try:
946
- chapter_no = int(chapter_no or 0)
947
- except (TypeError, ValueError):
948
- chapter_no = 0
949
- if chapter_no <= 0:
950
- return False, "章号无效"
951
- if chapter_no in ledger.published_chapters(task_id, plat):
952
- return True, ""
953
- pending = next((r for r in ledger.recent(task_id=task_id, platform=plat, limit=100)
954
- if r.get("action") == "upload_chapter_pending"
955
- and int(r.get("chapter_no") or 0) == chapter_no), None)
956
- if not pending:
957
- return False, "没有找到待确认的填稿记录,请先重新填入该章"
958
- op_id = pending.get("operation_id") or ""
959
- current = operations.get(op_id) if op_id else None
960
- if current and current.get("status") == "pending":
961
- ok = operations.confirm(op_id, remote_receipt="manual-confirmed",
962
- metadata={"platform": plat, "action": "upload_chapter"})
963
- elif current:
964
- ok = operations.reconcile(op_id, "confirmed", remote_receipt="manual-confirmed",
965
- metadata={"platform": plat, "action": "upload_chapter"})
966
- else:
967
- ok = True
968
- if not ok:
969
- return False, "待确认操作状态已变化,请重新校准后再试"
970
- ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=chapter_no,
971
- book_id=pending.get("book_id") or "", title=pending.get("title") or "",
972
- ok=True, operation_id=op_id, operation_status="confirmed",
973
- remote_receipt="manual-confirmed")
974
- return True, ""
975
-
976
-
977
- # ------------------------------------------------ 已发章数校准(平台实况对账)
978
- # 台账只记得 CodeBee 自己发成功的章:用户在平台窗口里手工补交/平台驳回/
979
- # 台账重复记录都会让「已发 N」漂移(2026-09-28 实案:番茄平台 8 章台账 7)。
980
- # 校准 = 打开章节管理页数行,按状态关键词分桶后写回 books.json 的 remote_*。
981
-
982
- _STATUS_PUB = "已发布"
983
- _STATUS_REVIEW = ("待审核", "审核中", "排队")
984
- _STATUS_BAD = ("未通过", "驳回")
985
-
986
-
987
- def bucket_chapter_rows(rows):
988
- """章节管理页的行文本 → {total, published, review, rejected}。
989
-
990
- 行里含状态关键词才计入对应桶;total=识别出的章节数据行数。"""
991
- out = {"total": 0, "published": 0, "review": 0, "rejected": 0}
992
- for t in rows or []:
993
- t = str(t)
994
- if not t:
995
- continue
996
- if "章节名称" in t: # 表头兜底(正常到不了这)
997
- continue
998
- out["total"] += 1
999
- if any(k in t for k in _STATUS_BAD):
1000
- out["rejected"] += 1
1001
- elif _STATUS_PUB in t:
1002
- out["published"] += 1
1003
- elif any(k in t for k in _STATUS_REVIEW):
1004
- out["review"] += 1
1005
- return out
1006
-
1007
-
1008
- def sync_published(task_id, plat, manual=False):
1009
- """同步执行一次校准(manager.sync_published_async 的线程体)。
1010
-
1011
- manual=False(打开作品页自动触发)只 attach 在跑的浏览器实例,绝不
1012
- 静默 launch 新窗口吓人;manual=True(用户点「校准」按钮)才允许
1013
- attach-or-launch。识别不到章节行(改版/未登录/分页)时只记 note
1014
- 不覆写 remote_*——宁可显示旧数也不把 0 当真相。"""
1015
- from .browser import Browser
1016
- book = ledger.book_for(task_id, plat)
1017
- if not book:
1018
- return False, "该任务未在此平台登记作品"
1019
- if _st(plat).get("status") == "busy":
1020
- return False, "该平台有操作正在进行中"
1021
- mod = PLATFORMS[plat]
1022
- js = mod.CONFIG.get("count_rows_js")
1023
- if not js:
1024
- return False, "该平台未配置章节计数"
1025
- try:
1026
- if manual:
1027
- b, page = _open_page(plat)
1028
- else:
1029
- port = _st(plat).get("port") or 0
1030
- b = Browser.attach(int(port)) # 实例已死 → 异常 → 只记原因
1031
- page = b.first_page(create=True)
1032
- except Exception as e:
1033
- return False, "浏览器未连接,点「连接平台」后再校准"
1034
- if not str(book.get("book_id") or "").strip():
1035
- # book_id 空的登记(人工登记/未验证建书结果)别拿去拼 URL 盲跑:
1036
- # 空 id 会落到作品列表页,数章节必然 0 行,报一句误导人的「可能改版」。
1037
- # 先按书名对账,找回补账再校准;找不回就明说书可能没建成。
1038
- bid = _resolve_book_id(plat, page, book)
1039
- if bid:
1040
- book = dict(book, book_id=bid)
1041
- ledger.save_book(task_id, plat, book) # 合并语义:保留 remote_*
1042
- else:
1043
- return False, ("平台作品列表按书名未找到《%s》,这本书可能尚未建成;"
1044
- "请到平台作品管理确认,若已存在可人工登记"
1045
- % (book.get("title") or ""))
1046
- try:
1047
- page.navigate(mod.chapter_manage_url(book), timeout=40)
1048
- time.sleep(4.0) # SPA 表格慢渲染
1049
- url = str(page.url() or "")
1050
- if any(m in url for m in mod.CONFIG["login_url_marks"]) \
1051
- or url.startswith("chrome-error://"):
1052
- return False, "平台登录态已失效,请重连后校准"
1053
- rows = page.call(js) or []
1054
- st = bucket_chapter_rows(rows)
1055
- if st["total"] <= 0:
1056
- return False, "章节管理页未识别到章节列表(可能改版)"
1057
- ledger.update_book(task_id, plat,
1058
- remote_total=st["total"],
1059
- remote_published=st["published"],
1060
- remote_review=st["review"],
1061
- remote_rejected=st["rejected"],
1062
- remote_synced_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1063
- return True, ""
1064
- except Exception as e:
1065
- return False, "校准失败:%s" % str(e)[:160]
1066
-
1067
-
1068
- def _sync_preflight(task_id, plat, manual):
1069
- """校准前置检查(同步、不碰浏览器页面):登记在案/没在忙/平台支持;
1070
- 自动触发(manual=False)时还要求浏览器实例活着——绝不静默 launch。"""
1071
- from .browser import Browser
1072
- if plat not in PLATFORMS:
1073
- return False, "未知平台"
1074
- if not ledger.book_for(task_id, plat):
1075
- return False, "该任务未在此平台登记作品"
1076
- if _st(plat).get("status") == "busy":
1077
- return False, "该平台有操作正在进行中"
1078
- if not PLATFORMS[plat].CONFIG.get("count_rows_js"):
1079
- return False, "该平台未配置章节计数"
1080
- if not manual:
1081
- port = _st(plat).get("port") or 0
1082
- try:
1083
- Browser.attach(int(port))
1084
- except Exception:
1085
- return False, "浏览器未连接,点「连接平台」后再校准"
1086
- return True, ""
1087
-
1088
-
1089
- def sync_published_async(task_id, plat, manual=False):
1090
- """校准进后台线程(浏览器操作绝不能堵 HTTP 线程)。
1091
-
1092
- 返回 (ok, err):预检失败时线程都不起,接口即时回报 skipped 原因。"""
1093
- ok, err = _sync_preflight(task_id, plat, manual)
1094
- if not ok:
1095
- return False, err
1096
-
1097
- def run():
1098
- ok2, err2 = sync_published(task_id, plat, manual=manual)
1099
- if not ok2:
1100
- ledger.update_book(task_id, plat, remote_error=err2,
1101
- remote_synced_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1102
-
1103
- threading.Thread(target=run, daemon=True,
1104
- name="pub-sync-%s" % plat).start()
1105
- return True, ""
1106
-
1107
-
1108
- def published_local(task_id, plat):
1109
- """本地台账口径的已发章数(去重后的章号数,记录数会因重试虚高)。"""
1110
- return len(ledger.published_chapters(task_id, plat))
1111
-
1112
-
1113
- def history(task_id=None, plat=None, limit=50):
1114
- return ledger.recent(task_id=task_id, platform=plat, limit=limit)
1115
-
1116
-
1117
- _load()
1118
- _migrate_legacy_profiles()
1
+ # -*- coding: utf-8 -*-
2
+ """发布管理:平台会话(attach-or-launch)、登录状态机、建书/发章后台线程。
3
+
4
+ 状态机(data/publish/state.json 的 platforms.<id>.status):
5
+ none 从未连接
6
+ waiting_login 浏览器已开等用户扫码(connect 后,轮询 15 分钟)
7
+ connected 登录检测通过
8
+ busy 发布动作进行中(结束回 connected / error)
9
+ error 最后一次动作失败(error 字段带人话原因,可重试)
10
+
11
+ 浏览器生命周期:每平台一个持久化 profile(~/.codebee/publish_profiles/<id>,
12
+ 仓库外——GB 级浏览器运行时数据不进仓库目录),登录态落在 profile 里。
13
+ 服务重启后按 state.json 记住的调试端口 attach 旧实例;实例已死才重新
14
+ launch——用户登录一次,之后无感。
15
+
16
+ 与 bookmeta.generate_async 同款线程纪律:动作起后台线程即返回,前端靠
17
+ view()(SSE/轮询)看进度;线程内任何异常都落终态,绝不悬挂。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ import threading
24
+ import time
25
+
26
+ from .. import operations, paths, quality_gate
27
+ from . import fanqie, flow, ledger, qimao
28
+ from .browser import Browser, BrowserError, Page
29
+
30
+ PLATFORMS = {"fanqie": fanqie, "qimao": qimao}
31
+ LOGIN_WAIT_S = 15 * 60 # 扫码等待窗口
32
+ STATUS_FILE = paths.PUBLISH_DIR / "state.json"
33
+
34
+ LOCK = threading.RLock()
35
+ _state = {"platforms": {}} # {plat: {status, port, at, error, last_login, last_action}}
36
+ _browsers = {} # plat → Browser(进程内缓存,alive 校验兜底)
37
+
38
+
39
+ # ---------------------------------------------------------------- 状态
40
+ def _load():
41
+ global _state
42
+ try:
43
+ d = json.loads(STATUS_FILE.read_text(encoding="utf-8"))
44
+ if isinstance(d, dict) and isinstance(d.get("platforms"), dict):
45
+ _state = d
46
+ return
47
+ except Exception:
48
+ pass
49
+ _state = {"platforms": {}}
50
+
51
+
52
+ def _save():
53
+ with LOCK:
54
+ try:
55
+ paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
56
+ tmp = STATUS_FILE.with_suffix(".tmp")
57
+ tmp.write_text(json.dumps(_state, ensure_ascii=False, indent=1),
58
+ encoding="utf-8")
59
+ tmp.replace(STATUS_FILE)
60
+ except Exception:
61
+ pass
62
+
63
+
64
+ def _set(plat, **kv):
65
+ with LOCK:
66
+ ent = _state["platforms"].setdefault(plat, {})
67
+ ent.update(kv)
68
+ ent["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
69
+ _save()
70
+
71
+
72
+ def _st(plat):
73
+ return (_state.get("platforms") or {}).get(plat) or {}
74
+
75
+
76
+ def _quality_release_guard(task, plat, action="publish", target_chapter=None,
77
+ force=False, force_confirmed=False, force_reason=""):
78
+ """Return a release decision backed by the latest completed review.
79
+
80
+ Non-fiction/code tasks keep their existing publish workflow; novel release
81
+ actions must carry a verifiable quality verdict. This keeps the gate
82
+ focused on the signing failure mode without breaking generic file publish.
83
+ """
84
+ if str(task.get("type") or "").lower() not in ("novel", "serial_novel") \
85
+ and (task.get("engine") or "") != "review":
86
+ return {"allowed": True, "status": "not_applicable", "blockers": [],
87
+ "warnings": [], "forced": False}
88
+ from .. import store
89
+ verdict = None
90
+ for run in sorted(store.task_runs(task.get("id")) or [],
91
+ key=lambda item: str(item.get("id") or ""), reverse=True):
92
+ if run.get("status") == "done" and isinstance(run.get("verdict"), dict):
93
+ verdict = run["verdict"]
94
+ break
95
+ decision = quality_gate.evaluate_release(
96
+ verdict or {}, action=action, target_chapter=target_chapter,
97
+ platform_version=ledger.book_for(task.get("id"), plat) or {},
98
+ force=force, force_confirmed=force_confirmed, force_reason=force_reason)
99
+ decision["version_snapshot"] = quality_gate.local_version(task.get("workdir") or "")
100
+ decision["platform_snapshot"] = ledger.book_for(task.get("id"), plat) or {}
101
+ if verdict:
102
+ decision["quality_status"] = verdict.get("quality_status") or "continue_only"
103
+ decision["review_run_id"] = run.get("id")
104
+ return decision
105
+
106
+
107
+ def view():
108
+ """前端状态视图:平台状态 + 是否找到浏览器 + 作品登记。"""
109
+ from .browser import find_browser
110
+ out = {}
111
+ for pid, mod in PLATFORMS.items():
112
+ s = _st(pid)
113
+ out[pid] = {"label": mod.CONFIG["label"], "status": s.get("status") or "none",
114
+ "at": s.get("at") or "", "error": s.get("error") or "",
115
+ "last_action": s.get("last_action") or "",
116
+ "profile": str(_profile_dir(pid))}
117
+ return {"platforms": out, "browser_found": bool(find_browser())}
118
+
119
+
120
+ def recover_orphans():
121
+ """启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。
122
+
123
+ 升级自愈:旧版「等扫码窗口一关就冤判超时」留下的 error(error 文案带
124
+ 「等待登录超时」)排队后台复核——profile 登录态还在的直接翻 connected,
125
+ 别让升级完还挂着旧冤案;attach 不到活实例就维持原样(用户点重连时
126
+ 新终审逻辑自会兜住)。"""
127
+ _load()
128
+ n = 0
129
+ stale = []
130
+ for plat in PLATFORMS:
131
+ s = _st(plat)
132
+ if s.get("status") in ("waiting_login", "busy"):
133
+ _set(plat, status="error", error="上次操作随服务重启中断,请重试")
134
+ n += 1
135
+ elif s.get("status") == "error" and "等待登录超时" in (s.get("error") or ""):
136
+ stale.append(plat)
137
+ if stale:
138
+ threading.Thread(target=_recheck_stale_errors, args=(stale,),
139
+ daemon=True, name="pub-stale-recheck").start()
140
+ return n
141
+
142
+
143
+ def _recheck_stale_errors(plats):
144
+ """旧版假超时的后台复核:只 attach 还活着的浏览器实例(绝不 launch——
145
+ 升级重启时静默弹窗口吓人),登录态复核过了翻 connected;否则不动。"""
146
+ for plat in plats:
147
+ if _st(plat).get("status") != "error":
148
+ continue # 用户已先行操作(重连/断开),别覆盖
149
+ port = _st(plat).get("port") or 0
150
+ if not port:
151
+ continue
152
+ try:
153
+ b = Browser.attach(int(port))
154
+ except Exception:
155
+ continue # 实例已死:不 launch,维持原错误
156
+ with LOCK:
157
+ _browsers[plat] = b
158
+ page = b.first_page(create=False)
159
+ if page is None:
160
+ continue
161
+ try:
162
+ ok, _u = _check_login(plat, page)
163
+ except Exception:
164
+ continue
165
+ if ok and _st(plat).get("status") == "error":
166
+ _set(plat, status="connected", error="",
167
+ last_login=time.strftime("%m-%d %H:%M"))
168
+ ledger.record(plat, "connect", ok=True)
169
+
170
+
171
+ # ---------------------------------------------------------------- 浏览器会话
172
+ def _profiles_base():
173
+ """profile 存放根:用户主目录 ~/.codebee/publish_profiles(仓库外)。
174
+
175
+ 浏览器 profile 是 GB 级运行时数据(缓存/扩展/字体),放仓库 data/ 里
176
+ 会拖垮静态扫描/备份(639M→986M 实测淹没整仓安全扫描),且登录态
177
+ cookie 没必要进任何仓库周边流程。"""
178
+ from pathlib import Path as _P
179
+ return _P.home() / ".codebee" / "publish_profiles"
180
+
181
+
182
+ def _migrate_legacy_profiles():
183
+ """一次性迁移:老位置 data/publish/profiles/<plat> 整体搬到新根(保登录态)。
184
+
185
+ 新位置已有同名平台目录时跳过(老数据视为已废弃);搬家失败静默——
186
+ 大不了用户重扫一次码。"""
187
+ from pathlib import Path as _P
188
+ legacy = _P(paths.PUBLISH_DIR) / "profiles"
189
+ if not legacy.is_dir():
190
+ return
191
+ base = _profiles_base()
192
+ try:
193
+ base.mkdir(parents=True, exist_ok=True)
194
+ except OSError:
195
+ return
196
+ for plat_dir in legacy.iterdir():
197
+ if not plat_dir.is_dir():
198
+ continue
199
+ dst = base / plat_dir.name
200
+ if dst.exists():
201
+ continue
202
+ try:
203
+ import shutil
204
+ shutil.move(str(plat_dir), str(dst)) # 跨盘(仓库盘→系统盘)也能搬
205
+ except OSError:
206
+ continue
207
+ try:
208
+ legacy.rmdir() # 空了才删得掉;还有残留就留给下次
209
+ except OSError:
210
+ pass
211
+
212
+
213
+ def _profile_dir(plat):
214
+ return _profiles_base() / plat
215
+
216
+
217
+ def _ensure_browser(plat):
218
+ """attach-or-launch:进程内缓存 → 记住的端口 → 全新 launch。"""
219
+ with LOCK:
220
+ b = _browsers.get(plat)
221
+ if b and b.alive():
222
+ return b
223
+ port = _st(plat).get("port") or 0
224
+ if port:
225
+ try:
226
+ b = Browser.attach(int(port))
227
+ _browsers[plat] = b
228
+ return b
229
+ except BrowserError:
230
+ pass # 旧实例已死:走 launch
231
+ b = Browser(_profile_dir(plat)) # 有窗口:用户要扫码/人工确认
232
+ _browsers[plat] = b
233
+ _set(plat, port=b.port)
234
+ return b
235
+
236
+
237
+ def _open_page(plat):
238
+ b = _ensure_browser(plat)
239
+ # 单标签纪律:平台点击(如「上传章节」)会开新 tab,流程引擎的 page
240
+ # 对象可能留在旧 tab 上填错页面(0 字草稿案的乱源)。每次动作前收拢
241
+ # 到一个 tab——发布是串行作业,多 tab 只会串台。
242
+ try:
243
+ for t in b.pages()[1:]:
244
+ try:
245
+ from .browser import _http_json
246
+ _http_json("http://127.0.0.1:%d/json/close/%s" % (b.port, t["id"]))
247
+ except Exception:
248
+ pass
249
+ except Exception:
250
+ pass
251
+ return b, b.first_page(create=True)
252
+
253
+
254
+ def _check_login(plat, page):
255
+ """打开后台首页看 URL 是否被踢到登录页。返回 (ok, 当前url)。
256
+
257
+ 导航失败页(chrome-error://)不含登录标记,曾把「域名打不开」误判成
258
+ 已登录——假 connected 的来源,先排除。"""
259
+ mod = PLATFORMS[plat]
260
+ try:
261
+ page.navigate(mod.CONFIG["home"], timeout=30)
262
+ except BrowserError as e:
263
+ return False, str(e)
264
+ url = str(page.url() or "")
265
+ if url.startswith("chrome-error://") or url.startswith("about:"):
266
+ return False, url # 页面没打开:网络/域名问题,不是登录态
267
+ if any(m in url for m in mod.CONFIG["login_url_marks"]):
268
+ return False, url
269
+ return True, url
270
+
271
+
272
+ # ---------------------------------------------------------------- 流程加载
273
+ def load_flow(plat, action):
274
+ """流程表三层:data/publish/flows-<plat>.json(用户校准)→ 仓库校准模板
275
+ flows-<plat>-calibrated.json(真机验证过的基线,随代码分发)→ 平台模块
276
+ 内置默认(待校准推测)。"""
277
+ from pathlib import Path
278
+ for fp in (paths.PUBLISH_DIR / ("flows-%s.json" % plat),
279
+ Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
280
+ try:
281
+ data = json.loads(fp.read_text(encoding="utf-8"))
282
+ if isinstance(data, dict) and isinstance(data.get(action), list):
283
+ return data[action]
284
+ except Exception:
285
+ pass
286
+ return PLATFORMS[plat].FLOWS[action]
287
+
288
+
289
+ # ---------------------------------------------------------------- 动作:连接
290
+ def connect(plat):
291
+ """开浏览器到平台首页,起后台线程轮询登录态(等扫码)。"""
292
+ if plat not in PLATFORMS:
293
+ return False, "未知平台"
294
+ try:
295
+ b, page = _open_page(plat)
296
+ except BrowserError as e:
297
+ _set(plat, status="error", error=str(e))
298
+ return False, str(e)
299
+ page.navigate(PLATFORMS[plat].CONFIG["home"])
300
+ _set(plat, status="waiting_login", error="")
301
+
302
+ def wait_login():
303
+ mod = PLATFORMS[plat]
304
+ deadline = time.time() + LOGIN_WAIT_S
305
+ while time.time() < deadline:
306
+ try:
307
+ url = str(page.url() or "")
308
+ if url and not any(m in url for m in mod.CONFIG["login_url_marks"]) \
309
+ and "about:blank" not in url and "chrome-error" not in url:
310
+ # 用户登录完成(离开登录页)。再主动开一次首页做复核,
311
+ # 复核被踢回登录页说明只是中间跳转,继续等。
312
+ ok, _u = _check_login(plat, page)
313
+ if ok:
314
+ _set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
315
+ ledger.record(plat, "connect", ok=True)
316
+ return
317
+ except (BrowserError, Exception):
318
+ pass # 页面被用户关掉等:继续等到超时
319
+ time.sleep(5)
320
+ _finalize_login_wait(plat)
321
+
322
+ threading.Thread(target=wait_login, daemon=True,
323
+ name="pub-login-%s" % plat).start()
324
+ return True, ""
325
+
326
+
327
+ def _finalize_login_wait(plat):
328
+ """等扫码超时后的终审:用户可能已在本窗口登录后把窗口关了(profile
329
+ 里登录态还在),attach-or-launch 重拉页面再复核一次,别急着冤判
330
+ 超时——「明明登录着却报超时」是发布卡死感的最大来源。复核也过不了
331
+ 才落超时错误。返回 True 表示复核通过(已判 connected)。"""
332
+ try:
333
+ _b, page = _open_page(plat)
334
+ ok, _u = _check_login(plat, page)
335
+ except Exception:
336
+ ok = False
337
+ if ok:
338
+ _set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
339
+ ledger.record(plat, "connect", ok=True)
340
+ else:
341
+ _set(plat, status="error", error="等待登录超时(15 分钟),请重新点连接")
342
+ return ok
343
+
344
+
345
+ def disconnect(plat):
346
+ b = _browsers.pop(plat, None)
347
+ if b:
348
+ b.close()
349
+ _set(plat, status="none", error="")
350
+ return True
351
+
352
+
353
+ # ---------------------------------------------------------------- 动作:探测
354
+ def probe_form_async(plat):
355
+ """「探测表单」:跑 probe 流程把页面真实可交互元素 dump 进台账 log。"""
356
+ if plat not in PLATFORMS:
357
+ return False, "未知平台"
358
+ if _st(plat).get("status") not in ("connected", "error"):
359
+ return False, "请先连接并登录平台"
360
+ _set(plat, status="busy", last_action="probe", error="")
361
+
362
+ logs = []
363
+
364
+ def run():
365
+ try:
366
+ b, page = _open_page(plat)
367
+ flow.run_flow(page, load_flow(plat, "probe_form"),
368
+ values={}, config=PLATFORMS[plat].CONFIG,
369
+ shot=lambda n: page.screenshot(ledger.shot_path(plat, "probe", n)),
370
+ log=logs.append)
371
+ _set(plat, status="connected")
372
+ ledger.record(plat, "probe", ok=True,
373
+ error="\n".join(logs)[:2000])
374
+ except Exception as e:
375
+ _set(plat, status="error", error="探测失败:%s" % e)
376
+ ledger.record(plat, "probe", ok=False, error=str(e)[:300])
377
+ finally:
378
+ _save()
379
+
380
+ threading.Thread(target=run, daemon=True, name="pub-probe-%s" % plat).start()
381
+ return True, ""
382
+
383
+
384
+ # ---------------------------------------------------------------- 动作:建书
385
+ _ALT_COUNT = 3 # 撞名后自动换名最多试 3 个候选
386
+ _ALT_TIMEOUT = 90 # 候选书名一次轻调用的墙钟(发布线程内,不能久等)
387
+ _TITLE_MAX = 15 # 两平台书名统一截 15 字(bookmeta._norm 同口径)
388
+
389
+
390
+ def _mech_titles(base):
391
+ """机械变体兜底(模型不可用时的最后手段):最小改动、不重构词——
392
+ 同音字(了→啦)、去「我的」前缀、加「纪事」尾。丑但能建成书,
393
+ 烧在发布线程里的几万字稿费比书名门面贵得多。"""
394
+ out, seen = [], {base}
395
+ for v in (base.replace("了", "啦", 1),
396
+ base[2:] if base.startswith("我的") else "",
397
+ (base + "纪事") if len(base) <= 13 else ""):
398
+ v = str(v or "").strip()[:_TITLE_MAX]
399
+ if len(v) >= 4 and v not in seen:
400
+ out.append(v)
401
+ seen.add(v)
402
+ return out
403
+
404
+
405
+ def _alt_titles(plat, data, task, logs):
406
+ """撞名候选队列:编排者基于原名+简介一次轻调用重出书名,失败静默
407
+ 降级机械变体——候选生成绝不能阻断建书主流程。"""
408
+ base = str(data.get("book_name") or "").strip()
409
+ if not base:
410
+ return []
411
+ out = []
412
+ try:
413
+ from .. import modelhub, planner, runner
414
+ orch = modelhub.resolve_orchestrator()
415
+ if orch:
416
+ prov, model = orch
417
+ prompt = (
418
+ "你是网文编辑。原书名《%s》在平台重名被拒,请为同一本书重起"
419
+ " %d 个新书名:每个 15 字内;保持原题材气质与钩子感;避开原名"
420
+ "的核心词组合与「我的XX藏不住」式高频套路组合;书名里带一个本"
421
+ "书独有的专名(人名/金手指/设定词)最不易再撞。只输出一个 "
422
+ "```json 字符串数组,如 [\"书名1\",\"书名2\"],不要输出其它内容。"
423
+ "\n\n## 简介(截选)\n%s"
424
+ % (base, _ALT_COUNT + 2, str(data.get("summary") or "")[:300]))
425
+ res = modelhub.chat(prov["id"], model, prompt,
426
+ max_tokens=600, timeout=_ALT_TIMEOUT)
427
+ planner._log_usage("publish", "create_book", task or {"id": ""}, res,
428
+ model=model,
429
+ provider=prov.get("name", prov.get("id", "")),
430
+ provider_id=prov.get("id", ""))
431
+ if res["ok"]:
432
+ arr = runner.extract_json(res.get("text") or "")
433
+ if isinstance(arr, dict):
434
+ arr = arr.get("titles") or arr.get("candidates") or []
435
+ if isinstance(arr, list):
436
+ for x in arr:
437
+ t = str(x or "").strip().replace("\n", " ")[:_TITLE_MAX]
438
+ if len(t) >= 2 and t != base and t not in out:
439
+ out.append(t)
440
+ if out:
441
+ logs.append("候选书名(编排者 %s):%s"
442
+ % (model, "、".join(out[:5])))
443
+ except Exception as e:
444
+ logs.append("候选书名生成降级(%s)" % str(e)[:100])
445
+ for v in _mech_titles(base):
446
+ if v not in out:
447
+ out.append(v)
448
+ return out[:_ALT_COUNT + 2]
449
+
450
+
451
+ def _sync_renamed_book(task_id, plat, task, data, new_name, logs):
452
+ """建书撞名自动换名后的全链路同步。
453
+
454
+ 只换建书表单里的字、bookmeta/大纲/归档还留旧名的话:下轮发章按旧书名
455
+ 对账、用户点「重生成作品信息」又把旧名改回去——马甲案同款错位复发。
456
+ 这里把 bookmeta data、归档 Markdown、最新批次大纲的 book_title 统一到
457
+ 新名(台账 title 由调用方 save_book 落)。任一环节失败只记日志,
458
+ 不拦建书成功主流程。"""
459
+ try:
460
+ from pathlib import Path as _Path
461
+ from .. import bookmeta as _bm
462
+ from .. import store as _store
463
+ data["book_name"] = new_name
464
+ cur = _store.get_task(task_id)
465
+ prev = ((cur or {}).get("book_meta") or {}).get(plat) or {}
466
+ if isinstance(prev.get("data"), dict):
467
+ payload = dict(prev)
468
+ payload["data"] = dict(prev["data"], book_name=new_name)
469
+ _store.set_book_meta(task_id, plat, payload)
470
+ fp = _Path(task.get("workdir") or "") / _bm.PLATFORMS[plat]["file"]
471
+ if str(fp.parent) and fp.parent.is_dir():
472
+ fp.write_text(_bm.render_markdown(cur, plat, payload["data"]),
473
+ encoding="utf-8")
474
+ logs.append("作品信息与归档已同步新书名《%s》" % new_name)
475
+ # 大纲书名:续写链继承与「一键重生成」素材都读最新批次大纲,
476
+ # 不同步会让下次重生成把书名改回旧名
477
+ for r in _store.task_runs(task_id):
478
+ o = r.get("outline")
479
+ if isinstance(o, dict) and o.get("chapters") and o.get("book_title"):
480
+ _store.update_run(r["id"], outline=dict(o, book_title=new_name))
481
+ logs.append("大纲书名已同步为《%s》" % new_name)
482
+ break
483
+ except Exception as e:
484
+ logs.append("书名同步部分失败(不拦建书):%s" % str(e)[:120])
485
+
486
+
487
+ def _create_preflight(plat, data):
488
+ """建书前置闸:平台表单对必填字段有硬校验(番茄简介 50-500 字),过不了
489
+ 校验时页面不跳转、流程只能误报「未登录或改版」——开浏览器之前先拦下,
490
+ 把人话原因还给用户。返回错误文案,空串=放行。"""
491
+ placeholders = {"待补充", "(待补充)", "(待补充)"}
492
+ title = str(data.get("book_name") or "").strip()
493
+ if not title or title in placeholders:
494
+ return "作品名称为空,请先补书名再点「创建作品」"
495
+ summary = str(data.get("summary") or "").strip()
496
+ if plat == "fanqie":
497
+ if len(summary) < 50 or len(summary) > 500:
498
+ return ("作品简介 %d 字,番茄建书表单要求 50-500 字;"
499
+ "请先补简介再点「创建作品」" % len(summary))
500
+ if not str(data.get("category") or "").strip():
501
+ return "作品分类为空,请先重生成作品信息再建书"
502
+ for key in ("tags_theme", "tags_role", "tags_plot"):
503
+ if not (data.get(key) or []):
504
+ return "作品标签(%s)为空,请先重生成作品信息再建书" % key
505
+ elif plat == "qimao":
506
+ if not summary or summary in placeholders:
507
+ return "作品简介为空,请先补简介再点「创建作品」"
508
+ for key in ("category_main", "category_sub"):
509
+ if not str(data.get(key) or "").strip():
510
+ return "作品分类为空,请先重生成作品信息再建书"
511
+ for key in ("tags_style", "tags_role", "tags_plot", "tags_bg"):
512
+ if not (data.get(key) or []):
513
+ return "作品标签(%s)为空,请先重生成作品信息再建书" % key
514
+ return ""
515
+
516
+
517
+ def create_book_async(task_id, plat, auto_submit=False, force=False,
518
+ force_confirmed=False, force_reason=""):
519
+ """按任务 book_meta 的资料在平台建书。返回 (ok, err)。"""
520
+ from .. import store
521
+ if plat not in PLATFORMS:
522
+ return False, "未知平台"
523
+ task = store.get_task(task_id)
524
+ if not task:
525
+ return False, "任务不存在"
526
+ # 连载链沿链继承(都是用第一个):续写批次从自己身上建书也用首批生成的资料
527
+ meta = store.inherited_book_meta(task, plat)
528
+ if meta.get("status") != "done" or not meta.get("data"):
529
+ return False, "请先生成该平台的作品信息"
530
+ if _st(plat).get("status") == "busy":
531
+ return False, "该平台有操作正在进行中"
532
+ existing = ledger.book_for(task_id, plat)
533
+ # A title-only manual registration is usable as a binding. For legacy
534
+ # entries without source metadata, only a recorded failed create-book
535
+ # attempt is safe to retry; otherwise it may be a real manual binding.
536
+ if existing:
537
+ if existing.get("book_id") or existing.get("source") == "manual":
538
+ return False, "该任务已在此平台登记过作品,请直接发章"
539
+ failed_create = any(
540
+ r.get("action") == "create_book" and not r.get("ok")
541
+ for r in ledger.recent(task_id=task_id, platform=plat, limit=50))
542
+ if not failed_create:
543
+ return False, "该任务已有作品登记但未确认,请登记已有作品或先在平台核对"
544
+ why = _create_preflight(plat, meta["data"])
545
+ if why:
546
+ return False, why
547
+ quality = _quality_release_guard(
548
+ task, plat, action="publish", force=force,
549
+ force_confirmed=force_confirmed, force_reason=force_reason)
550
+ if not quality.get("allowed"):
551
+ return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
552
+ ok_login, why = _login_guard(plat)
553
+ if not ok_login:
554
+ return False, why
555
+ _set(plat, status="busy", last_action="create_book", error="")
556
+
557
+ data = meta["data"]
558
+ mod = PLATFORMS[plat]
559
+ logs = []
560
+ book_name = (data.get("book_name") or "").strip()
561
+ operation_id = operations.begin(
562
+ "publish:%s:create_book" % plat,
563
+ {"task_id": task_id, "title": book_name}, task_id=task_id,
564
+ metadata={"platform": plat, "action": "create_book"})
565
+
566
+ def run():
567
+ final_name = book_name
568
+ try:
569
+ b, page = _open_page(plat)
570
+ # 书名全平台唯一(2026-09-30 马甲案):撞名是建书常见死法,撞了
571
+ # 原样报「疑似未建成」会白烧几万字稿费。试名队列=原名+自动候选
572
+ # (编排者重出→机械变体兜底);同名书恰在本账号下时对账复用不换
573
+ # 名。人工提交模式(auto_submit=false)不自动换名——用户在页面
574
+ # 上自己点提交,撞名他自己看得见。
575
+ names = [book_name]
576
+ cands_ready = False
577
+ book_id = ""
578
+ i = 0
579
+ while i < len(names) and i < 1 + _ALT_COUNT + 2:
580
+ name = names[i]
581
+ i += 1
582
+ final_name = name
583
+ if i > 1:
584
+ logs.append("第 %d 次尝试,书名《%s》" % (i, name))
585
+ values = mod.values_create_book(dict(data, book_name=name))
586
+ steps = _with_tag_steps(load_flow(plat, "create_book"),
587
+ mod.tag_groups(data), values, mod)
588
+ try:
589
+ flow.run_flow(page, steps, values=values, config=mod.CONFIG,
590
+ auto_submit=auto_submit, dup_check=auto_submit,
591
+ shot=lambda n: page.screenshot(
592
+ ledger.shot_path(plat, task_id, n)),
593
+ log=logs.append)
594
+ except flow.TitleDupError as e:
595
+ # 事前预检命中:平台实时校验已喊重名,点提交必被拒
596
+ logs.append(str(e))
597
+ bid = _resolve_book_id(plat, page, {"title": name})
598
+ if bid:
599
+ book_id, final_name = bid, name
600
+ logs.append("同名书就在本账号下,已复用 book_id=%s" % bid)
601
+ break
602
+ if not cands_ready:
603
+ names = names + _alt_titles(plat, data, task, logs)
604
+ cands_ready = True
605
+ if i >= len(names):
606
+ break # 候选没准备出来(模型+机械全空)
607
+ continue
608
+ # book_id:创建成功后平台跳书籍详情/编辑器,从 URL 提取
609
+ # (番茄 book-info/<id>;七猫 information?id=<id>)。url_any 过了
610
+ # 但页面还在跳转链上时再等几轮;提取落空把最终 URL 记进日志,
611
+ # 别再静默登记空 id(发章只能兜底找书,直达编辑器就废了)。
612
+ try:
613
+ for _try in range(6):
614
+ m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
615
+ str(page.url() or ""))
616
+ if m_url:
617
+ book_id = m_url.group(1) or m_url.group(2)
618
+ break
619
+ time.sleep(0.8)
620
+ if not book_id:
621
+ logs.append("book_id 提取落空,最终页面:%s"
622
+ % str(page.url() or "")[:120])
623
+ except Exception:
624
+ pass
625
+ if book_id:
626
+ break
627
+ if not auto_submit:
628
+ # 人工提交模式:按书名对账兜底即止,不自动换名
629
+ bid = _resolve_book_id(plat, page, {"title": name})
630
+ if bid:
631
+ book_id = bid
632
+ logs.append("book_id 提取落空,已按书名在平台对账找回 %s" % bid)
633
+ break
634
+ # 自动提交被平台静默拒绝(不跳转)时,toast 常喊重名——抓
635
+ # 回来判定,别落进「疑似未建成」的糊涂账
636
+ hint = ""
637
+ for _try in range(3):
638
+ hint = flow.dup_hint(page)
639
+ if hint:
640
+ break
641
+ time.sleep(1.0)
642
+ if hint:
643
+ logs.append("页面提示:%s" % hint[:120])
644
+ bid = _resolve_book_id(plat, page, {"title": name})
645
+ if bid:
646
+ book_id, final_name = bid, name
647
+ logs.append("同名书就在本账号下,已复用 book_id=%s" % bid)
648
+ break
649
+ if not cands_ready:
650
+ names = names + _alt_titles(plat, data, task, logs)
651
+ cands_ready = True
652
+ continue
653
+ # 非撞名失败:按书名对账兜底(原有逻辑),找不回就明说未建成
654
+ bid = _resolve_book_id(plat, page, {"title": name})
655
+ if bid:
656
+ book_id = bid
657
+ logs.append("book_id 提取落空,已按书名在平台对账找回 %s" % bid)
658
+ break
659
+ if book_id and final_name != book_name:
660
+ # 换名成功(或复用同名旧书):书名全链路同步到新名
661
+ _sync_renamed_book(task_id, plat, task, data, final_name, logs)
662
+ operation_status = "confirmed" if book_id else "unknown"
663
+ if book_id:
664
+ operations.confirm(operation_id, remote_receipt=book_id,
665
+ metadata={"platform": plat, "action": "create_book"})
666
+ else:
667
+ operations.mark_unknown(
668
+ operation_id,
669
+ "建书流程走完,但平台作品列表按书名未找到《%s》,书很可能未建成;"
670
+ "请到平台作品管理确认,若已存在可人工登记" % final_name,
671
+ metadata={"platform": plat, "action": "create_book"})
672
+ ledger.record(plat, "create_book", task_id=task_id, title=final_name,
673
+ book_id=book_id, ok=bool(book_id),
674
+ error=((("" if book_id else
675
+ "建书流程走完但未取得 book_id,且平台按书名未找到"
676
+ "《%s》,疑似未建成\n" % final_name)
677
+ + "\n".join(logs))[:2000] or None),
678
+ shot=str(ledger.shot_path(plat, task_id, "")),
679
+ operation_id=operation_id, operation_status=operation_status,
680
+ remote_receipt=book_id)
681
+ if book_id:
682
+ # 未取得 id 不落台账:空条目会让界面亮「已建书」、校准盲跑,
683
+ # 而发章/下次建书各自有按书名对账的兜底,空条目只剩害处。
684
+ ledger.save_book(task_id, plat, {"book_id": book_id, "title": final_name,
685
+ "source": "create"})
686
+ _set(plat, status="connected", error="")
687
+ except Exception as e:
688
+ operations.finish_exception(operation_id, e)
689
+ operation_status = (operations.get(operation_id) or {}).get("status") or "failed"
690
+ _set(plat, status="error", error="建书失败:%s" % e)
691
+ ledger.record(plat, "create_book", task_id=task_id, title=book_name,
692
+ ok=False, error=str(e)[:300],
693
+ shot=str(ledger.shot_path(plat, task_id, "")),
694
+ operation_id=operation_id, operation_status=operation_status)
695
+ finally:
696
+ _save()
697
+
698
+ threading.Thread(target=run, daemon=True,
699
+ name="pub-book-%s" % plat).start()
700
+ return True, ""
701
+
702
+
703
+ # ---------------------------------------------------------------- 动作:登记已有作品
704
+ def register_book(task_id, plat, title, book_id=""):
705
+ """人工登记平台已有作品:建书流程没走通、或用户纯手工在平台上建的
706
+ 书,补进台账让卡片翻到「已建书·发一章」,避免再点「创建作品」造出
707
+ 重复书。只动本地台账不碰浏览器;发章按书名找书,title 必填,
708
+ book_id 选填(有直达 URL 时填)。已登记会覆盖更新(兼纠错口)。"""
709
+ from .. import store
710
+ if plat not in PLATFORMS:
711
+ return False, "未知平台"
712
+ if not store.get_task(task_id):
713
+ return False, "任务不存在"
714
+ title = (title or "").strip()
715
+ if not title:
716
+ return False, "作品名必填(发章按作品名在平台找书)"
717
+ # 换绑另一本书(book_id/title 变了)时旧书的校准数作废,防张冠李戴
718
+ old = ledger.book_for(task_id, plat) or {}
719
+ if old and (old.get("book_id") != str(book_id or "").strip()
720
+ or old.get("title") != title[:120]):
721
+ ledger.update_book(task_id, plat, remote_total=None, remote_published=None,
722
+ remote_review=None, remote_rejected=None,
723
+ remote_synced_at=None, remote_error=None)
724
+ ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
725
+ "title": title[:120], "source": "manual"})
726
+ return True, ""
727
+
728
+
729
+ def _with_tag_steps(steps, groups, values, mod=None):
730
+ """标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
731
+ "tags" 步骤按组切换点选。组显示名映射来自**对应平台模块**的
732
+ TAG_GROUP_LABELS(曾硬编码 qimao——番茄任务 key 撞名被套上七猫组名,
733
+ 插桩 text 变 list repr)。兼容旧流程表(无 tags 步骤时插桩);流程表
734
+ 已用 {tag_x} 占位(番茄式 click_real)时不插桩——占位由 values 自答。"""
735
+ flat = []
736
+ labels = getattr(mod, "TAG_GROUP_LABELS", {}) if mod else {}
737
+ for key, tags in groups or []:
738
+ grp = labels.get(key, "")
739
+ flat.extend([grp, str(t)] if grp and str(t).strip() else str(t)
740
+ for t in tags if str(t).strip())
741
+ if not flat:
742
+ return steps
743
+ if any(st.get("do") == "tags" for st in steps):
744
+ values["_tags"] = flat
745
+ return steps
746
+ if any("{tag_" in str(st.get("text") or "") for st in steps if st.get("text")):
747
+ return steps # 流程表自带标签占位(番茄式):values 已就绪
748
+ tag_steps = [{"do": "click_text", "text": str(t), "contains": False,
749
+ "scope": "[class*=tag] li,span,label,[class*=label]"}
750
+ for t in flat]
751
+ out, inserted = list(steps), False
752
+ for i, st in enumerate(out):
753
+ if st.get("do") in ("submit", "shot") and not inserted:
754
+ out[i:i] = tag_steps
755
+ inserted = True
756
+ if not inserted:
757
+ out += tag_steps
758
+ return out
759
+
760
+
761
+ def _login_guard(plat):
762
+ """动作前的登录前置:状态 connected 放行;否则现场复核一次。"""
763
+ s = _st(plat)
764
+ if s.get("status") == "connected":
765
+ return True, ""
766
+ try:
767
+ _b, page = _open_page(plat)
768
+ ok, url = _check_login(plat, page)
769
+ if ok:
770
+ _set(plat, status="connected")
771
+ return True, ""
772
+ return False, "平台未登录(当前页面 %s),请先点「连接平台」扫码" % url[:80]
773
+ except BrowserError as e:
774
+ return False, "浏览器不可用:%s" % e
775
+
776
+
777
+ def _url_values(mod, book):
778
+ """平台模块提供的 URL 占位值(chapter_manage_url/draft_url/editor_url),
779
+ 逐键取、缺哪个跳哪个。曾按序连赋 + except AttributeError 兜底:draft_url
780
+ 只有七猫有,番茄走到第二行即断,editor_url 永远赋不上——{editor_url}
781
+ 残进 navigate,页面停在 about:blank 误报「未登录或改版」(0924 三连败)。"""
782
+ out = {}
783
+ for key in ("chapter_manage_url", "draft_url", "editor_url"):
784
+ fn = getattr(mod, key, None)
785
+ if callable(fn):
786
+ out[key] = fn(book)
787
+ return out
788
+
789
+
790
+ def _resolve_book_id(plat, page, book):
791
+ """登记缺 book_id 时按书名在作家后台找回(平台模块可选提供
792
+ resolve_book_id)。找不回返回空串:流程按无 id 兜底路径继续,让后续
793
+ 步骤如实报错,绝不在这里拦死。找回后回写 books.json,下次直达。"""
794
+ fn = getattr(PLATFORMS[plat], "resolve_book_id", None)
795
+ if not callable(fn) or str((book or {}).get("book_id") or "").strip():
796
+ return ""
797
+ try:
798
+ return str(fn(page, book) or "").strip()
799
+ except Exception:
800
+ return ""
801
+
802
+
803
+ # ---------------------------------------------------------------- 动作:发章
804
+ # 裸标题行(中文数字章稿常态):行首「第三十一章 绕开的路线」,无 # 前缀。
805
+ # 认不出会把标题行留进正文、标题回退成文件名 chapter-31——番茄编辑器
806
+ # 序号/标题分开填,两头全输(2026-10-08 实案)。
807
+ _BARE_HEAD_RE = re.compile(
808
+ r"^\s*第\s*[0-90-9一二三四五六七八九十百千零两]+\s*[章节回]")
809
+
810
+
811
+ def split_chapter_name(title):
812
+ """「第三十一章 绕开的路线」→「绕开的路线」(番茄标题位不带章号前缀,
813
+ 序号单独填)。没有章号前缀、或前缀后没有正题 → 原样返回。"""
814
+ title = str(title or "")
815
+ m = ledger._CHAPTER_RE.search(title)
816
+ if not m:
817
+ return title
818
+ rest = title[m.end():].strip(" ::,,、.·-—-_ ")
819
+ return rest or title
820
+
821
+
822
+ def chapter_fill_values(ch_no, title, body, book_name):
823
+ """发章流程的 values 字典。chapter_title=完整标题行(台账留痕/七猫流程
824
+ 锚点口径不变);chapter_no=阿拉伯数字串(番茄「第 _ 章」序号框只认数字,
825
+ 2026-10-08 实案:流程有 fill 步骤但 values 从没给过这个键,序号永远
826
+ 空着被平台以「章节序号只支持阿拉伯数字」打回);chapter_name=拆掉章号
827
+ 前缀的正题(番茄标题位)。章号解析不出给空串,fill 步骤按空值跳过。"""
828
+ return {"chapter_title": str(title or ""),
829
+ "chapter_name": split_chapter_name(title),
830
+ "chapter_no": str(int(ch_no)) if ch_no else "",
831
+ "chapter_body": str(body or ""),
832
+ "book_name": str(book_name or "")}
833
+
834
+
835
+ def read_chapter(fp):
836
+ """读章节文件 → (章号, 标题, 正文, 错误)。UTF-8→GBK 回退(章稿乱码教训)。
837
+
838
+ 标题识别三档:markdown「# 」标题行 → 裸「第X章 标题」首行 → 文件名。
839
+ 标题行不进正文(平台编辑器序号/标题单独填,正文再带一遍读者会看到重复)。
840
+ 章号从标题/文件名的「第X章」解析,解析不出记 0(台账不按章号幂等,
841
+ 只按标题留痕)。"""
842
+ from pathlib import Path
843
+ from .. import runner
844
+ p = Path(fp)
845
+ if not p.is_file():
846
+ return 0, "", "", "章节文件不存在:%s" % p
847
+ try:
848
+ text = runner.read_text_any_enc(p)
849
+ except Exception as e:
850
+ return 0, "", "", "读章节文件失败:%s" % e
851
+ lines = text.strip().splitlines()
852
+ title = ""
853
+ body_start = 0
854
+ for i, ln in enumerate(lines[:5]):
855
+ s = ln.strip()
856
+ if s.startswith("#"): # markdown 标题行
857
+ s = s.lstrip("#").strip()
858
+ if s:
859
+ title, body_start = s, i + 1
860
+ break
861
+ if not title:
862
+ for i, ln in enumerate(lines[:5]): # 裸「第X章 …」首行
863
+ s = ln.strip()
864
+ if _BARE_HEAD_RE.match(s):
865
+ title, body_start = s, i + 1
866
+ break
867
+ if not title:
868
+ title = p.stem
869
+ body = "\n".join(lines[body_start:]).strip()
870
+ ch_no = ledger.parse_chapter_no(title) or ledger.parse_chapter_no(p.name)
871
+ return ch_no, title, body, ""
872
+
873
+
874
+ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
875
+ force=False, force_confirmed=False, force_reason=""):
876
+ """把一章发到平台(或填好待人工确认)。幂等:已成功发布的章号拒绝重发。"""
877
+ from .. import store
878
+ if plat not in PLATFORMS:
879
+ return False, "未知平台"
880
+ task = store.get_task(task_id)
881
+ if not task:
882
+ return False, "任务不存在"
883
+ book = ledger.book_for(task_id, plat)
884
+ if not book:
885
+ return False, "该任务尚未在此平台建书,请先「创建作品」"
886
+ if not book.get("book_id") and book.get("source") != "manual":
887
+ return False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
888
+ # 闭包中需要在找回 book_id 后更新书籍引用;使用独立副本避免
889
+ # Python 将 book 误判为 run() 的局部变量,导致前置解析触发
890
+ # UnboundLocalError。
891
+ book_ref = dict(book)
892
+ if _st(plat).get("status") == "busy":
893
+ return False, "该平台有操作正在进行中"
894
+ ch_no, title, body, err = read_chapter(chapter_file)
895
+ if err:
896
+ return False, err
897
+ quality = _quality_release_guard(
898
+ task, plat, action="publish", target_chapter=ch_no or None,
899
+ force=force, force_confirmed=force_confirmed, force_reason=force_reason)
900
+ if not quality.get("allowed"):
901
+ return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
902
+ n_chars = len(body.replace("\n", "").replace(" ", ""))
903
+ if n_chars < 100:
904
+ return False, "正文过短(%d 字),疑似未完成章节" % n_chars
905
+ # 平台级下限(如七猫编辑器明示「最少 1000 字」:不足时发布被静默拦截)
906
+ min_chars = int(PLATFORMS[plat].CONFIG.get("min_chapter_chars") or 100)
907
+ if n_chars < min_chars:
908
+ return False, ("正文 %d 字未达该平台下限(%d 字),补足后再发"
909
+ % (n_chars, min_chars))
910
+ if n_chars > 30000:
911
+ return False, "正文超长(%d 字),平台单章上限一般 2 万字" % n_chars
912
+ done = ledger.published_chapters(task_id, plat)
913
+ if ch_no and ch_no in done:
914
+ return False, "第 %d 章已成功发布过(台账幂等拦截);确需重发请手工处理" % ch_no
915
+ ok_login, why = _login_guard(plat)
916
+ if not ok_login:
917
+ return False, why
918
+ _set(plat, status="busy", last_action="upload_chapter", error="")
919
+
920
+ mod = PLATFORMS[plat]
921
+ values = chapter_fill_values(ch_no, title, body,
922
+ book_ref.get("title") or "")
923
+ # 分卷:按本地卷计划解析目标卷(无计划=空串,volume 步骤按空值跳过,
924
+ # 保持平台默认卷);_volumes 是平台卷表共享缓存,弹窗新建的卷由流程
925
+ # 步骤追加进来,跑完回写 books.json 供后续章节免建。
926
+ try:
927
+ from . import volumes as _volumes
928
+ values["volume_name"] = _volumes.name_for(task.get("workdir"), ch_no)
929
+ except Exception:
930
+ values["volume_name"] = ""
931
+ values["_volumes"] = list(book_ref.get("volumes") or [])
932
+ values.update(_url_values(mod, book_ref)) # verify/draft/editor 页 URL(流程占位)
933
+ logs = []
934
+ operation_id = operations.begin(
935
+ "publish:%s:upload_chapter" % plat,
936
+ {"task_id": task_id, "chapter_no": ch_no, "title": title}, task_id=task_id,
937
+ metadata={"platform": plat, "action": "upload_chapter"})
938
+
939
+ def run():
940
+ try:
941
+ b, page = _open_page(plat)
942
+ bid = _resolve_book_id(plat, page, book_ref)
943
+ if bid: # 建书时没提上 id 的书,发章前补账
944
+ book_ref.update(book_id=bid)
945
+ values.update(_url_values(mod, book_ref))
946
+ ledger.save_book(task_id, plat, book_ref)
947
+ logs.append("已按书名找回 book_id=%s 并更新登记" % bid)
948
+ elif not book_ref.get("book_id"):
949
+ raise RuntimeError("未按作品名找到远端作品,未打开章节编辑器;"
950
+ "请检查平台作品名或补填作品 ID")
951
+ flow.run_flow(page, load_flow(plat, "upload_chapter"), values=values,
952
+ config=mod.CONFIG, auto_submit=auto_submit,
953
+ shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
954
+ log=logs.append)
955
+ new_vols = values.get("_volumes")
956
+ if isinstance(new_vols, list) and new_vols != (book_ref.get("volumes") or []):
957
+ ledger.update_book(task_id, plat, volumes=new_vols)
958
+ if auto_submit:
959
+ operations.confirm(operation_id, metadata={"platform": plat,
960
+ "action": "upload_chapter",
961
+ "chapter_no": ch_no})
962
+ ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
963
+ book_id=book_ref.get("book_id") or "", title=title, ok=True,
964
+ operation_id=operation_id, operation_status="confirmed")
965
+ else:
966
+ # run_flow stops immediately before the submit step. Keep the
967
+ # operation pending and never count the filled form as published.
968
+ ledger.record(plat, "upload_chapter_pending", task_id=task_id,
969
+ chapter_no=ch_no, book_id=book_ref.get("book_id") or "",
970
+ title=title, ok=False, error="等待人工提交",
971
+ operation_id=operation_id, operation_status="pending")
972
+ _set(plat, status="connected", error="")
973
+ except Exception as e:
974
+ operations.finish_exception(operation_id, e)
975
+ operation_status = (operations.get(operation_id) or {}).get("status") or "failed"
976
+ _set(plat, status="error", error="发章失败:%s" % e)
977
+ ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
978
+ book_id=book_ref.get("book_id") or "", title=title,
979
+ ok=False, error=str(e)[:300], operation_id=operation_id,
980
+ operation_status=operation_status)
981
+ finally:
982
+ _save()
983
+
984
+ threading.Thread(target=run, daemon=True,
985
+ name="pub-ch-%s" % plat).start()
986
+ return True, ""
987
+
988
+
989
+ def confirm_manual_chapter(task_id, plat, chapter_no):
990
+ """Record an explicit user confirmation after submitting in the browser."""
991
+ from .. import store
992
+ if plat not in PLATFORMS:
993
+ return False, "未知平台"
994
+ if not store.get_task(task_id):
995
+ return False, "任务不存在"
996
+ try:
997
+ chapter_no = int(chapter_no or 0)
998
+ except (TypeError, ValueError):
999
+ chapter_no = 0
1000
+ if chapter_no <= 0:
1001
+ return False, "章号无效"
1002
+ if chapter_no in ledger.published_chapters(task_id, plat):
1003
+ return True, ""
1004
+ pending = next((r for r in ledger.recent(task_id=task_id, platform=plat, limit=100)
1005
+ if r.get("action") == "upload_chapter_pending"
1006
+ and int(r.get("chapter_no") or 0) == chapter_no), None)
1007
+ if not pending:
1008
+ return False, "没有找到待确认的填稿记录,请先重新填入该章"
1009
+ op_id = pending.get("operation_id") or ""
1010
+ current = operations.get(op_id) if op_id else None
1011
+ if current and current.get("status") == "pending":
1012
+ ok = operations.confirm(op_id, remote_receipt="manual-confirmed",
1013
+ metadata={"platform": plat, "action": "upload_chapter"})
1014
+ elif current:
1015
+ ok = operations.reconcile(op_id, "confirmed", remote_receipt="manual-confirmed",
1016
+ metadata={"platform": plat, "action": "upload_chapter"})
1017
+ else:
1018
+ ok = True
1019
+ if not ok:
1020
+ return False, "待确认操作状态已变化,请重新校准后再试"
1021
+ ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=chapter_no,
1022
+ book_id=pending.get("book_id") or "", title=pending.get("title") or "",
1023
+ ok=True, operation_id=op_id, operation_status="confirmed",
1024
+ remote_receipt="manual-confirmed")
1025
+ return True, ""
1026
+
1027
+
1028
+ # ------------------------------------------------ 已发章数校准(平台实况对账)
1029
+ # 台账只记得 CodeBee 自己发成功的章:用户在平台窗口里手工补交/平台驳回/
1030
+ # 台账重复记录都会让「已发 N」漂移(2026-09-28 实案:番茄平台 8 章台账 7)。
1031
+ # 校准 = 打开章节管理页数行(跨页+跨卷),按状态关键词分桶后写回 books.json
1032
+ # 的 remote_*。行收集/分桶在 chaprows(本文件超行数基线拆出,2026-10-08)。
1033
+ from .chaprows import bucket_chapter_rows, collect_all_volumes # noqa: E402
1034
+
1035
+
1036
+ def sync_published(task_id, plat, manual=False):
1037
+ """同步执行一次校准(manager.sync_published_async 的线程体)。
1038
+
1039
+ manual=False(打开作品页自动触发)只 attach 在跑的浏览器实例,绝不
1040
+ 静默 launch 新窗口吓人;manual=True(用户点「校准」按钮)才允许
1041
+ attach-or-launch。行收集走 _collect_all_volumes(跨页+跨卷);
1042
+ 识别不到章节行(改版/未登录)时只记 note 不覆写 remote_*——宁可
1043
+ 显示旧数也不把 0 当真相。"""
1044
+ from .browser import Browser
1045
+ book = ledger.book_for(task_id, plat)
1046
+ if not book:
1047
+ return False, "该任务未在此平台登记作品"
1048
+ if _st(plat).get("status") == "busy":
1049
+ return False, "该平台有操作正在进行中"
1050
+ mod = PLATFORMS[plat]
1051
+ js = mod.CONFIG.get("count_rows_js")
1052
+ if not js:
1053
+ return False, "该平台未配置章节计数"
1054
+ try:
1055
+ if manual:
1056
+ b, page = _open_page(plat)
1057
+ else:
1058
+ port = _st(plat).get("port") or 0
1059
+ b = Browser.attach(int(port)) # 实例已死 → 异常 → 只记原因
1060
+ page = b.first_page(create=True)
1061
+ except Exception as e:
1062
+ return False, "浏览器未连接,点「连接平台」后再校准"
1063
+ if not str(book.get("book_id") or "").strip():
1064
+ # book_id 空的登记(人工登记/未验证建书结果)别拿去拼 URL 盲跑:
1065
+ # 空 id 会落到作品列表页,数章节必然 0 行,报一句误导人的「可能改版」。
1066
+ # 先按书名对账,找回补账再校准;找不回就明说书可能没建成。
1067
+ bid = _resolve_book_id(plat, page, book)
1068
+ if bid:
1069
+ book = dict(book, book_id=bid)
1070
+ ledger.save_book(task_id, plat, book) # 合并语义:保留 remote_*
1071
+ else:
1072
+ return False, ("平台作品列表按书名未找到《%s》,这本书可能尚未建成;"
1073
+ "请到平台作品管理确认,若已存在可人工登记"
1074
+ % (book.get("title") or ""))
1075
+ try:
1076
+ page.navigate(mod.chapter_manage_url(book), timeout=40)
1077
+ time.sleep(4.0) # SPA 表格慢渲染
1078
+ url = str(page.url() or "")
1079
+ if any(m in url for m in mod.CONFIG["login_url_marks"]) \
1080
+ or url.startswith("chrome-error://"):
1081
+ return False, "平台登录态已失效,请重连后校准"
1082
+ rows, vols = collect_all_volumes(page, mod)
1083
+ st = bucket_chapter_rows(rows)
1084
+ if st["total"] <= 0:
1085
+ return False, "章节管理页未识别到章节列表(可能改版)"
1086
+ extra = {"volumes": vols} if vols else {}
1087
+ ledger.update_book(task_id, plat,
1088
+ remote_total=st["total"],
1089
+ remote_published=st["published"],
1090
+ remote_review=st["review"],
1091
+ remote_rejected=st["rejected"],
1092
+ remote_published_nos=st["published_nos"],
1093
+ remote_synced_at=time.strftime("%Y-%m-%d %H:%M:%S"),
1094
+ **extra)
1095
+ return True, ""
1096
+ except Exception as e:
1097
+ return False, "校准失败:%s" % str(e)[:160]
1098
+
1099
+
1100
+ def _sync_preflight(task_id, plat, manual):
1101
+ """校准前置检查(同步、不碰浏览器页面):登记在案/没在忙/平台支持;
1102
+ 自动触发(manual=False)时还要求浏览器实例活着——绝不静默 launch。"""
1103
+ from .browser import Browser
1104
+ if plat not in PLATFORMS:
1105
+ return False, "未知平台"
1106
+ if not ledger.book_for(task_id, plat):
1107
+ return False, "该任务未在此平台登记作品"
1108
+ if _st(plat).get("status") == "busy":
1109
+ return False, "该平台有操作正在进行中"
1110
+ if not PLATFORMS[plat].CONFIG.get("count_rows_js"):
1111
+ return False, "该平台未配置章节计数"
1112
+ if not manual:
1113
+ port = _st(plat).get("port") or 0
1114
+ try:
1115
+ Browser.attach(int(port))
1116
+ except Exception:
1117
+ return False, "浏览器未连接,点「连接平台」后再校准"
1118
+ return True, ""
1119
+
1120
+
1121
+ def sync_published_async(task_id, plat, manual=False):
1122
+ """校准进后台线程(浏览器操作绝不能堵 HTTP 线程)。
1123
+
1124
+ 返回 (ok, err):预检失败时线程都不起,接口即时回报 skipped 原因。"""
1125
+ ok, err = _sync_preflight(task_id, plat, manual)
1126
+ if not ok:
1127
+ return False, err
1128
+
1129
+ def run():
1130
+ ok2, err2 = sync_published(task_id, plat, manual=manual)
1131
+ if not ok2:
1132
+ ledger.update_book(task_id, plat, remote_error=err2,
1133
+ remote_synced_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1134
+
1135
+ threading.Thread(target=run, daemon=True,
1136
+ name="pub-sync-%s" % plat).start()
1137
+ return True, ""
1138
+
1139
+
1140
+ def published_local(task_id, plat):
1141
+ """本地台账口径的已发章数(去重后的章号数,记录数会因重试虚高)。"""
1142
+ return len(ledger.published_chapters(task_id, plat))
1143
+
1144
+
1145
+ def history(task_id=None, plat=None, limit=50):
1146
+ return ledger.recent(task_id=task_id, platform=plat, limit=limit)
1147
+
1148
+
1149
+ _load()
1150
+ _migrate_legacy_profiles()