codebee 0.1.65 → 0.1.67

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 (116) hide show
  1. package/CHANGELOG.md +532 -513
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/acceptance.py +100 -0
  5. package/app/core/aiflavor.py +197 -197
  6. package/app/core/artifacts.py +148 -148
  7. package/app/core/assets.py +110 -0
  8. package/app/core/attachments.py +623 -532
  9. package/app/core/automation.py +758 -758
  10. package/app/core/backup.py +470 -470
  11. package/app/core/beekeeper.py +183 -183
  12. package/app/core/board.py +209 -209
  13. package/app/core/bookmeta.py +930 -759
  14. package/app/core/bookmeta_catalog.py +180 -180
  15. package/app/core/branching.py +137 -137
  16. package/app/core/builtin_agent.py +1234 -1191
  17. package/app/core/capability.py +144 -144
  18. package/app/core/catalog.py +23 -24
  19. package/app/core/chaptersafe.py +32 -32
  20. package/app/core/cleanup.py +303 -303
  21. package/app/core/compaction.py +201 -186
  22. package/app/core/covergen.py +356 -356
  23. package/app/core/defectretro.py +43 -0
  24. package/app/core/diagnostics.py +114 -114
  25. package/app/core/dispatch.py +173 -173
  26. package/app/core/dispatch_log.py +113 -113
  27. package/app/core/env_scrub.py +85 -85
  28. package/app/core/error_codes.py +220 -65
  29. package/app/core/errorlog.py +137 -137
  30. package/app/core/evaluation.py +174 -0
  31. package/app/core/failure_notify.py +290 -0
  32. package/app/core/flows.py +401 -355
  33. package/app/core/goal_service.py +167 -167
  34. package/app/core/health.py +374 -342
  35. package/app/core/history.py +38 -38
  36. package/app/core/hooks.py +162 -156
  37. package/app/core/jobs.py +1050 -1022
  38. package/app/core/knowledge.py +56 -5
  39. package/app/core/manager.py +2122 -1996
  40. package/app/core/market.py +362 -362
  41. package/app/core/market_remote.py +190 -92
  42. package/app/core/mocks.py +64 -64
  43. package/app/core/modelhub.py +3546 -3332
  44. package/app/core/notify.py +106 -106
  45. package/app/core/operations.py +228 -0
  46. package/app/core/paihang.py +138 -109
  47. package/app/core/paths.py +2 -2
  48. package/app/core/pipeline.py +4808 -4494
  49. package/app/core/planner.py +37 -32
  50. package/app/core/portguard.py +111 -111
  51. package/app/core/preview.py +293 -293
  52. package/app/core/publish/__init__.py +7 -7
  53. package/app/core/publish/auto.py +367 -367
  54. package/app/core/publish/browser.py +539 -533
  55. package/app/core/publish/fanqie.py +159 -128
  56. package/app/core/publish/flow.py +440 -406
  57. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  58. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  59. package/app/core/publish/ledger.py +203 -198
  60. package/app/core/publish/manager.py +692 -590
  61. package/app/core/publish/qimao.py +141 -141
  62. package/app/core/publish/ws.py +139 -139
  63. package/app/core/redact.py +38 -38
  64. package/app/core/repeat_guard.py +129 -124
  65. package/app/core/revisions.py +51 -0
  66. package/app/core/router.py +119 -81
  67. package/app/core/runner.py +2065 -2042
  68. package/app/core/selfupdate.py +384 -384
  69. package/app/core/session_log.py +161 -161
  70. package/app/core/sessions.py +312 -312
  71. package/app/core/settings.py +149 -149
  72. package/app/core/settings_schema.py +262 -262
  73. package/app/core/share_page.py +114 -114
  74. package/app/core/skill_scan.py +114 -106
  75. package/app/core/skillpacks/fanqie-novel.md +80 -80
  76. package/app/core/skillpacks/market/character-bible.md +66 -66
  77. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  78. package/app/core/skillpacks/market/git-workflow.md +57 -57
  79. package/app/core/skillpacks/market/release-notes.md +72 -72
  80. package/app/core/skillpacks/market/weekly-report.md +71 -71
  81. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  82. package/app/core/skills.py +315 -73
  83. package/app/core/step_runner.py +132 -132
  84. package/app/core/store.py +230 -11
  85. package/app/core/task_compile.py +199 -199
  86. package/app/core/telemetry.py +292 -292
  87. package/app/core/tlsctx.py +64 -64
  88. package/app/core/token_meter.py +147 -147
  89. package/app/core/upstream.py +41 -0
  90. package/app/core/usage.py +114 -3
  91. package/app/core/volumes.py +389 -355
  92. package/app/core/wxdigest.py +858 -858
  93. package/app/core/zentao.py +2465 -2447
  94. package/app/main.py +2957 -2832
  95. package/app/pick_dialog.py +110 -110
  96. package/app/ui/app.js +14384 -13977
  97. package/app/ui/artdiff.js +74 -0
  98. package/app/ui/board.css +20 -41
  99. package/app/ui/board.html +162 -162
  100. package/app/ui/board.js +305 -305
  101. package/app/ui/i18n.js +2766 -2711
  102. package/app/ui/icons/bee.svg +79 -79
  103. package/app/ui/index.html +7 -1
  104. package/app/ui/manifest.json +16 -16
  105. package/app/ui/qrcode.js +2297 -2297
  106. package/app/ui/style.css +6179 -6125
  107. package/bin/tutti.js +147 -147
  108. package/package.json +46 -41
  109. package/tools/wx_reader.py +261 -261
  110. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  111. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  112. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  113. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  114. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  115. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  116. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -1,590 +1,692 @@
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 threading
23
- import time
24
-
25
- from .. import paths
26
- from . import fanqie, flow, ledger, qimao
27
- from .browser import Browser, BrowserError, Page
28
-
29
- PLATFORMS = {"fanqie": fanqie, "qimao": qimao}
30
- LOGIN_WAIT_S = 15 * 60 # 扫码等待窗口
31
- STATUS_FILE = paths.PUBLISH_DIR / "state.json"
32
-
33
- LOCK = threading.RLock()
34
- _state = {"platforms": {}} # {plat: {status, port, at, error, last_login, last_action}}
35
- _browsers = {} # plat → Browser(进程内缓存,alive 校验兜底)
36
-
37
-
38
- # ---------------------------------------------------------------- 状态
39
- def _load():
40
- global _state
41
- try:
42
- d = json.loads(STATUS_FILE.read_text(encoding="utf-8"))
43
- if isinstance(d, dict) and isinstance(d.get("platforms"), dict):
44
- _state = d
45
- return
46
- except Exception:
47
- pass
48
- _state = {"platforms": {}}
49
-
50
-
51
- def _save():
52
- with LOCK:
53
- try:
54
- paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
55
- tmp = STATUS_FILE.with_suffix(".tmp")
56
- tmp.write_text(json.dumps(_state, ensure_ascii=False, indent=1),
57
- encoding="utf-8")
58
- tmp.replace(STATUS_FILE)
59
- except Exception:
60
- pass
61
-
62
-
63
- def _set(plat, **kv):
64
- with LOCK:
65
- ent = _state["platforms"].setdefault(plat, {})
66
- ent.update(kv)
67
- ent["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
68
- _save()
69
-
70
-
71
- def _st(plat):
72
- return (_state.get("platforms") or {}).get(plat) or {}
73
-
74
-
75
- def view():
76
- """前端状态视图:平台状态 + 是否找到浏览器 + 作品登记。"""
77
- from .browser import find_browser
78
- out = {}
79
- for pid, mod in PLATFORMS.items():
80
- s = _st(pid)
81
- out[pid] = {"label": mod.CONFIG["label"], "status": s.get("status") or "none",
82
- "at": s.get("at") or "", "error": s.get("error") or "",
83
- "last_action": s.get("last_action") or "",
84
- "profile": str(_profile_dir(pid))}
85
- return {"platforms": out, "browser_found": bool(find_browser())}
86
-
87
-
88
- def recover_orphans():
89
- """启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。
90
-
91
- 升级自愈:旧版「等扫码窗口一关就冤判超时」留下的 error(error 文案带
92
- 「等待登录超时」)排队后台复核——profile 登录态还在的直接翻 connected,
93
- 别让升级完还挂着旧冤案;attach 不到活实例就维持原样(用户点重连时
94
- 新终审逻辑自会兜住)。"""
95
- _load()
96
- n = 0
97
- stale = []
98
- for plat in PLATFORMS:
99
- s = _st(plat)
100
- if s.get("status") in ("waiting_login", "busy"):
101
- _set(plat, status="error", error="上次操作随服务重启中断,请重试")
102
- n += 1
103
- elif s.get("status") == "error" and "等待登录超时" in (s.get("error") or ""):
104
- stale.append(plat)
105
- if stale:
106
- threading.Thread(target=_recheck_stale_errors, args=(stale,),
107
- daemon=True, name="pub-stale-recheck").start()
108
- return n
109
-
110
-
111
- def _recheck_stale_errors(plats):
112
- """旧版假超时的后台复核:只 attach 还活着的浏览器实例(绝不 launch——
113
- 升级重启时静默弹窗口吓人),登录态复核过了翻 connected;否则不动。"""
114
- for plat in plats:
115
- if _st(plat).get("status") != "error":
116
- continue # 用户已先行操作(重连/断开),别覆盖
117
- port = _st(plat).get("port") or 0
118
- if not port:
119
- continue
120
- try:
121
- b = Browser.attach(int(port))
122
- except Exception:
123
- continue # 实例已死:不 launch,维持原错误
124
- with LOCK:
125
- _browsers[plat] = b
126
- page = b.first_page(create=False)
127
- if page is None:
128
- continue
129
- try:
130
- ok, _u = _check_login(plat, page)
131
- except Exception:
132
- continue
133
- if ok and _st(plat).get("status") == "error":
134
- _set(plat, status="connected", error="",
135
- last_login=time.strftime("%m-%d %H:%M"))
136
- ledger.record(plat, "connect", ok=True)
137
-
138
-
139
- # ---------------------------------------------------------------- 浏览器会话
140
- def _profiles_base():
141
- """profile 存放根:用户主目录 ~/.codebee/publish_profiles(仓库外)。
142
-
143
- 浏览器 profile 是 GB 级运行时数据(缓存/扩展/字体),放仓库 data/ 里
144
- 会拖垮静态扫描/备份(639M→986M 实测淹没整仓安全扫描),且登录态
145
- cookie 没必要进任何仓库周边流程。"""
146
- from pathlib import Path as _P
147
- return _P.home() / ".codebee" / "publish_profiles"
148
-
149
-
150
- def _migrate_legacy_profiles():
151
- """一次性迁移:老位置 data/publish/profiles/<plat> 整体搬到新根(保登录态)。
152
-
153
- 新位置已有同名平台目录时跳过(老数据视为已废弃);搬家失败静默——
154
- 大不了用户重扫一次码。"""
155
- from pathlib import Path as _P
156
- legacy = _P(paths.PUBLISH_DIR) / "profiles"
157
- if not legacy.is_dir():
158
- return
159
- base = _profiles_base()
160
- try:
161
- base.mkdir(parents=True, exist_ok=True)
162
- except OSError:
163
- return
164
- for plat_dir in legacy.iterdir():
165
- if not plat_dir.is_dir():
166
- continue
167
- dst = base / plat_dir.name
168
- if dst.exists():
169
- continue
170
- try:
171
- import shutil
172
- shutil.move(str(plat_dir), str(dst)) # 跨盘(仓库盘→系统盘)也能搬
173
- except OSError:
174
- continue
175
- try:
176
- legacy.rmdir() # 空了才删得掉;还有残留就留给下次
177
- except OSError:
178
- pass
179
-
180
-
181
- def _profile_dir(plat):
182
- return _profiles_base() / plat
183
-
184
-
185
- def _ensure_browser(plat):
186
- """attach-or-launch:进程内缓存 → 记住的端口 → 全新 launch。"""
187
- with LOCK:
188
- b = _browsers.get(plat)
189
- if b and b.alive():
190
- return b
191
- port = _st(plat).get("port") or 0
192
- if port:
193
- try:
194
- b = Browser.attach(int(port))
195
- _browsers[plat] = b
196
- return b
197
- except BrowserError:
198
- pass # 旧实例已死:走 launch
199
- b = Browser(_profile_dir(plat)) # 有窗口:用户要扫码/人工确认
200
- _browsers[plat] = b
201
- _set(plat, port=b.port)
202
- return b
203
-
204
-
205
- def _open_page(plat):
206
- b = _ensure_browser(plat)
207
- # 单标签纪律:平台点击(如「上传章节」)会开新 tab,流程引擎的 page
208
- # 对象可能留在旧 tab 上填错页面(0 字草稿案的乱源)。每次动作前收拢
209
- # 到一个 tab——发布是串行作业,多 tab 只会串台。
210
- try:
211
- for t in b.pages()[1:]:
212
- try:
213
- from .browser import _http_json
214
- _http_json("http://127.0.0.1:%d/json/close/%s" % (b.port, t["id"]))
215
- except Exception:
216
- pass
217
- except Exception:
218
- pass
219
- return b, b.first_page(create=True)
220
-
221
-
222
- def _check_login(plat, page):
223
- """打开后台首页看 URL 是否被踢到登录页。返回 (ok, 当前url)。
224
-
225
- 导航失败页(chrome-error://)不含登录标记,曾把「域名打不开」误判成
226
- 已登录——假 connected 的来源,先排除。"""
227
- mod = PLATFORMS[plat]
228
- try:
229
- page.navigate(mod.CONFIG["home"], timeout=30)
230
- except BrowserError as e:
231
- return False, str(e)
232
- url = str(page.url() or "")
233
- if url.startswith("chrome-error://") or url.startswith("about:"):
234
- return False, url # 页面没打开:网络/域名问题,不是登录态
235
- if any(m in url for m in mod.CONFIG["login_url_marks"]):
236
- return False, url
237
- return True, url
238
-
239
-
240
- # ---------------------------------------------------------------- 流程加载
241
- def load_flow(plat, action):
242
- """流程表三层:data/publish/flows-<plat>.json(用户校准)→ 仓库校准模板
243
- flows-<plat>-calibrated.json(真机验证过的基线,随代码分发)→ 平台模块
244
- 内置默认(待校准推测)。"""
245
- from pathlib import Path
246
- for fp in (paths.PUBLISH_DIR / ("flows-%s.json" % plat),
247
- Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
248
- try:
249
- data = json.loads(fp.read_text(encoding="utf-8"))
250
- if isinstance(data, dict) and isinstance(data.get(action), list):
251
- return data[action]
252
- except Exception:
253
- pass
254
- return PLATFORMS[plat].FLOWS[action]
255
-
256
-
257
- # ---------------------------------------------------------------- 动作:连接
258
- def connect(plat):
259
- """开浏览器到平台首页,起后台线程轮询登录态(等扫码)。"""
260
- if plat not in PLATFORMS:
261
- return False, "未知平台"
262
- try:
263
- b, page = _open_page(plat)
264
- except BrowserError as e:
265
- _set(plat, status="error", error=str(e))
266
- return False, str(e)
267
- page.navigate(PLATFORMS[plat].CONFIG["home"])
268
- _set(plat, status="waiting_login", error="")
269
-
270
- def wait_login():
271
- mod = PLATFORMS[plat]
272
- deadline = time.time() + LOGIN_WAIT_S
273
- while time.time() < deadline:
274
- try:
275
- url = str(page.url() or "")
276
- if url and not any(m in url for m in mod.CONFIG["login_url_marks"]) \
277
- and "about:blank" not in url and "chrome-error" not in url:
278
- # 用户登录完成(离开登录页)。再主动开一次首页做复核,
279
- # 复核被踢回登录页说明只是中间跳转,继续等。
280
- ok, _u = _check_login(plat, page)
281
- if ok:
282
- _set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
283
- ledger.record(plat, "connect", ok=True)
284
- return
285
- except (BrowserError, Exception):
286
- pass # 页面被用户关掉等:继续等到超时
287
- time.sleep(5)
288
- _finalize_login_wait(plat)
289
-
290
- threading.Thread(target=wait_login, daemon=True,
291
- name="pub-login-%s" % plat).start()
292
- return True, ""
293
-
294
-
295
- def _finalize_login_wait(plat):
296
- """等扫码超时后的终审:用户可能已在本窗口登录后把窗口关了(profile
297
- 里登录态还在),attach-or-launch 重拉页面再复核一次,别急着冤判
298
- 超时——「明明登录着却报超时」是发布卡死感的最大来源。复核也过不了
299
- 才落超时错误。返回 True 表示复核通过(已判 connected)。"""
300
- try:
301
- _b, page = _open_page(plat)
302
- ok, _u = _check_login(plat, page)
303
- except Exception:
304
- ok = False
305
- if ok:
306
- _set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
307
- ledger.record(plat, "connect", ok=True)
308
- else:
309
- _set(plat, status="error", error="等待登录超时(15 分钟),请重新点连接")
310
- return ok
311
-
312
-
313
- def disconnect(plat):
314
- b = _browsers.pop(plat, None)
315
- if b:
316
- b.close()
317
- _set(plat, status="none", error="")
318
- return True
319
-
320
-
321
- # ---------------------------------------------------------------- 动作:探测
322
- def probe_form_async(plat):
323
- """「探测表单」:跑 probe 流程把页面真实可交互元素 dump 进台账 log。"""
324
- if plat not in PLATFORMS:
325
- return False, "未知平台"
326
- if _st(plat).get("status") not in ("connected", "error"):
327
- return False, "请先连接并登录平台"
328
- _set(plat, status="busy", last_action="probe", error="")
329
-
330
- logs = []
331
-
332
- def run():
333
- try:
334
- b, page = _open_page(plat)
335
- flow.run_flow(page, load_flow(plat, "probe_form"),
336
- values={}, config=PLATFORMS[plat].CONFIG,
337
- shot=lambda n: page.screenshot(ledger.shot_path(plat, "probe", n)),
338
- log=logs.append)
339
- _set(plat, status="connected")
340
- ledger.record(plat, "probe", ok=True,
341
- error="\n".join(logs)[:2000])
342
- except Exception as e:
343
- _set(plat, status="error", error="探测失败:%s" % e)
344
- ledger.record(plat, "probe", ok=False, error=str(e)[:300])
345
- finally:
346
- _save()
347
-
348
- threading.Thread(target=run, daemon=True, name="pub-probe-%s" % plat).start()
349
- return True, ""
350
-
351
-
352
- # ---------------------------------------------------------------- 动作:建书
353
- def create_book_async(task_id, plat, auto_submit=False):
354
- """按任务 book_meta 的资料在平台建书。返回 (ok, err)。"""
355
- from .. import store
356
- if plat not in PLATFORMS:
357
- return False, "未知平台"
358
- task = store.get_task(task_id)
359
- if not task:
360
- return False, "任务不存在"
361
- meta = ((task.get("book_meta") or {}).get(plat) or {})
362
- if meta.get("status") != "done" or not meta.get("data"):
363
- return False, "请先生成该平台的作品信息"
364
- if _st(plat).get("status") == "busy":
365
- return False, "该平台有操作正在进行中"
366
- if ledger.book_for(task_id, plat):
367
- return False, "该任务已在此平台登记过作品,请直接发章"
368
- ok_login, why = _login_guard(plat)
369
- if not ok_login:
370
- return False, why
371
- _set(plat, status="busy", last_action="create_book", error="")
372
-
373
- data = meta["data"]
374
- mod = PLATFORMS[plat]
375
- logs = []
376
-
377
- def run():
378
- book_name = (data.get("book_name") or "").strip()
379
- try:
380
- b, page = _open_page(plat)
381
- values = mod.values_create_book(data)
382
- steps = _with_tag_steps(load_flow(plat, "create_book"),
383
- mod.tag_groups(data), values, mod)
384
- flow.run_flow(page, steps, values=values, config=mod.CONFIG,
385
- auto_submit=auto_submit,
386
- shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
387
- log=logs.append)
388
- # book_id:创建成功后平台跳书籍详情/编辑器,从 URL 提取
389
- # (番茄 book-info/<id>;七猫 information?id=<id>)。
390
- # 提取不到就先用书名登记,发章按书名找书。
391
- book_id = ""
392
- try:
393
- m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
394
- str(page.url() or ""))
395
- if m_url:
396
- book_id = m_url.group(1) or m_url.group(2)
397
- except Exception:
398
- pass
399
- ledger.record(plat, "create_book", task_id=task_id, title=book_name,
400
- book_id=book_id, ok=True,
401
- error="\n".join(logs)[:2000],
402
- shot=str(ledger.shot_path(plat, task_id, "")))
403
- ledger.save_book(task_id, plat, {"book_id": book_id, "title": book_name})
404
- _set(plat, status="connected", error="")
405
- except Exception as e:
406
- _set(plat, status="error", error="建书失败:%s" % e)
407
- ledger.record(plat, "create_book", task_id=task_id, title=book_name,
408
- ok=False, error=str(e)[:300],
409
- shot=str(ledger.shot_path(plat, task_id, "")))
410
- finally:
411
- _save()
412
-
413
- threading.Thread(target=run, daemon=True,
414
- name="pub-book-%s" % plat).start()
415
- return True, ""
416
-
417
-
418
- # ---------------------------------------------------------------- 动作:登记已有作品
419
- def register_book(task_id, plat, title, book_id=""):
420
- """人工登记平台已有作品:建书流程没走通、或用户纯手工在平台上建的
421
- 书,补进台账让卡片翻到「已建书·发一章」,避免再点「创建作品」造出
422
- 重复书。只动本地台账不碰浏览器;发章按书名找书,title 必填,
423
- book_id 选填(有直达 URL 时填)。已登记会覆盖更新(兼纠错口)。"""
424
- from .. import store
425
- if plat not in PLATFORMS:
426
- return False, "未知平台"
427
- if not store.get_task(task_id):
428
- return False, "任务不存在"
429
- title = (title or "").strip()
430
- if not title:
431
- return False, "作品名必填(发章按作品名在平台找书)"
432
- ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
433
- "title": title[:120]})
434
- return True, ""
435
-
436
-
437
- def _with_tag_steps(steps, groups, values, mod=None):
438
- """标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
439
- "tags" 步骤按组切换点选。组显示名映射来自**对应平台模块**的
440
- TAG_GROUP_LABELS(曾硬编码 qimao——番茄任务 key 撞名被套上七猫组名,
441
- 插桩 text 变 list repr)。兼容旧流程表(无 tags 步骤时插桩);流程表
442
- 已用 {tag_x} 占位(番茄式 click_real)时不插桩——占位由 values 自答。"""
443
- flat = []
444
- labels = getattr(mod, "TAG_GROUP_LABELS", {}) if mod else {}
445
- for key, tags in groups or []:
446
- grp = labels.get(key, "")
447
- flat.extend([grp, str(t)] if grp and str(t).strip() else str(t)
448
- for t in tags if str(t).strip())
449
- if not flat:
450
- return steps
451
- if any(st.get("do") == "tags" for st in steps):
452
- values["_tags"] = flat
453
- return steps
454
- if any("{tag_" in str(st.get("text") or "") for st in steps if st.get("text")):
455
- return steps # 流程表自带标签占位(番茄式):values 已就绪
456
- tag_steps = [{"do": "click_text", "text": str(t), "contains": False,
457
- "scope": "[class*=tag] li,span,label,[class*=label]"}
458
- for t in flat]
459
- out, inserted = list(steps), False
460
- for i, st in enumerate(out):
461
- if st.get("do") in ("submit", "shot") and not inserted:
462
- out[i:i] = tag_steps
463
- inserted = True
464
- if not inserted:
465
- out += tag_steps
466
- return out
467
-
468
-
469
- def _login_guard(plat):
470
- """动作前的登录前置:状态 connected 放行;否则现场复核一次。"""
471
- s = _st(plat)
472
- if s.get("status") == "connected":
473
- return True, ""
474
- try:
475
- _b, page = _open_page(plat)
476
- ok, url = _check_login(plat, page)
477
- if ok:
478
- _set(plat, status="connected")
479
- return True, ""
480
- return False, "平台未登录(当前页面 %s),请先点「连接平台」扫码" % url[:80]
481
- except BrowserError as e:
482
- return False, "浏览器不可用:%s" % e
483
-
484
-
485
- # ---------------------------------------------------------------- 动作:发章
486
- def read_chapter(fp):
487
- """读章节文件 → (章号, 标题, 正文, 错误)。UTF-8→GBK 回退(章稿乱码教训)。
488
-
489
- 标题取首个「# 」标题行,没有则用文件名;章号从标题/文件名的
490
- 「第X章」解析,解析不出记 0(台账不按章号幂等,只按标题留痕)。"""
491
- from pathlib import Path
492
- from .. import runner
493
- p = Path(fp)
494
- if not p.is_file():
495
- return 0, "", "", "章节文件不存在:%s" % p
496
- try:
497
- text = runner.read_text_any_enc(p)
498
- except Exception as e:
499
- return 0, "", "", "读章节文件失败:%s" % e
500
- lines = text.strip().splitlines()
501
- title = ""
502
- body_start = 0
503
- for i, ln in enumerate(lines[:5]):
504
- s = ln.strip()
505
- if s.startswith("#"): # 只认 markdown 标题行
506
- s = s.lstrip("#").strip()
507
- if s:
508
- title, body_start = s, i + 1
509
- break
510
- if not title:
511
- title = p.stem
512
- body = "\n".join(lines[body_start:]).strip()
513
- ch_no = ledger.parse_chapter_no(title) or ledger.parse_chapter_no(p.name)
514
- return ch_no, title, body, ""
515
-
516
-
517
- def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
518
- """把一章发到平台(或填好待人工确认)。幂等:已成功发布的章号拒绝重发。"""
519
- from .. import store
520
- if plat not in PLATFORMS:
521
- return False, "未知平台"
522
- task = store.get_task(task_id)
523
- if not task:
524
- return False, "任务不存在"
525
- book = ledger.book_for(task_id, plat)
526
- if not book:
527
- return False, "该任务尚未在此平台建书,请先「创建作品」"
528
- if _st(plat).get("status") == "busy":
529
- return False, "该平台有操作正在进行中"
530
- ch_no, title, body, err = read_chapter(chapter_file)
531
- if err:
532
- return False, err
533
- n_chars = len(body.replace("\n", "").replace(" ", ""))
534
- if n_chars < 100:
535
- return False, "正文过短(%d 字),疑似未完成章节" % n_chars
536
- # 平台级下限(如七猫编辑器明示「最少 1000 字」:不足时发布被静默拦截)
537
- min_chars = int(PLATFORMS[plat].CONFIG.get("min_chapter_chars") or 100)
538
- if n_chars < min_chars:
539
- return False, ("正文 %d 字未达该平台下限(%d 字),补足后再发"
540
- % (n_chars, min_chars))
541
- if n_chars > 30000:
542
- return False, "正文超长(%d 字),平台单章上限一般 2 万字" % n_chars
543
- done = ledger.published_chapters(task_id, plat)
544
- if ch_no and ch_no in done:
545
- return False, "第 %d 章已成功发布过(台账幂等拦截);确需重发请手工处理" % ch_no
546
- ok_login, why = _login_guard(plat)
547
- if not ok_login:
548
- return False, why
549
- _set(plat, status="busy", last_action="upload_chapter", error="")
550
-
551
- mod = PLATFORMS[plat]
552
- values = {"chapter_title": title, "chapter_body": body,
553
- "book_name": book.get("title") or ""}
554
- try: # verify/draft 页 URL(流程占位)
555
- values["chapter_manage_url"] = mod.chapter_manage_url(book)
556
- values["draft_url"] = mod.draft_url(book)
557
- values["editor_url"] = mod.editor_url(book)
558
- except AttributeError:
559
- pass
560
- logs = []
561
-
562
- def run():
563
- try:
564
- b, page = _open_page(plat)
565
- flow.run_flow(page, load_flow(plat, "upload_chapter"), values=values,
566
- config=mod.CONFIG, auto_submit=auto_submit,
567
- shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
568
- log=logs.append)
569
- ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
570
- book_id=book.get("book_id") or "", title=title, ok=True)
571
- _set(plat, status="connected", error="")
572
- except Exception as e:
573
- _set(plat, status="error", error="发章失败:%s" % e)
574
- ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
575
- book_id=book.get("book_id") or "", title=title,
576
- ok=False, error=str(e)[:300])
577
- finally:
578
- _save()
579
-
580
- threading.Thread(target=run, daemon=True,
581
- name="pub-ch-%s" % plat).start()
582
- return True, ""
583
-
584
-
585
- def history(task_id=None, plat=None, limit=50):
586
- return ledger.recent(task_id=task_id, platform=plat, limit=limit)
587
-
588
-
589
- _load()
590
- _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
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 view():
77
+ """前端状态视图:平台状态 + 是否找到浏览器 + 作品登记。"""
78
+ from .browser import find_browser
79
+ out = {}
80
+ for pid, mod in PLATFORMS.items():
81
+ s = _st(pid)
82
+ out[pid] = {"label": mod.CONFIG["label"], "status": s.get("status") or "none",
83
+ "at": s.get("at") or "", "error": s.get("error") or "",
84
+ "last_action": s.get("last_action") or "",
85
+ "profile": str(_profile_dir(pid))}
86
+ return {"platforms": out, "browser_found": bool(find_browser())}
87
+
88
+
89
+ def recover_orphans():
90
+ """启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。
91
+
92
+ 升级自愈:旧版「等扫码窗口一关就冤判超时」留下的 error(error 文案带
93
+ 「等待登录超时」)排队后台复核——profile 登录态还在的直接翻 connected,
94
+ 别让升级完还挂着旧冤案;attach 不到活实例就维持原样(用户点重连时
95
+ 新终审逻辑自会兜住)。"""
96
+ _load()
97
+ n = 0
98
+ stale = []
99
+ for plat in PLATFORMS:
100
+ s = _st(plat)
101
+ if s.get("status") in ("waiting_login", "busy"):
102
+ _set(plat, status="error", error="上次操作随服务重启中断,请重试")
103
+ n += 1
104
+ elif s.get("status") == "error" and "等待登录超时" in (s.get("error") or ""):
105
+ stale.append(plat)
106
+ if stale:
107
+ threading.Thread(target=_recheck_stale_errors, args=(stale,),
108
+ daemon=True, name="pub-stale-recheck").start()
109
+ return n
110
+
111
+
112
+ def _recheck_stale_errors(plats):
113
+ """旧版假超时的后台复核:只 attach 还活着的浏览器实例(绝不 launch——
114
+ 升级重启时静默弹窗口吓人),登录态复核过了翻 connected;否则不动。"""
115
+ for plat in plats:
116
+ if _st(plat).get("status") != "error":
117
+ continue # 用户已先行操作(重连/断开),别覆盖
118
+ port = _st(plat).get("port") or 0
119
+ if not port:
120
+ continue
121
+ try:
122
+ b = Browser.attach(int(port))
123
+ except Exception:
124
+ continue # 实例已死:不 launch,维持原错误
125
+ with LOCK:
126
+ _browsers[plat] = b
127
+ page = b.first_page(create=False)
128
+ if page is None:
129
+ continue
130
+ try:
131
+ ok, _u = _check_login(plat, page)
132
+ except Exception:
133
+ continue
134
+ if ok and _st(plat).get("status") == "error":
135
+ _set(plat, status="connected", error="",
136
+ last_login=time.strftime("%m-%d %H:%M"))
137
+ ledger.record(plat, "connect", ok=True)
138
+
139
+
140
+ # ---------------------------------------------------------------- 浏览器会话
141
+ def _profiles_base():
142
+ """profile 存放根:用户主目录 ~/.codebee/publish_profiles(仓库外)。
143
+
144
+ 浏览器 profile 是 GB 级运行时数据(缓存/扩展/字体),放仓库 data/ 里
145
+ 会拖垮静态扫描/备份(639M→986M 实测淹没整仓安全扫描),且登录态
146
+ cookie 没必要进任何仓库周边流程。"""
147
+ from pathlib import Path as _P
148
+ return _P.home() / ".codebee" / "publish_profiles"
149
+
150
+
151
+ def _migrate_legacy_profiles():
152
+ """一次性迁移:老位置 data/publish/profiles/<plat> 整体搬到新根(保登录态)。
153
+
154
+ 新位置已有同名平台目录时跳过(老数据视为已废弃);搬家失败静默——
155
+ 大不了用户重扫一次码。"""
156
+ from pathlib import Path as _P
157
+ legacy = _P(paths.PUBLISH_DIR) / "profiles"
158
+ if not legacy.is_dir():
159
+ return
160
+ base = _profiles_base()
161
+ try:
162
+ base.mkdir(parents=True, exist_ok=True)
163
+ except OSError:
164
+ return
165
+ for plat_dir in legacy.iterdir():
166
+ if not plat_dir.is_dir():
167
+ continue
168
+ dst = base / plat_dir.name
169
+ if dst.exists():
170
+ continue
171
+ try:
172
+ import shutil
173
+ shutil.move(str(plat_dir), str(dst)) # 跨盘(仓库盘→系统盘)也能搬
174
+ except OSError:
175
+ continue
176
+ try:
177
+ legacy.rmdir() # 空了才删得掉;还有残留就留给下次
178
+ except OSError:
179
+ pass
180
+
181
+
182
+ def _profile_dir(plat):
183
+ return _profiles_base() / plat
184
+
185
+
186
+ def _ensure_browser(plat):
187
+ """attach-or-launch:进程内缓存 → 记住的端口 → 全新 launch。"""
188
+ with LOCK:
189
+ b = _browsers.get(plat)
190
+ if b and b.alive():
191
+ return b
192
+ port = _st(plat).get("port") or 0
193
+ if port:
194
+ try:
195
+ b = Browser.attach(int(port))
196
+ _browsers[plat] = b
197
+ return b
198
+ except BrowserError:
199
+ pass # 旧实例已死:走 launch
200
+ b = Browser(_profile_dir(plat)) # 有窗口:用户要扫码/人工确认
201
+ _browsers[plat] = b
202
+ _set(plat, port=b.port)
203
+ return b
204
+
205
+
206
+ def _open_page(plat):
207
+ b = _ensure_browser(plat)
208
+ # 单标签纪律:平台点击(如「上传章节」)会开新 tab,流程引擎的 page
209
+ # 对象可能留在旧 tab 上填错页面(0 字草稿案的乱源)。每次动作前收拢
210
+ # 到一个 tab——发布是串行作业,多 tab 只会串台。
211
+ try:
212
+ for t in b.pages()[1:]:
213
+ try:
214
+ from .browser import _http_json
215
+ _http_json("http://127.0.0.1:%d/json/close/%s" % (b.port, t["id"]))
216
+ except Exception:
217
+ pass
218
+ except Exception:
219
+ pass
220
+ return b, b.first_page(create=True)
221
+
222
+
223
+ def _check_login(plat, page):
224
+ """打开后台首页看 URL 是否被踢到登录页。返回 (ok, 当前url)。
225
+
226
+ 导航失败页(chrome-error://)不含登录标记,曾把「域名打不开」误判成
227
+ 已登录——假 connected 的来源,先排除。"""
228
+ mod = PLATFORMS[plat]
229
+ try:
230
+ page.navigate(mod.CONFIG["home"], timeout=30)
231
+ except BrowserError as e:
232
+ return False, str(e)
233
+ url = str(page.url() or "")
234
+ if url.startswith("chrome-error://") or url.startswith("about:"):
235
+ return False, url # 页面没打开:网络/域名问题,不是登录态
236
+ if any(m in url for m in mod.CONFIG["login_url_marks"]):
237
+ return False, url
238
+ return True, url
239
+
240
+
241
+ # ---------------------------------------------------------------- 流程加载
242
+ def load_flow(plat, action):
243
+ """流程表三层:data/publish/flows-<plat>.json(用户校准)→ 仓库校准模板
244
+ flows-<plat>-calibrated.json(真机验证过的基线,随代码分发)→ 平台模块
245
+ 内置默认(待校准推测)。"""
246
+ from pathlib import Path
247
+ for fp in (paths.PUBLISH_DIR / ("flows-%s.json" % plat),
248
+ Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
249
+ try:
250
+ data = json.loads(fp.read_text(encoding="utf-8"))
251
+ if isinstance(data, dict) and isinstance(data.get(action), list):
252
+ return data[action]
253
+ except Exception:
254
+ pass
255
+ return PLATFORMS[plat].FLOWS[action]
256
+
257
+
258
+ # ---------------------------------------------------------------- 动作:连接
259
+ def connect(plat):
260
+ """开浏览器到平台首页,起后台线程轮询登录态(等扫码)。"""
261
+ if plat not in PLATFORMS:
262
+ return False, "未知平台"
263
+ try:
264
+ b, page = _open_page(plat)
265
+ except BrowserError as e:
266
+ _set(plat, status="error", error=str(e))
267
+ return False, str(e)
268
+ page.navigate(PLATFORMS[plat].CONFIG["home"])
269
+ _set(plat, status="waiting_login", error="")
270
+
271
+ def wait_login():
272
+ mod = PLATFORMS[plat]
273
+ deadline = time.time() + LOGIN_WAIT_S
274
+ while time.time() < deadline:
275
+ try:
276
+ url = str(page.url() or "")
277
+ if url and not any(m in url for m in mod.CONFIG["login_url_marks"]) \
278
+ and "about:blank" not in url and "chrome-error" not in url:
279
+ # 用户登录完成(离开登录页)。再主动开一次首页做复核,
280
+ # 复核被踢回登录页说明只是中间跳转,继续等。
281
+ ok, _u = _check_login(plat, page)
282
+ if ok:
283
+ _set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
284
+ ledger.record(plat, "connect", ok=True)
285
+ return
286
+ except (BrowserError, Exception):
287
+ pass # 页面被用户关掉等:继续等到超时
288
+ time.sleep(5)
289
+ _finalize_login_wait(plat)
290
+
291
+ threading.Thread(target=wait_login, daemon=True,
292
+ name="pub-login-%s" % plat).start()
293
+ return True, ""
294
+
295
+
296
+ def _finalize_login_wait(plat):
297
+ """等扫码超时后的终审:用户可能已在本窗口登录后把窗口关了(profile
298
+ 里登录态还在),attach-or-launch 重拉页面再复核一次,别急着冤判
299
+ 超时——「明明登录着却报超时」是发布卡死感的最大来源。复核也过不了
300
+ 才落超时错误。返回 True 表示复核通过(已判 connected)。"""
301
+ try:
302
+ _b, page = _open_page(plat)
303
+ ok, _u = _check_login(plat, page)
304
+ except Exception:
305
+ ok = False
306
+ if ok:
307
+ _set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
308
+ ledger.record(plat, "connect", ok=True)
309
+ else:
310
+ _set(plat, status="error", error="等待登录超时(15 分钟),请重新点连接")
311
+ return ok
312
+
313
+
314
+ def disconnect(plat):
315
+ b = _browsers.pop(plat, None)
316
+ if b:
317
+ b.close()
318
+ _set(plat, status="none", error="")
319
+ return True
320
+
321
+
322
+ # ---------------------------------------------------------------- 动作:探测
323
+ def probe_form_async(plat):
324
+ """「探测表单」:跑 probe 流程把页面真实可交互元素 dump 进台账 log。"""
325
+ if plat not in PLATFORMS:
326
+ return False, "未知平台"
327
+ if _st(plat).get("status") not in ("connected", "error"):
328
+ return False, "请先连接并登录平台"
329
+ _set(plat, status="busy", last_action="probe", error="")
330
+
331
+ logs = []
332
+
333
+ def run():
334
+ try:
335
+ b, page = _open_page(plat)
336
+ flow.run_flow(page, load_flow(plat, "probe_form"),
337
+ values={}, config=PLATFORMS[plat].CONFIG,
338
+ shot=lambda n: page.screenshot(ledger.shot_path(plat, "probe", n)),
339
+ log=logs.append)
340
+ _set(plat, status="connected")
341
+ ledger.record(plat, "probe", ok=True,
342
+ error="\n".join(logs)[:2000])
343
+ except Exception as e:
344
+ _set(plat, status="error", error="探测失败:%s" % e)
345
+ ledger.record(plat, "probe", ok=False, error=str(e)[:300])
346
+ finally:
347
+ _save()
348
+
349
+ threading.Thread(target=run, daemon=True, name="pub-probe-%s" % plat).start()
350
+ return True, ""
351
+
352
+
353
+ # ---------------------------------------------------------------- 动作:建书
354
+ def _create_preflight(plat, data):
355
+ """建书前置闸:平台表单对必填字段有硬校验(番茄简介 50-500 字),过不了
356
+ 校验时页面不跳转、流程只能误报「未登录或改版」——开浏览器之前先拦下,
357
+ 把人话原因还给用户。返回错误文案,空串=放行。"""
358
+ placeholders = {"待补充", "(待补充)", "(待补充)"}
359
+ title = str(data.get("book_name") or "").strip()
360
+ if not title or title in placeholders:
361
+ return "作品名称为空,请先补书名再点「创建作品」"
362
+ summary = str(data.get("summary") or "").strip()
363
+ if plat == "fanqie":
364
+ if len(summary) < 50 or len(summary) > 500:
365
+ return ("作品简介 %d 字,番茄建书表单要求 50-500 字;"
366
+ "请先补简介再点「创建作品」" % len(summary))
367
+ if not str(data.get("category") or "").strip():
368
+ return "作品分类为空,请先重生成作品信息再建书"
369
+ for key in ("tags_theme", "tags_role", "tags_plot"):
370
+ if not (data.get(key) or []):
371
+ return "作品标签(%s)为空,请先重生成作品信息再建书" % key
372
+ elif plat == "qimao":
373
+ if not summary or summary in placeholders:
374
+ return "作品简介为空,请先补简介再点「创建作品」"
375
+ for key in ("category_main", "category_sub"):
376
+ if not str(data.get(key) or "").strip():
377
+ return "作品分类为空,请先重生成作品信息再建书"
378
+ for key in ("tags_style", "tags_role", "tags_plot", "tags_bg"):
379
+ if not (data.get(key) or []):
380
+ return "作品标签(%s)为空,请先重生成作品信息再建书" % key
381
+ return ""
382
+
383
+
384
+ def create_book_async(task_id, plat, auto_submit=False):
385
+ """按任务 book_meta 的资料在平台建书。返回 (ok, err)。"""
386
+ from .. import store
387
+ if plat not in PLATFORMS:
388
+ return False, "未知平台"
389
+ task = store.get_task(task_id)
390
+ if not task:
391
+ return False, "任务不存在"
392
+ meta = ((task.get("book_meta") or {}).get(plat) or {})
393
+ if meta.get("status") != "done" or not meta.get("data"):
394
+ return False, "请先生成该平台的作品信息"
395
+ if _st(plat).get("status") == "busy":
396
+ return False, "该平台有操作正在进行中"
397
+ if ledger.book_for(task_id, plat):
398
+ return False, "该任务已在此平台登记过作品,请直接发章"
399
+ why = _create_preflight(plat, meta["data"])
400
+ if why:
401
+ return False, why
402
+ ok_login, why = _login_guard(plat)
403
+ if not ok_login:
404
+ return False, why
405
+ _set(plat, status="busy", last_action="create_book", error="")
406
+
407
+ data = meta["data"]
408
+ mod = PLATFORMS[plat]
409
+ logs = []
410
+ book_name = (data.get("book_name") or "").strip()
411
+ operation_id = operations.begin(
412
+ "publish:%s:create_book" % plat,
413
+ {"task_id": task_id, "title": book_name}, task_id=task_id,
414
+ metadata={"platform": plat, "action": "create_book"})
415
+
416
+ def run():
417
+ try:
418
+ b, page = _open_page(plat)
419
+ values = mod.values_create_book(data)
420
+ steps = _with_tag_steps(load_flow(plat, "create_book"),
421
+ mod.tag_groups(data), values, mod)
422
+ flow.run_flow(page, steps, values=values, config=mod.CONFIG,
423
+ auto_submit=auto_submit,
424
+ shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
425
+ log=logs.append)
426
+ # book_id:创建成功后平台跳书籍详情/编辑器,从 URL 提取
427
+ # (番茄 book-info/<id>;七猫 information?id=<id>)。url_any 过了
428
+ # 但页面还在跳转链上时再等几轮;提取落空把最终 URL 记进日志,
429
+ # 别再静默登记空 id(发章只能兜底找书,直达编辑器就废了)。
430
+ book_id = ""
431
+ try:
432
+ for _try in range(6):
433
+ m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
434
+ str(page.url() or ""))
435
+ if m_url:
436
+ book_id = m_url.group(1) or m_url.group(2)
437
+ break
438
+ time.sleep(0.8)
439
+ if not book_id:
440
+ logs.append("book_id 提取落空,最终页面:%s"
441
+ % str(page.url() or "")[:120])
442
+ except Exception:
443
+ pass
444
+ operation_status = "confirmed" if book_id else "unknown"
445
+ if book_id:
446
+ operations.confirm(operation_id, remote_receipt=book_id,
447
+ metadata={"platform": plat, "action": "create_book"})
448
+ else:
449
+ operations.mark_unknown(
450
+ operation_id,
451
+ "建书流程完成但未取得远端 book_id,请先在平台核对后再重试",
452
+ metadata={"platform": plat, "action": "create_book"})
453
+ ledger.record(plat, "create_book", task_id=task_id, title=book_name,
454
+ book_id=book_id, ok=bool(book_id),
455
+ error=("\n".join(logs)[:2000] if book_id else
456
+ "建书结果未知:未取得远端 book_id;请先对账"),
457
+ shot=str(ledger.shot_path(plat, task_id, "")),
458
+ operation_id=operation_id, operation_status=operation_status,
459
+ remote_receipt=book_id)
460
+ ledger.save_book(task_id, plat, {"book_id": book_id, "title": book_name})
461
+ _set(plat, status="connected", error="")
462
+ except Exception as e:
463
+ operations.finish_exception(operation_id, e)
464
+ operation_status = (operations.get(operation_id) or {}).get("status") or "failed"
465
+ _set(plat, status="error", error="建书失败:%s" % e)
466
+ ledger.record(plat, "create_book", task_id=task_id, title=book_name,
467
+ ok=False, error=str(e)[:300],
468
+ shot=str(ledger.shot_path(plat, task_id, "")),
469
+ operation_id=operation_id, operation_status=operation_status)
470
+ finally:
471
+ _save()
472
+
473
+ threading.Thread(target=run, daemon=True,
474
+ name="pub-book-%s" % plat).start()
475
+ return True, ""
476
+
477
+
478
+ # ---------------------------------------------------------------- 动作:登记已有作品
479
+ def register_book(task_id, plat, title, book_id=""):
480
+ """人工登记平台已有作品:建书流程没走通、或用户纯手工在平台上建的
481
+ 书,补进台账让卡片翻到「已建书·发一章」,避免再点「创建作品」造出
482
+ 重复书。只动本地台账不碰浏览器;发章按书名找书,title 必填,
483
+ book_id 选填(有直达 URL 时填)。已登记会覆盖更新(兼纠错口)。"""
484
+ from .. import store
485
+ if plat not in PLATFORMS:
486
+ return False, "未知平台"
487
+ if not store.get_task(task_id):
488
+ return False, "任务不存在"
489
+ title = (title or "").strip()
490
+ if not title:
491
+ return False, "作品名必填(发章按作品名在平台找书)"
492
+ ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
493
+ "title": title[:120]})
494
+ return True, ""
495
+
496
+
497
+ def _with_tag_steps(steps, groups, values, mod=None):
498
+ """标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
499
+ "tags" 步骤按组切换点选。组显示名映射来自**对应平台模块**的
500
+ TAG_GROUP_LABELS(曾硬编码 qimao——番茄任务 key 撞名被套上七猫组名,
501
+ 插桩 text 变 list repr)。兼容旧流程表(无 tags 步骤时插桩);流程表
502
+ 已用 {tag_x} 占位(番茄式 click_real)时不插桩——占位由 values 自答。"""
503
+ flat = []
504
+ labels = getattr(mod, "TAG_GROUP_LABELS", {}) if mod else {}
505
+ for key, tags in groups or []:
506
+ grp = labels.get(key, "")
507
+ flat.extend([grp, str(t)] if grp and str(t).strip() else str(t)
508
+ for t in tags if str(t).strip())
509
+ if not flat:
510
+ return steps
511
+ if any(st.get("do") == "tags" for st in steps):
512
+ values["_tags"] = flat
513
+ return steps
514
+ if any("{tag_" in str(st.get("text") or "") for st in steps if st.get("text")):
515
+ return steps # 流程表自带标签占位(番茄式):values 已就绪
516
+ tag_steps = [{"do": "click_text", "text": str(t), "contains": False,
517
+ "scope": "[class*=tag] li,span,label,[class*=label]"}
518
+ for t in flat]
519
+ out, inserted = list(steps), False
520
+ for i, st in enumerate(out):
521
+ if st.get("do") in ("submit", "shot") and not inserted:
522
+ out[i:i] = tag_steps
523
+ inserted = True
524
+ if not inserted:
525
+ out += tag_steps
526
+ return out
527
+
528
+
529
+ def _login_guard(plat):
530
+ """动作前的登录前置:状态 connected 放行;否则现场复核一次。"""
531
+ s = _st(plat)
532
+ if s.get("status") == "connected":
533
+ return True, ""
534
+ try:
535
+ _b, page = _open_page(plat)
536
+ ok, url = _check_login(plat, page)
537
+ if ok:
538
+ _set(plat, status="connected")
539
+ return True, ""
540
+ return False, "平台未登录(当前页面 %s),请先点「连接平台」扫码" % url[:80]
541
+ except BrowserError as e:
542
+ return False, "浏览器不可用:%s" % e
543
+
544
+
545
+ def _url_values(mod, book):
546
+ """平台模块提供的 URL 占位值(chapter_manage_url/draft_url/editor_url),
547
+ 逐键取、缺哪个跳哪个。曾按序连赋 + except AttributeError 兜底:draft_url
548
+ 只有七猫有,番茄走到第二行即断,editor_url 永远赋不上——{editor_url}
549
+ 残进 navigate,页面停在 about:blank 误报「未登录或改版」(0924 三连败)。"""
550
+ out = {}
551
+ for key in ("chapter_manage_url", "draft_url", "editor_url"):
552
+ fn = getattr(mod, key, None)
553
+ if callable(fn):
554
+ out[key] = fn(book)
555
+ return out
556
+
557
+
558
+ def _resolve_book_id(plat, page, book):
559
+ """登记缺 book_id 时按书名在作家后台找回(平台模块可选提供
560
+ resolve_book_id)。找不回返回空串:流程按无 id 兜底路径继续,让后续
561
+ 步骤如实报错,绝不在这里拦死。找回后回写 books.json,下次直达。"""
562
+ fn = getattr(PLATFORMS[plat], "resolve_book_id", None)
563
+ if not callable(fn) or str((book or {}).get("book_id") or "").strip():
564
+ return ""
565
+ try:
566
+ return str(fn(page, book) or "").strip()
567
+ except Exception:
568
+ return ""
569
+
570
+
571
+ # ---------------------------------------------------------------- 动作:发章
572
+ def read_chapter(fp):
573
+ """读章节文件 → (章号, 标题, 正文, 错误)。UTF-8→GBK 回退(章稿乱码教训)。
574
+
575
+ 标题取首个「# 」标题行,没有则用文件名;章号从标题/文件名的
576
+ 「第X章」解析,解析不出记 0(台账不按章号幂等,只按标题留痕)。"""
577
+ from pathlib import Path
578
+ from .. import runner
579
+ p = Path(fp)
580
+ if not p.is_file():
581
+ return 0, "", "", "章节文件不存在:%s" % p
582
+ try:
583
+ text = runner.read_text_any_enc(p)
584
+ except Exception as e:
585
+ return 0, "", "", "读章节文件失败:%s" % e
586
+ lines = text.strip().splitlines()
587
+ title = ""
588
+ body_start = 0
589
+ for i, ln in enumerate(lines[:5]):
590
+ s = ln.strip()
591
+ if s.startswith("#"): # 只认 markdown 标题行
592
+ s = s.lstrip("#").strip()
593
+ if s:
594
+ title, body_start = s, i + 1
595
+ break
596
+ if not title:
597
+ title = p.stem
598
+ body = "\n".join(lines[body_start:]).strip()
599
+ ch_no = ledger.parse_chapter_no(title) or ledger.parse_chapter_no(p.name)
600
+ return ch_no, title, body, ""
601
+
602
+
603
+ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
604
+ """把一章发到平台(或填好待人工确认)。幂等:已成功发布的章号拒绝重发。"""
605
+ from .. import store
606
+ if plat not in PLATFORMS:
607
+ return False, "未知平台"
608
+ task = store.get_task(task_id)
609
+ if not task:
610
+ return False, "任务不存在"
611
+ book = ledger.book_for(task_id, plat)
612
+ if not book:
613
+ return False, "该任务尚未在此平台建书,请先「创建作品」"
614
+ # 闭包中需要在找回 book_id 后更新书籍引用;使用独立副本避免
615
+ # Python 将 book 误判为 run() 的局部变量,导致前置解析触发
616
+ # UnboundLocalError。
617
+ book_ref = dict(book)
618
+ if _st(plat).get("status") == "busy":
619
+ return False, "该平台有操作正在进行中"
620
+ ch_no, title, body, err = read_chapter(chapter_file)
621
+ if err:
622
+ return False, err
623
+ n_chars = len(body.replace("\n", "").replace(" ", ""))
624
+ if n_chars < 100:
625
+ return False, "正文过短(%d 字),疑似未完成章节" % n_chars
626
+ # 平台级下限(如七猫编辑器明示「最少 1000 字」:不足时发布被静默拦截)
627
+ min_chars = int(PLATFORMS[plat].CONFIG.get("min_chapter_chars") or 100)
628
+ if n_chars < min_chars:
629
+ return False, ("正文 %d 字未达该平台下限(%d 字),补足后再发"
630
+ % (n_chars, min_chars))
631
+ if n_chars > 30000:
632
+ return False, "正文超长(%d 字),平台单章上限一般 2 万字" % n_chars
633
+ done = ledger.published_chapters(task_id, plat)
634
+ if ch_no and ch_no in done:
635
+ return False, "第 %d 章已成功发布过(台账幂等拦截);确需重发请手工处理" % ch_no
636
+ ok_login, why = _login_guard(plat)
637
+ if not ok_login:
638
+ return False, why
639
+ _set(plat, status="busy", last_action="upload_chapter", error="")
640
+
641
+ mod = PLATFORMS[plat]
642
+ values = {"chapter_title": title, "chapter_body": body,
643
+ "book_name": book_ref.get("title") or ""}
644
+ values.update(_url_values(mod, book_ref)) # verify/draft/editor 页 URL(流程占位)
645
+ logs = []
646
+ operation_id = operations.begin(
647
+ "publish:%s:upload_chapter" % plat,
648
+ {"task_id": task_id, "chapter_no": ch_no, "title": title}, task_id=task_id,
649
+ metadata={"platform": plat, "action": "upload_chapter"})
650
+
651
+ def run():
652
+ try:
653
+ b, page = _open_page(plat)
654
+ bid = _resolve_book_id(plat, page, book_ref)
655
+ if bid: # 建书时没提上 id 的书,发章前补账
656
+ book_ref.update(book_id=bid)
657
+ values.update(_url_values(mod, book_ref))
658
+ ledger.save_book(task_id, plat, book_ref)
659
+ logs.append("已按书名找回 book_id=%s 并更新登记" % bid)
660
+ flow.run_flow(page, load_flow(plat, "upload_chapter"), values=values,
661
+ config=mod.CONFIG, auto_submit=auto_submit,
662
+ shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
663
+ log=logs.append)
664
+ operations.confirm(operation_id, metadata={"platform": plat,
665
+ "action": "upload_chapter",
666
+ "chapter_no": ch_no})
667
+ ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
668
+ book_id=book_ref.get("book_id") or "", title=title, ok=True,
669
+ operation_id=operation_id, operation_status="confirmed")
670
+ _set(plat, status="connected", error="")
671
+ except Exception as e:
672
+ operations.finish_exception(operation_id, e)
673
+ operation_status = (operations.get(operation_id) or {}).get("status") or "failed"
674
+ _set(plat, status="error", error="发章失败:%s" % e)
675
+ ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
676
+ book_id=book_ref.get("book_id") or "", title=title,
677
+ ok=False, error=str(e)[:300], operation_id=operation_id,
678
+ operation_status=operation_status)
679
+ finally:
680
+ _save()
681
+
682
+ threading.Thread(target=run, daemon=True,
683
+ name="pub-ch-%s" % plat).start()
684
+ return True, ""
685
+
686
+
687
+ def history(task_id=None, plat=None, limit=50):
688
+ return ledger.recent(task_id=task_id, platform=plat, limit=limit)
689
+
690
+
691
+ _load()
692
+ _migrate_legacy_profiles()