codebee 0.1.65 → 0.1.66

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 (110) hide show
  1. package/CHANGELOG.md +520 -513
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/aiflavor.py +197 -197
  5. package/app/core/artifacts.py +148 -148
  6. package/app/core/attachments.py +532 -532
  7. package/app/core/automation.py +758 -758
  8. package/app/core/backup.py +470 -470
  9. package/app/core/beekeeper.py +183 -183
  10. package/app/core/board.py +209 -209
  11. package/app/core/bookmeta.py +796 -759
  12. package/app/core/bookmeta_catalog.py +180 -180
  13. package/app/core/branching.py +137 -137
  14. package/app/core/builtin_agent.py +1234 -1191
  15. package/app/core/capability.py +144 -144
  16. package/app/core/catalog.py +23 -24
  17. package/app/core/chaptersafe.py +32 -32
  18. package/app/core/cleanup.py +303 -303
  19. package/app/core/compaction.py +201 -186
  20. package/app/core/covergen.py +356 -356
  21. package/app/core/diagnostics.py +114 -114
  22. package/app/core/dispatch.py +173 -173
  23. package/app/core/dispatch_log.py +113 -113
  24. package/app/core/env_scrub.py +85 -85
  25. package/app/core/error_codes.py +220 -65
  26. package/app/core/errorlog.py +137 -137
  27. package/app/core/evaluation.py +174 -0
  28. package/app/core/failure_notify.py +290 -0
  29. package/app/core/flows.py +355 -355
  30. package/app/core/goal_service.py +167 -167
  31. package/app/core/health.py +374 -342
  32. package/app/core/history.py +38 -38
  33. package/app/core/hooks.py +162 -156
  34. package/app/core/jobs.py +1034 -1022
  35. package/app/core/knowledge.py +6 -2
  36. package/app/core/manager.py +2115 -1996
  37. package/app/core/market.py +362 -362
  38. package/app/core/market_remote.py +66 -66
  39. package/app/core/mocks.py +64 -64
  40. package/app/core/modelhub.py +3517 -3332
  41. package/app/core/notify.py +106 -106
  42. package/app/core/paihang.py +109 -109
  43. package/app/core/paths.py +2 -2
  44. package/app/core/pipeline.py +4614 -4494
  45. package/app/core/planner.py +33 -31
  46. package/app/core/portguard.py +111 -111
  47. package/app/core/preview.py +293 -293
  48. package/app/core/publish/__init__.py +7 -7
  49. package/app/core/publish/auto.py +367 -367
  50. package/app/core/publish/browser.py +533 -533
  51. package/app/core/publish/fanqie.py +128 -128
  52. package/app/core/publish/flow.py +432 -406
  53. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  54. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +623 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +129 -124
  61. package/app/core/router.py +119 -81
  62. package/app/core/runner.py +2050 -2042
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +114 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/skills.py +221 -72
  78. package/app/core/step_runner.py +132 -132
  79. package/app/core/store.py +23 -0
  80. package/app/core/task_compile.py +199 -199
  81. package/app/core/telemetry.py +292 -292
  82. package/app/core/tlsctx.py +64 -64
  83. package/app/core/token_meter.py +147 -147
  84. package/app/core/upstream.py +41 -0
  85. package/app/core/usage.py +114 -3
  86. package/app/core/volumes.py +355 -355
  87. package/app/core/wxdigest.py +858 -858
  88. package/app/core/zentao.py +2447 -2447
  89. package/app/main.py +2838 -2832
  90. package/app/pick_dialog.py +110 -110
  91. package/app/ui/app.js +13994 -13977
  92. package/app/ui/board.css +20 -41
  93. package/app/ui/board.html +162 -162
  94. package/app/ui/board.js +305 -305
  95. package/app/ui/i18n.js +2716 -2711
  96. package/app/ui/icons/bee.svg +79 -79
  97. package/app/ui/index.html +1 -1
  98. package/app/ui/manifest.json +16 -16
  99. package/app/ui/qrcode.js +2297 -2297
  100. package/app/ui/style.css +6107 -6125
  101. package/bin/tutti.js +147 -147
  102. package/package.json +46 -41
  103. package/tools/wx_reader.py +261 -261
  104. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  105. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  106. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  107. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  108. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  109. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  110. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -1,590 +1,623 @@
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 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_preflight(plat, data):
354
+ """建书前置闸:平台表单对必填字段有硬校验(番茄简介 50-500 字),过不了
355
+ 校验时页面不跳转、流程只能误报「未登录或改版」——开浏览器之前先拦下,
356
+ 把人话原因还给用户。返回错误文案,空串=放行。"""
357
+ placeholders = {"待补充", "(待补充)", "(待补充)"}
358
+ title = str(data.get("book_name") or "").strip()
359
+ if not title or title in placeholders:
360
+ return "作品名称为空,请先补书名再点「创建作品」"
361
+ summary = str(data.get("summary") or "").strip()
362
+ if plat == "fanqie":
363
+ if len(summary) < 50 or len(summary) > 500:
364
+ return ("作品简介 %d 字,番茄建书表单要求 50-500 字;"
365
+ "请先补简介再点「创建作品」" % len(summary))
366
+ if not str(data.get("category") or "").strip():
367
+ return "作品分类为空,请先重生成作品信息再建书"
368
+ for key in ("tags_theme", "tags_role", "tags_plot"):
369
+ if not (data.get(key) or []):
370
+ return "作品标签(%s)为空,请先重生成作品信息再建书" % key
371
+ elif plat == "qimao":
372
+ if not summary or summary in placeholders:
373
+ return "作品简介为空,请先补简介再点「创建作品」"
374
+ for key in ("category_main", "category_sub"):
375
+ if not str(data.get(key) or "").strip():
376
+ return "作品分类为空,请先重生成作品信息再建书"
377
+ for key in ("tags_style", "tags_role", "tags_plot", "tags_bg"):
378
+ if not (data.get(key) or []):
379
+ return "作品标签(%s)为空,请先重生成作品信息再建书" % key
380
+ return ""
381
+
382
+
383
+ def create_book_async(task_id, plat, auto_submit=False):
384
+ """按任务 book_meta 的资料在平台建书。返回 (ok, err)。"""
385
+ from .. import store
386
+ if plat not in PLATFORMS:
387
+ return False, "未知平台"
388
+ task = store.get_task(task_id)
389
+ if not task:
390
+ return False, "任务不存在"
391
+ meta = ((task.get("book_meta") or {}).get(plat) or {})
392
+ if meta.get("status") != "done" or not meta.get("data"):
393
+ return False, "请先生成该平台的作品信息"
394
+ if _st(plat).get("status") == "busy":
395
+ return False, "该平台有操作正在进行中"
396
+ if ledger.book_for(task_id, plat):
397
+ return False, "该任务已在此平台登记过作品,请直接发章"
398
+ why = _create_preflight(plat, meta["data"])
399
+ if why:
400
+ return False, why
401
+ ok_login, why = _login_guard(plat)
402
+ if not ok_login:
403
+ return False, why
404
+ _set(plat, status="busy", last_action="create_book", error="")
405
+
406
+ data = meta["data"]
407
+ mod = PLATFORMS[plat]
408
+ logs = []
409
+
410
+ def run():
411
+ book_name = (data.get("book_name") or "").strip()
412
+ try:
413
+ b, page = _open_page(plat)
414
+ values = mod.values_create_book(data)
415
+ steps = _with_tag_steps(load_flow(plat, "create_book"),
416
+ mod.tag_groups(data), values, mod)
417
+ flow.run_flow(page, steps, values=values, config=mod.CONFIG,
418
+ auto_submit=auto_submit,
419
+ shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
420
+ log=logs.append)
421
+ # book_id:创建成功后平台跳书籍详情/编辑器,从 URL 提取
422
+ # (番茄 book-info/<id>;七猫 information?id=<id>)。
423
+ # 提取不到就先用书名登记,发章按书名找书。
424
+ book_id = ""
425
+ try:
426
+ m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
427
+ str(page.url() or ""))
428
+ if m_url:
429
+ book_id = m_url.group(1) or m_url.group(2)
430
+ except Exception:
431
+ pass
432
+ ledger.record(plat, "create_book", task_id=task_id, title=book_name,
433
+ book_id=book_id, ok=True,
434
+ error="\n".join(logs)[:2000],
435
+ shot=str(ledger.shot_path(plat, task_id, "")))
436
+ ledger.save_book(task_id, plat, {"book_id": book_id, "title": book_name})
437
+ _set(plat, status="connected", error="")
438
+ except Exception as e:
439
+ _set(plat, status="error", error="建书失败:%s" % e)
440
+ ledger.record(plat, "create_book", task_id=task_id, title=book_name,
441
+ ok=False, error=str(e)[:300],
442
+ shot=str(ledger.shot_path(plat, task_id, "")))
443
+ finally:
444
+ _save()
445
+
446
+ threading.Thread(target=run, daemon=True,
447
+ name="pub-book-%s" % plat).start()
448
+ return True, ""
449
+
450
+
451
+ # ---------------------------------------------------------------- 动作:登记已有作品
452
+ def register_book(task_id, plat, title, book_id=""):
453
+ """人工登记平台已有作品:建书流程没走通、或用户纯手工在平台上建的
454
+ 书,补进台账让卡片翻到「已建书·发一章」,避免再点「创建作品」造出
455
+ 重复书。只动本地台账不碰浏览器;发章按书名找书,title 必填,
456
+ book_id 选填(有直达 URL 时填)。已登记会覆盖更新(兼纠错口)。"""
457
+ from .. import store
458
+ if plat not in PLATFORMS:
459
+ return False, "未知平台"
460
+ if not store.get_task(task_id):
461
+ return False, "任务不存在"
462
+ title = (title or "").strip()
463
+ if not title:
464
+ return False, "作品名必填(发章按作品名在平台找书)"
465
+ ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
466
+ "title": title[:120]})
467
+ return True, ""
468
+
469
+
470
+ def _with_tag_steps(steps, groups, values, mod=None):
471
+ """标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
472
+ "tags" 步骤按组切换点选。组显示名映射来自**对应平台模块**的
473
+ TAG_GROUP_LABELS(曾硬编码 qimao——番茄任务 key 撞名被套上七猫组名,
474
+ 插桩 text 变 list repr)。兼容旧流程表(无 tags 步骤时插桩);流程表
475
+ 已用 {tag_x} 占位(番茄式 click_real)时不插桩——占位由 values 自答。"""
476
+ flat = []
477
+ labels = getattr(mod, "TAG_GROUP_LABELS", {}) if mod else {}
478
+ for key, tags in groups or []:
479
+ grp = labels.get(key, "")
480
+ flat.extend([grp, str(t)] if grp and str(t).strip() else str(t)
481
+ for t in tags if str(t).strip())
482
+ if not flat:
483
+ return steps
484
+ if any(st.get("do") == "tags" for st in steps):
485
+ values["_tags"] = flat
486
+ return steps
487
+ if any("{tag_" in str(st.get("text") or "") for st in steps if st.get("text")):
488
+ return steps # 流程表自带标签占位(番茄式):values 已就绪
489
+ tag_steps = [{"do": "click_text", "text": str(t), "contains": False,
490
+ "scope": "[class*=tag] li,span,label,[class*=label]"}
491
+ for t in flat]
492
+ out, inserted = list(steps), False
493
+ for i, st in enumerate(out):
494
+ if st.get("do") in ("submit", "shot") and not inserted:
495
+ out[i:i] = tag_steps
496
+ inserted = True
497
+ if not inserted:
498
+ out += tag_steps
499
+ return out
500
+
501
+
502
+ def _login_guard(plat):
503
+ """动作前的登录前置:状态 connected 放行;否则现场复核一次。"""
504
+ s = _st(plat)
505
+ if s.get("status") == "connected":
506
+ return True, ""
507
+ try:
508
+ _b, page = _open_page(plat)
509
+ ok, url = _check_login(plat, page)
510
+ if ok:
511
+ _set(plat, status="connected")
512
+ return True, ""
513
+ return False, "平台未登录(当前页面 %s),请先点「连接平台」扫码" % url[:80]
514
+ except BrowserError as e:
515
+ return False, "浏览器不可用:%s" % e
516
+
517
+
518
+ # ---------------------------------------------------------------- 动作:发章
519
+ def read_chapter(fp):
520
+ """读章节文件 → (章号, 标题, 正文, 错误)。UTF-8→GBK 回退(章稿乱码教训)。
521
+
522
+ 标题取首个「# 」标题行,没有则用文件名;章号从标题/文件名的
523
+ 「第X章」解析,解析不出记 0(台账不按章号幂等,只按标题留痕)。"""
524
+ from pathlib import Path
525
+ from .. import runner
526
+ p = Path(fp)
527
+ if not p.is_file():
528
+ return 0, "", "", "章节文件不存在:%s" % p
529
+ try:
530
+ text = runner.read_text_any_enc(p)
531
+ except Exception as e:
532
+ return 0, "", "", "读章节文件失败:%s" % e
533
+ lines = text.strip().splitlines()
534
+ title = ""
535
+ body_start = 0
536
+ for i, ln in enumerate(lines[:5]):
537
+ s = ln.strip()
538
+ if s.startswith("#"): # 只认 markdown 标题行
539
+ s = s.lstrip("#").strip()
540
+ if s:
541
+ title, body_start = s, i + 1
542
+ break
543
+ if not title:
544
+ title = p.stem
545
+ body = "\n".join(lines[body_start:]).strip()
546
+ ch_no = ledger.parse_chapter_no(title) or ledger.parse_chapter_no(p.name)
547
+ return ch_no, title, body, ""
548
+
549
+
550
+ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
551
+ """把一章发到平台(或填好待人工确认)。幂等:已成功发布的章号拒绝重发。"""
552
+ from .. import store
553
+ if plat not in PLATFORMS:
554
+ return False, "未知平台"
555
+ task = store.get_task(task_id)
556
+ if not task:
557
+ return False, "任务不存在"
558
+ book = ledger.book_for(task_id, plat)
559
+ if not book:
560
+ return False, "该任务尚未在此平台建书,请先「创建作品」"
561
+ if _st(plat).get("status") == "busy":
562
+ return False, "该平台有操作正在进行中"
563
+ ch_no, title, body, err = read_chapter(chapter_file)
564
+ if err:
565
+ return False, err
566
+ n_chars = len(body.replace("\n", "").replace(" ", ""))
567
+ if n_chars < 100:
568
+ return False, "正文过短(%d 字),疑似未完成章节" % n_chars
569
+ # 平台级下限(如七猫编辑器明示「最少 1000 字」:不足时发布被静默拦截)
570
+ min_chars = int(PLATFORMS[plat].CONFIG.get("min_chapter_chars") or 100)
571
+ if n_chars < min_chars:
572
+ return False, ("正文 %d 字未达该平台下限(%d 字),补足后再发"
573
+ % (n_chars, min_chars))
574
+ if n_chars > 30000:
575
+ return False, "正文超长(%d 字),平台单章上限一般 2 万字" % n_chars
576
+ done = ledger.published_chapters(task_id, plat)
577
+ if ch_no and ch_no in done:
578
+ return False, "第 %d 章已成功发布过(台账幂等拦截);确需重发请手工处理" % ch_no
579
+ ok_login, why = _login_guard(plat)
580
+ if not ok_login:
581
+ return False, why
582
+ _set(plat, status="busy", last_action="upload_chapter", error="")
583
+
584
+ mod = PLATFORMS[plat]
585
+ values = {"chapter_title": title, "chapter_body": body,
586
+ "book_name": book.get("title") or ""}
587
+ try: # verify/draft 页 URL(流程占位)
588
+ values["chapter_manage_url"] = mod.chapter_manage_url(book)
589
+ values["draft_url"] = mod.draft_url(book)
590
+ values["editor_url"] = mod.editor_url(book)
591
+ except AttributeError:
592
+ pass
593
+ logs = []
594
+
595
+ def run():
596
+ try:
597
+ b, page = _open_page(plat)
598
+ flow.run_flow(page, load_flow(plat, "upload_chapter"), values=values,
599
+ config=mod.CONFIG, auto_submit=auto_submit,
600
+ shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
601
+ log=logs.append)
602
+ ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
603
+ book_id=book.get("book_id") or "", title=title, ok=True)
604
+ _set(plat, status="connected", error="")
605
+ except Exception as e:
606
+ _set(plat, status="error", error="发章失败:%s" % e)
607
+ ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
608
+ book_id=book.get("book_id") or "", title=title,
609
+ ok=False, error=str(e)[:300])
610
+ finally:
611
+ _save()
612
+
613
+ threading.Thread(target=run, daemon=True,
614
+ name="pub-ch-%s" % plat).start()
615
+ return True, ""
616
+
617
+
618
+ def history(task_id=None, plat=None, limit=50):
619
+ return ledger.recent(task_id=task_id, platform=plat, limit=limit)
620
+
621
+
622
+ _load()
623
+ _migrate_legacy_profiles()