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