codebee 0.1.71 → 0.1.73
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 +601 -589
- package/LICENSE +21 -21
- package/README.md +508 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/aiflavor.py +197 -197
- 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 +95 -95
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +935 -935
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1680 -1673
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- 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 +190 -190
- 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/evalbench.py +836 -720
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flows.py +8 -8
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +374 -374
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +25 -1
- package/app/core/manager.py +2208 -2143
- package/app/core/market.py +90 -4
- package/app/core/market_remote.py +16 -0
- package/app/core/mocks.py +64 -64
- package/app/core/modelhub.py +3 -2
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +189 -189
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -138
- package/app/core/pipeline.py +102 -30
- package/app/core/planner.py +2 -0
- package/app/core/plugins.py +34 -0
- package/app/core/portguard.py +111 -111
- package/app/core/preview.py +293 -293
- package/app/core/prompt_guard.py +78 -0
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +396 -367
- package/app/core/publish/browser.py +655 -655
- package/app/core/publish/fanqie.py +165 -165
- package/app/core/publish/flow.py +640 -589
- package/app/core/publish/flows-fanqie-calibrated.json +336 -336
- package/app/core/publish/ledger.py +307 -229
- package/app/core/publish/manager.py +1118 -824
- package/app/core/publish/qimao.py +176 -146
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +194 -0
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2142 -2142
- 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_schema.py +270 -270
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -122
- package/app/core/skillpacks/fanqie-novel.md +98 -80
- 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 +70 -59
- package/app/core/skills.py +58 -8
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +51 -0
- 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 +144 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1035 -1035
- package/app/core/volumes.py +389 -389
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2692 -2614
- package/app/main.py +110 -39
- package/app/mcp_server.py +175 -175
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +509 -114
- package/app/ui/artdiff.js +74 -74
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +41 -2
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +8 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +74 -26
- package/bin/tutti.js +147 -147
- package/package.json +48 -47
- 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//344/275/234/345/223/201/344/277/241/346/201/257-/344/270/203/347/214/253.md +0 -18
|
@@ -1,229 +1,307 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""发布台账:每次发布动作(连接/建书/传章节)追加一条 JSONL,append-only。
|
|
3
|
-
|
|
4
|
-
落盘 data/publish/publish-YYYYMM.jsonl(按月分文件,与用量台账同范式)。
|
|
5
|
-
幂等键 (task_id, platform, chapter_no):章节发布成功后重发即跳过——
|
|
6
|
-
断点续发(发布到一半挂了,重启后接着发没发完的)靠这一条。
|
|
7
|
-
|
|
8
|
-
作品登记 books.json:task ↔ 平台作品的绑定(建书成功后写入 book_id/标题),
|
|
9
|
-
原子写(tmp + os.replace)。与台账分工:台账只追加不改(审计/历史),
|
|
10
|
-
可变状态(当前绑定的作品)进 books.json。
|
|
11
|
-
|
|
12
|
-
截图存证:data/publish/shots/<platform>/<task_id>/<时间戳>-<步骤名>.png,
|
|
13
|
-
每步动作后落一张,出错可回看卡在哪一步;旧截图按任务清理不自动做(量小)。
|
|
14
|
-
"""
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import json
|
|
18
|
-
import re
|
|
19
|
-
import threading
|
|
20
|
-
import time
|
|
21
|
-
|
|
22
|
-
from .. import paths
|
|
23
|
-
|
|
24
|
-
LOCK = threading.RLock()
|
|
25
|
-
|
|
26
|
-
FIELDS = ("ts", "day", "platform", "action", "task_id", "chapter_no",
|
|
27
|
-
"book_id", "title", "ok", "error", "shot", "operation_id",
|
|
28
|
-
"operation_status", "remote_receipt")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _month_file(day):
|
|
32
|
-
return paths.PUBLISH_DIR / ("publish-%s.jsonl" % day[:7].replace("-", ""))
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def record(platform, action, task_id="", chapter_no=0, book_id="",
|
|
36
|
-
title="", ok=True, error="", shot="", operation_id="",
|
|
37
|
-
operation_status="", remote_receipt=""):
|
|
38
|
-
"""追加一条发布记录。异常全吞——记账失败绝不能影响发布主流程。"""
|
|
39
|
-
try:
|
|
40
|
-
rec = {
|
|
41
|
-
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
42
|
-
"day": time.strftime("%Y-%m-%d"),
|
|
43
|
-
"platform": str(platform)[:16],
|
|
44
|
-
"action": str(action)[:24],
|
|
45
|
-
"task_id": str(task_id)[:64],
|
|
46
|
-
"chapter_no": int(chapter_no or 0),
|
|
47
|
-
"book_id": str(book_id)[:80],
|
|
48
|
-
"title": str(title)[:120],
|
|
49
|
-
"ok": bool(ok),
|
|
50
|
-
"error": str(error or "")[:300],
|
|
51
|
-
"shot": str(shot)[:200],
|
|
52
|
-
"operation_id": str(operation_id or "")[:80],
|
|
53
|
-
"operation_status": str(operation_status or "")[:20],
|
|
54
|
-
"remote_receipt": str(remote_receipt or "")[:300],
|
|
55
|
-
}
|
|
56
|
-
with LOCK:
|
|
57
|
-
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
58
|
-
with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
|
|
59
|
-
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
60
|
-
except Exception:
|
|
61
|
-
pass
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _iter_records(days=90):
|
|
65
|
-
"""近 N 天的记录(月份文件粒度粗滤,行内再按 day 过滤)。"""
|
|
66
|
-
import datetime
|
|
67
|
-
cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
|
|
68
|
-
for fp in sorted(paths.PUBLISH_DIR.glob("publish-*.jsonl")):
|
|
69
|
-
try:
|
|
70
|
-
lines = fp.read_text(encoding="utf-8").splitlines()
|
|
71
|
-
except OSError:
|
|
72
|
-
continue
|
|
73
|
-
for ln in lines:
|
|
74
|
-
try:
|
|
75
|
-
r = json.loads(ln)
|
|
76
|
-
except ValueError:
|
|
77
|
-
continue
|
|
78
|
-
if str(r.get("day") or "") >= cutoff:
|
|
79
|
-
yield r
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def published_chapters(task_id, platform):
|
|
83
|
-
"""该任务在该平台已成功发布的章节号集合(幂等跳过依据)。
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""发布台账:每次发布动作(连接/建书/传章节)追加一条 JSONL,append-only。
|
|
3
|
+
|
|
4
|
+
落盘 data/publish/publish-YYYYMM.jsonl(按月分文件,与用量台账同范式)。
|
|
5
|
+
幂等键 (task_id, platform, chapter_no):章节发布成功后重发即跳过——
|
|
6
|
+
断点续发(发布到一半挂了,重启后接着发没发完的)靠这一条。
|
|
7
|
+
|
|
8
|
+
作品登记 books.json:task ↔ 平台作品的绑定(建书成功后写入 book_id/标题),
|
|
9
|
+
原子写(tmp + os.replace)。与台账分工:台账只追加不改(审计/历史),
|
|
10
|
+
可变状态(当前绑定的作品)进 books.json。
|
|
11
|
+
|
|
12
|
+
截图存证:data/publish/shots/<platform>/<task_id>/<时间戳>-<步骤名>.png,
|
|
13
|
+
每步动作后落一张,出错可回看卡在哪一步;旧截图按任务清理不自动做(量小)。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from .. import paths
|
|
23
|
+
|
|
24
|
+
LOCK = threading.RLock()
|
|
25
|
+
|
|
26
|
+
FIELDS = ("ts", "day", "platform", "action", "task_id", "chapter_no",
|
|
27
|
+
"book_id", "title", "ok", "error", "shot", "operation_id",
|
|
28
|
+
"operation_status", "remote_receipt")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _month_file(day):
|
|
32
|
+
return paths.PUBLISH_DIR / ("publish-%s.jsonl" % day[:7].replace("-", ""))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def record(platform, action, task_id="", chapter_no=0, book_id="",
|
|
36
|
+
title="", ok=True, error="", shot="", operation_id="",
|
|
37
|
+
operation_status="", remote_receipt=""):
|
|
38
|
+
"""追加一条发布记录。异常全吞——记账失败绝不能影响发布主流程。"""
|
|
39
|
+
try:
|
|
40
|
+
rec = {
|
|
41
|
+
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
42
|
+
"day": time.strftime("%Y-%m-%d"),
|
|
43
|
+
"platform": str(platform)[:16],
|
|
44
|
+
"action": str(action)[:24],
|
|
45
|
+
"task_id": str(task_id)[:64],
|
|
46
|
+
"chapter_no": int(chapter_no or 0),
|
|
47
|
+
"book_id": str(book_id)[:80],
|
|
48
|
+
"title": str(title)[:120],
|
|
49
|
+
"ok": bool(ok),
|
|
50
|
+
"error": str(error or "")[:300],
|
|
51
|
+
"shot": str(shot)[:200],
|
|
52
|
+
"operation_id": str(operation_id or "")[:80],
|
|
53
|
+
"operation_status": str(operation_status or "")[:20],
|
|
54
|
+
"remote_receipt": str(remote_receipt or "")[:300],
|
|
55
|
+
}
|
|
56
|
+
with LOCK:
|
|
57
|
+
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
|
|
59
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _iter_records(days=90):
|
|
65
|
+
"""近 N 天的记录(月份文件粒度粗滤,行内再按 day 过滤)。"""
|
|
66
|
+
import datetime
|
|
67
|
+
cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
|
|
68
|
+
for fp in sorted(paths.PUBLISH_DIR.glob("publish-*.jsonl")):
|
|
69
|
+
try:
|
|
70
|
+
lines = fp.read_text(encoding="utf-8").splitlines()
|
|
71
|
+
except OSError:
|
|
72
|
+
continue
|
|
73
|
+
for ln in lines:
|
|
74
|
+
try:
|
|
75
|
+
r = json.loads(ln)
|
|
76
|
+
except ValueError:
|
|
77
|
+
continue
|
|
78
|
+
if str(r.get("day") or "") >= cutoff:
|
|
79
|
+
yield r
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def published_chapters(task_id, platform):
|
|
83
|
+
"""该任务在该平台已成功发布的章节号集合(幂等跳过依据)。
|
|
84
|
+
|
|
85
|
+
连载链合并口径:同一本书一条链,任一批次发过的章号整链可见——续写任务
|
|
86
|
+
重发首批已发章节会被这里拦下。"""
|
|
87
|
+
from .. import store
|
|
88
|
+
try:
|
|
89
|
+
ids = set(store.serial_chain_ids(task_id))
|
|
90
|
+
except Exception:
|
|
91
|
+
ids = {str(task_id)}
|
|
92
|
+
out = set()
|
|
93
|
+
for r in _iter_records(3650):
|
|
94
|
+
if (r.get("action") == "upload_chapter" and r.get("ok")
|
|
95
|
+
and r.get("task_id") in ids and r.get("platform") == platform):
|
|
96
|
+
n = r.get("chapter_no") or 0
|
|
97
|
+
if n > 0:
|
|
98
|
+
out.add(int(n))
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def recent(task_id=None, platform=None, limit=50):
|
|
103
|
+
"""最近记录(新在前),详情页发布历史用。task_id 给定时按连载链合并。"""
|
|
104
|
+
from .. import store
|
|
105
|
+
ids = None
|
|
106
|
+
if task_id:
|
|
107
|
+
try:
|
|
108
|
+
ids = set(store.serial_chain_ids(task_id))
|
|
109
|
+
except Exception:
|
|
110
|
+
ids = {str(task_id)}
|
|
111
|
+
out = []
|
|
112
|
+
for r in _iter_records(90):
|
|
113
|
+
if ids is not None and r.get("task_id") not in ids:
|
|
114
|
+
continue
|
|
115
|
+
if platform and r.get("platform") != platform:
|
|
116
|
+
continue
|
|
117
|
+
out.append(r)
|
|
118
|
+
out.reverse()
|
|
119
|
+
return out[:limit]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------- 作品登记
|
|
123
|
+
_BOOKS_FILE = paths.PUBLISH_DIR / "books.json"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_books():
|
|
127
|
+
try:
|
|
128
|
+
d = json.loads(_BOOKS_FILE.read_text(encoding="utf-8"))
|
|
129
|
+
return d if isinstance(d, dict) else {}
|
|
130
|
+
except Exception:
|
|
131
|
+
return {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _chain_order(task_id):
|
|
135
|
+
"""连载链条 id 序列(根在前、自己紧随其后):书籍绑定沿链共享的遍历序。
|
|
136
|
+
|
|
137
|
+
自己优先于祖先:续写任务上补找回的 book_id(写在自身条目)不该被根上
|
|
138
|
+
缺 id 的旧条目盖住。链取不到时回退 [task_id]。"""
|
|
139
|
+
from .. import store
|
|
140
|
+
try:
|
|
141
|
+
chain = [str(t) for t in store.serial_chain_ids(task_id)]
|
|
142
|
+
except Exception:
|
|
143
|
+
chain = []
|
|
144
|
+
chain = chain or [str(task_id)]
|
|
145
|
+
tid = str(task_id)
|
|
146
|
+
return [tid] + [t for t in chain if t != tid]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def save_book(task_id, platform, info):
|
|
150
|
+
"""登记/更新任务在某平台的作品绑定。info: {book_id, title, url?}。
|
|
151
|
+
|
|
152
|
+
绑定属于「这本书」而不是某一批章节:写入链条上已有条目的任务(通常根
|
|
153
|
+
任务),链条全空则落到根上——续写批次建书/补账不会在链条上分叉出第二份。
|
|
154
|
+
|
|
155
|
+
合并语义:只覆写登记四键,保留条目上的其他字段(如校准所得 remote_*),
|
|
156
|
+
否则发章链里找回 book_id 的一次 save_book 会把校准结果抹掉。"""
|
|
157
|
+
with LOCK:
|
|
158
|
+
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
books = load_books()
|
|
160
|
+
target = next((t for t in _chain_order(task_id)
|
|
161
|
+
if (books.get(t) or {}).get(str(platform))), None)
|
|
162
|
+
if target is None:
|
|
163
|
+
from .. import store # 链条全空:绑定落到根任务上
|
|
164
|
+
try:
|
|
165
|
+
target = str(store.serial_chain_ids(task_id)[0])
|
|
166
|
+
except Exception:
|
|
167
|
+
target = str(task_id)
|
|
168
|
+
entry = books.setdefault(target, {}).setdefault(str(platform), {})
|
|
169
|
+
entry.update({
|
|
170
|
+
"book_id": str(info.get("book_id") or ""),
|
|
171
|
+
"title": str(info.get("title") or "")[:120],
|
|
172
|
+
"url": str(info.get("url") or "")[:300],
|
|
173
|
+
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
174
|
+
})
|
|
175
|
+
# Distinguish a title-only manual binding from an unverified create-book result.
|
|
176
|
+
if "source" in info:
|
|
177
|
+
entry["source"] = str(info.get("source") or "")[:20]
|
|
178
|
+
tmp = _BOOKS_FILE.with_suffix(".tmp")
|
|
179
|
+
tmp.write_text(json.dumps(books, ensure_ascii=False, indent=1),
|
|
180
|
+
encoding="utf-8")
|
|
181
|
+
tmp.replace(_BOOKS_FILE)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def update_book(task_id, platform, **fields):
|
|
185
|
+
"""原地合并更新登记条目(如平台校准数 remote_*),不碰 book_id/title 等既有键。
|
|
186
|
+
|
|
187
|
+
save_book 是整条覆写语义(建书/找回 id 用),校准字段走这里才不会被
|
|
188
|
+
下一次 save_book 冲掉。连载链沿链找已有条目更新(校准从哪个批次发起
|
|
189
|
+
都落在这本书的账上);链条上没登记过不凭空造条目。"""
|
|
190
|
+
if not fields:
|
|
191
|
+
return
|
|
192
|
+
with LOCK:
|
|
193
|
+
books = load_books()
|
|
194
|
+
target = next((t for t in _chain_order(task_id)
|
|
195
|
+
if (books.get(t) or {}).get(str(platform))), None)
|
|
196
|
+
if target is None:
|
|
197
|
+
return # 没登记过的书不凭空造条目
|
|
198
|
+
ent = books[target][str(platform)]
|
|
199
|
+
for k, v in fields.items():
|
|
200
|
+
if v is None:
|
|
201
|
+
ent.pop(k, None) # None=删键(换绑书时作废旧校准数)
|
|
202
|
+
else:
|
|
203
|
+
ent[k] = v
|
|
204
|
+
tmp = _BOOKS_FILE.with_suffix(".tmp")
|
|
205
|
+
tmp.write_text(json.dumps(books, ensure_ascii=False, indent=1),
|
|
206
|
+
encoding="utf-8")
|
|
207
|
+
tmp.replace(_BOOKS_FILE)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def book_for(task_id, platform):
|
|
211
|
+
"""任务在某平台的作品绑定;连载链沿链继承(自己条目优先,其次祖先根)。"""
|
|
212
|
+
books = load_books()
|
|
213
|
+
for tid in _chain_order(task_id):
|
|
214
|
+
ent = (books.get(tid) or {}).get(str(platform))
|
|
215
|
+
if ent:
|
|
216
|
+
return ent
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def books_for(task_id):
|
|
221
|
+
"""任务在两平台的已登记绑定(沿连载链继承,都是用第一个)。
|
|
222
|
+
|
|
223
|
+
history / sync-published / pending 等前端视图的统一口径——续写批次
|
|
224
|
+
看到的就是这本书的登记,别处直查 load_books().get(tid) 会跟它们打架。"""
|
|
225
|
+
out = {}
|
|
226
|
+
for p in ("fanqie", "qimao"):
|
|
227
|
+
ent = book_for(task_id, p)
|
|
228
|
+
if ent:
|
|
229
|
+
out[p] = ent
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def shot_path(platform, task_id, step):
|
|
234
|
+
"""截图存证路径(只算路径不落盘,由 browser.Page.screenshot 写)。"""
|
|
235
|
+
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
236
|
+
return paths.PUBLISH_DIR / "shots" / str(platform) / str(task_id) / \
|
|
237
|
+
("%s-%s.png" % (ts, step))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
_CHAPTER_RE = re.compile(r"第\s*([0-90-9一二三四五六七八九十百千零两]+)\s*[章节回]")
|
|
241
|
+
_CHAPTER_FILE_RE = re.compile(
|
|
242
|
+
r"(?:^|[/\\_-])(?:chapter|chap|ch)[-_ ]*([0-90-9]+)(?:\D|$)",
|
|
243
|
+
re.IGNORECASE,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def parse_chapter_no(name):
|
|
248
|
+
"""从章节标题/文件名解析章号(第12章/第十二章/chapter-12),失败返回 0。"""
|
|
249
|
+
raw = str(name or "")
|
|
250
|
+
m = _CHAPTER_RE.search(raw)
|
|
251
|
+
if not m:
|
|
252
|
+
m = _CHAPTER_FILE_RE.search(raw)
|
|
253
|
+
if not m:
|
|
254
|
+
return 0
|
|
255
|
+
try:
|
|
256
|
+
return int(m.group(1).translate(str.maketrans("0123456789", "0123456789")))
|
|
257
|
+
except (TypeError, ValueError):
|
|
258
|
+
return 0
|
|
259
|
+
s = m.group(1)
|
|
260
|
+
if s.isdigit():
|
|
261
|
+
return int(s)
|
|
262
|
+
cn = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
|
|
263
|
+
"六": 6, "七": 7, "八": 8, "九": 9}
|
|
264
|
+
# 正序解析(十二=12、二十三=23、一百零五=105):数字暂存 num,
|
|
265
|
+
# 遇单位(十/百/千)把它乘上去并清零;万字内够用(章号没有更大的)
|
|
266
|
+
section, num = 0, 0
|
|
267
|
+
for ch in s:
|
|
268
|
+
if ch in cn:
|
|
269
|
+
num = cn[ch]
|
|
270
|
+
elif ch == "十":
|
|
271
|
+
section += (num or 1) * 10
|
|
272
|
+
num = 0
|
|
273
|
+
elif ch == "百":
|
|
274
|
+
section += (num or 1) * 100
|
|
275
|
+
num = 0
|
|
276
|
+
elif ch == "千":
|
|
277
|
+
section += (num or 1) * 1000
|
|
278
|
+
num = 0
|
|
279
|
+
return section + num
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------- 护栏统计
|
|
283
|
+
def today_count(task_id, platform):
|
|
284
|
+
"""该任务在该平台今日已成功发布的章数(每日上限护栏的计数口径)。"""
|
|
285
|
+
day = time.strftime("%Y-%m-%d")
|
|
286
|
+
return sum(1 for r in _iter_records(2)
|
|
287
|
+
if r.get("action") == "upload_chapter" and r.get("ok")
|
|
288
|
+
and r.get("task_id") == task_id and r.get("platform") == platform
|
|
289
|
+
and r.get("day") == day)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def consecutive_failures(platform):
|
|
293
|
+
"""该平台最近连续失败的发布动作数(连败退避护栏,跨任务口径)。
|
|
294
|
+
|
|
295
|
+
记录时间序旧→新,倒着数到第一条成功为止;连败大概率是风控或改版,
|
|
296
|
+
此时继续自动重试只会火上浇油,应转人工检查。
|
|
297
|
+
"""
|
|
298
|
+
n = 0
|
|
299
|
+
for r in reversed(list(_iter_records(7))):
|
|
300
|
+
if r.get("platform") != platform:
|
|
301
|
+
continue
|
|
302
|
+
if r.get("action") not in ("upload_chapter", "create_book"):
|
|
303
|
+
continue
|
|
304
|
+
if r.get("ok"):
|
|
305
|
+
break
|
|
306
|
+
n += 1
|
|
307
|
+
return n
|