codebee 0.1.65 → 0.1.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +520 -513
- package/LICENSE +21 -21
- package/README.md +494 -487
- package/app/core/aiflavor.py +197 -197
- package/app/core/artifacts.py +148 -148
- package/app/core/attachments.py +532 -532
- package/app/core/automation.py +758 -758
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +796 -759
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1234 -1191
- package/app/core/capability.py +144 -144
- package/app/core/catalog.py +23 -24
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -186
- package/app/core/covergen.py +356 -356
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +173 -173
- package/app/core/dispatch_log.py +113 -113
- package/app/core/env_scrub.py +85 -85
- package/app/core/error_codes.py +220 -65
- package/app/core/errorlog.py +137 -137
- package/app/core/evaluation.py +174 -0
- package/app/core/failure_notify.py +290 -0
- package/app/core/flows.py +355 -355
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +374 -342
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -156
- package/app/core/jobs.py +1034 -1022
- package/app/core/knowledge.py +6 -2
- package/app/core/manager.py +2115 -1996
- package/app/core/market.py +362 -362
- package/app/core/market_remote.py +66 -66
- package/app/core/mocks.py +64 -64
- package/app/core/modelhub.py +3517 -3332
- package/app/core/notify.py +106 -106
- package/app/core/paihang.py +109 -109
- package/app/core/paths.py +2 -2
- package/app/core/pipeline.py +4614 -4494
- package/app/core/planner.py +33 -31
- package/app/core/portguard.py +111 -111
- package/app/core/preview.py +293 -293
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +367 -367
- package/app/core/publish/browser.py +533 -533
- package/app/core/publish/fanqie.py +128 -128
- package/app/core/publish/flow.py +432 -406
- package/app/core/publish/flows-fanqie-calibrated.json +17 -9
- package/app/core/publish/flows-qimao-calibrated.json +6 -2
- package/app/core/publish/ledger.py +198 -198
- package/app/core/publish/manager.py +623 -590
- package/app/core/publish/qimao.py +141 -141
- package/app/core/publish/ws.py +139 -139
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -124
- package/app/core/router.py +119 -81
- package/app/core/runner.py +2050 -2042
- 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 +149 -149
- package/app/core/settings_schema.py +262 -262
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +114 -106
- package/app/core/skillpacks/fanqie-novel.md +80 -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/skills.py +221 -72
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +23 -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/upstream.py +41 -0
- package/app/core/usage.py +114 -3
- package/app/core/volumes.py +355 -355
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2447 -2447
- package/app/main.py +2838 -2832
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +13994 -13977
- package/app/ui/board.css +20 -41
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +2716 -2711
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +1 -1
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +6107 -6125
- package/bin/tutti.js +147 -147
- package/package.json +46 -41
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_note": "番茄真机校准(2026-09-19
|
|
2
|
+
"_note": "番茄真机校准(2026-09-19 定稿,2026-09-24 加固):编辑器=/main/writer/<book_id>/publish/ 直达(章节号 input.serial-input 纯数字;标题 div.serial-editor-input-hint input ≥5字/30上限;正文 .ProseMirror ≥1000字;「下一步」保存校验后按钮组右缘箭头展开下拉含「定时发布」)。建书=/main/writer/create 直达表单:radio 签约模式/目标读者(label 真实点击)→ 阅读标签 select(y<430)弹层 arco-anchor 切主分类/主题/角色/情节 + 标签云点击 + 确认 → 内容标签 select(y>430)弹层 情感/人设/世界观 同构 → 名称/主角1/主角2/简介 → 立即创建(submit 步骤:auto_submit=false 时填好即停,留给人工提交) → 跳 book-info/<id>。锚点切换渲染慢(0924 三连败):弹层打开后 settle,锚点 tries=12。",
|
|
3
3
|
"check_login": [
|
|
4
4
|
{
|
|
5
5
|
"do": "navigate",
|
|
@@ -161,6 +161,10 @@
|
|
|
161
161
|
"y_max": 430,
|
|
162
162
|
"tries": 10
|
|
163
163
|
},
|
|
164
|
+
{
|
|
165
|
+
"do": "sleep",
|
|
166
|
+
"s": 1.2
|
|
167
|
+
},
|
|
164
168
|
{
|
|
165
169
|
"do": "click_real",
|
|
166
170
|
"text": "{category}",
|
|
@@ -171,7 +175,7 @@
|
|
|
171
175
|
"do": "click_real",
|
|
172
176
|
"text": "主题",
|
|
173
177
|
"scope": ".category-modal .arco-anchor-link",
|
|
174
|
-
"tries":
|
|
178
|
+
"tries": 12
|
|
175
179
|
},
|
|
176
180
|
{
|
|
177
181
|
"do": "click_real",
|
|
@@ -185,7 +189,7 @@
|
|
|
185
189
|
"do": "click_real",
|
|
186
190
|
"text": "角色",
|
|
187
191
|
"scope": ".category-modal .arco-anchor-link",
|
|
188
|
-
"tries":
|
|
192
|
+
"tries": 12
|
|
189
193
|
},
|
|
190
194
|
{
|
|
191
195
|
"do": "click_real",
|
|
@@ -199,7 +203,7 @@
|
|
|
199
203
|
"do": "click_real",
|
|
200
204
|
"text": "情节",
|
|
201
205
|
"scope": ".category-modal .arco-anchor-link",
|
|
202
|
-
"tries":
|
|
206
|
+
"tries": 12
|
|
203
207
|
},
|
|
204
208
|
{
|
|
205
209
|
"do": "click_real",
|
|
@@ -226,11 +230,15 @@
|
|
|
226
230
|
"scope": "[class*=select-view],div,span",
|
|
227
231
|
"tries": 15
|
|
228
232
|
},
|
|
233
|
+
{
|
|
234
|
+
"do": "sleep",
|
|
235
|
+
"s": 1.2
|
|
236
|
+
},
|
|
229
237
|
{
|
|
230
238
|
"do": "click_real",
|
|
231
239
|
"text": "情感",
|
|
232
240
|
"scope": ".category-modal .arco-anchor-link",
|
|
233
|
-
"tries":
|
|
241
|
+
"tries": 12
|
|
234
242
|
},
|
|
235
243
|
{
|
|
236
244
|
"do": "click_real",
|
|
@@ -244,7 +252,7 @@
|
|
|
244
252
|
"do": "click_real",
|
|
245
253
|
"text": "人设",
|
|
246
254
|
"scope": ".category-modal .arco-anchor-link",
|
|
247
|
-
"tries":
|
|
255
|
+
"tries": 12
|
|
248
256
|
},
|
|
249
257
|
{
|
|
250
258
|
"do": "click_real",
|
|
@@ -258,7 +266,7 @@
|
|
|
258
266
|
"do": "click_real",
|
|
259
267
|
"text": "世界观",
|
|
260
268
|
"scope": ".category-modal .arco-anchor-link",
|
|
261
|
-
"tries":
|
|
269
|
+
"tries": 12
|
|
262
270
|
},
|
|
263
271
|
{
|
|
264
272
|
"do": "click_real",
|
|
@@ -304,7 +312,7 @@
|
|
|
304
312
|
"name": "create-book-filled"
|
|
305
313
|
},
|
|
306
314
|
{
|
|
307
|
-
"do": "
|
|
315
|
+
"do": "submit",
|
|
308
316
|
"text": "立即创建",
|
|
309
317
|
"scope": "button",
|
|
310
318
|
"tries": 10
|
|
@@ -321,4 +329,4 @@
|
|
|
321
329
|
]
|
|
322
330
|
}
|
|
323
331
|
]
|
|
324
|
-
}
|
|
332
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_note": "七猫真机校准(2026-09-19 凌晨定稿):建书=book-manage「新建小说」→站点弹层(click_match+确认)→information 表单(fill_label/radio/级联分类/tags 四组各1-3个+确定/主角名平铺/简介);书名仅允许,:!?中文标点(·被拦)。发章=编辑器填好后「立即发布」落草稿箱(未签约书限制)→草稿行「立即发布」→「更正序号去发布」→「确认发布」三层弹窗→章节待审核。正文下限 1000 字(min_chapter_chars)。book-upload?id=<书id>
|
|
2
|
+
"_note": "七猫真机校准(2026-09-19 凌晨定稿):建书=book-manage「新建小说」→站点弹层(click_match+确认)→information 表单(fill_label/radio/级联分类/tags 四组各1-3个+确定/主角名平铺/简介);书名仅允许,:!?中文标点(·被拦)。发章=编辑器填好后「立即发布」落草稿箱(未签约书限制)→草稿行「立即发布」→「更正序号去发布」→「确认发布」三层弹窗→章节待审核。正文下限 1000 字(min_chapter_chars)。book-upload?id=<书id> 直达编辑器。;2026-09-24 加固:二级分类选完 settle 1s 再点「添加标签」(下拉关闭动画吞点击致标签弹层未开、组名切换失败案)",
|
|
3
3
|
"probe_form": [
|
|
4
4
|
{
|
|
5
5
|
"do": "navigate",
|
|
@@ -96,6 +96,10 @@
|
|
|
96
96
|
"contains": true,
|
|
97
97
|
"scope": "li,span,[class*=dropdown] *,[class*=popper] *"
|
|
98
98
|
},
|
|
99
|
+
{
|
|
100
|
+
"do": "sleep",
|
|
101
|
+
"s": 1.0
|
|
102
|
+
},
|
|
99
103
|
{
|
|
100
104
|
"do": "click_text",
|
|
101
105
|
"text": "添加标签"
|
|
@@ -230,4 +234,4 @@
|
|
|
230
234
|
]
|
|
231
235
|
}
|
|
232
236
|
]
|
|
233
|
-
}
|
|
237
|
+
}
|
|
@@ -1,198 +1,198 @@
|
|
|
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")
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _month_file(day):
|
|
31
|
-
return paths.PUBLISH_DIR / ("publish-%s.jsonl" % day[:7].replace("-", ""))
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def record(platform, action, task_id="", chapter_no=0, book_id="",
|
|
35
|
-
title="", ok=True, error="", shot=""):
|
|
36
|
-
"""追加一条发布记录。异常全吞——记账失败绝不能影响发布主流程。"""
|
|
37
|
-
try:
|
|
38
|
-
rec = {
|
|
39
|
-
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
40
|
-
"day": time.strftime("%Y-%m-%d"),
|
|
41
|
-
"platform": str(platform)[:16],
|
|
42
|
-
"action": str(action)[:24],
|
|
43
|
-
"task_id": str(task_id)[:64],
|
|
44
|
-
"chapter_no": int(chapter_no or 0),
|
|
45
|
-
"book_id": str(book_id)[:80],
|
|
46
|
-
"title": str(title)[:120],
|
|
47
|
-
"ok": bool(ok),
|
|
48
|
-
"error": str(error or "")[:300],
|
|
49
|
-
"shot": str(shot)[:200],
|
|
50
|
-
}
|
|
51
|
-
with LOCK:
|
|
52
|
-
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
53
|
-
with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
|
|
54
|
-
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
55
|
-
except Exception:
|
|
56
|
-
pass
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _iter_records(days=90):
|
|
60
|
-
"""近 N 天的记录(月份文件粒度粗滤,行内再按 day 过滤)。"""
|
|
61
|
-
import datetime
|
|
62
|
-
cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
|
|
63
|
-
for fp in sorted(paths.PUBLISH_DIR.glob("publish-*.jsonl")):
|
|
64
|
-
try:
|
|
65
|
-
lines = fp.read_text(encoding="utf-8").splitlines()
|
|
66
|
-
except OSError:
|
|
67
|
-
continue
|
|
68
|
-
for ln in lines:
|
|
69
|
-
try:
|
|
70
|
-
r = json.loads(ln)
|
|
71
|
-
except ValueError:
|
|
72
|
-
continue
|
|
73
|
-
if str(r.get("day") or "") >= cutoff:
|
|
74
|
-
yield r
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def published_chapters(task_id, platform):
|
|
78
|
-
"""该任务在该平台已成功发布的章节号集合(幂等跳过依据)。"""
|
|
79
|
-
out = set()
|
|
80
|
-
for r in _iter_records(3650):
|
|
81
|
-
if (r.get("action") == "upload_chapter" and r.get("ok")
|
|
82
|
-
and r.get("task_id") == task_id and r.get("platform") == platform):
|
|
83
|
-
n = r.get("chapter_no") or 0
|
|
84
|
-
if n > 0:
|
|
85
|
-
out.add(int(n))
|
|
86
|
-
return out
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def recent(task_id=None, platform=None, limit=50):
|
|
90
|
-
"""最近记录(新在前),详情页发布历史用。"""
|
|
91
|
-
out = []
|
|
92
|
-
for r in _iter_records(90):
|
|
93
|
-
if task_id and r.get("task_id") != task_id:
|
|
94
|
-
continue
|
|
95
|
-
if platform and r.get("platform") != platform:
|
|
96
|
-
continue
|
|
97
|
-
out.append(r)
|
|
98
|
-
out.reverse()
|
|
99
|
-
return out[:limit]
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# ---------------------------------------------------------------- 作品登记
|
|
103
|
-
_BOOKS_FILE = paths.PUBLISH_DIR / "books.json"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def load_books():
|
|
107
|
-
try:
|
|
108
|
-
d = json.loads(_BOOKS_FILE.read_text(encoding="utf-8"))
|
|
109
|
-
return d if isinstance(d, dict) else {}
|
|
110
|
-
except Exception:
|
|
111
|
-
return {}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def save_book(task_id, platform, info):
|
|
115
|
-
"""登记/更新任务在某平台的作品绑定。info: {book_id, title, url?}。"""
|
|
116
|
-
with LOCK:
|
|
117
|
-
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
118
|
-
books = load_books()
|
|
119
|
-
books.setdefault(str(task_id), {})[str(platform)] = {
|
|
120
|
-
"book_id": str(info.get("book_id") or ""),
|
|
121
|
-
"title": str(info.get("title") or "")[:120],
|
|
122
|
-
"url": str(info.get("url") or "")[:300],
|
|
123
|
-
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
124
|
-
}
|
|
125
|
-
tmp = _BOOKS_FILE.with_suffix(".tmp")
|
|
126
|
-
tmp.write_text(json.dumps(books, ensure_ascii=False, indent=1),
|
|
127
|
-
encoding="utf-8")
|
|
128
|
-
tmp.replace(_BOOKS_FILE)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def book_for(task_id, platform):
|
|
132
|
-
return ((load_books().get(str(task_id)) or {}).get(str(platform))) or None
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def shot_path(platform, task_id, step):
|
|
136
|
-
"""截图存证路径(只算路径不落盘,由 browser.Page.screenshot 写)。"""
|
|
137
|
-
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
138
|
-
return paths.PUBLISH_DIR / "shots" / str(platform) / str(task_id) / \
|
|
139
|
-
("%s-%s.png" % (ts, step))
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
_CHAPTER_RE = re.compile(r"第\s*([0-90-9一二三四五六七八九十百千零两]+)\s*[章节回]")
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def parse_chapter_no(name):
|
|
146
|
-
"""从章节标题/文件名解析章号(第12章/第十二章),失败返回 0。"""
|
|
147
|
-
m = _CHAPTER_RE.search(str(name or ""))
|
|
148
|
-
if not m:
|
|
149
|
-
return 0
|
|
150
|
-
s = m.group(1)
|
|
151
|
-
if s.isdigit():
|
|
152
|
-
return int(s)
|
|
153
|
-
cn = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
|
|
154
|
-
"六": 6, "七": 7, "八": 8, "九": 9}
|
|
155
|
-
# 正序解析(十二=12、二十三=23、一百零五=105):数字暂存 num,
|
|
156
|
-
# 遇单位(十/百/千)把它乘上去并清零;万字内够用(章号没有更大的)
|
|
157
|
-
section, num = 0, 0
|
|
158
|
-
for ch in s:
|
|
159
|
-
if ch in cn:
|
|
160
|
-
num = cn[ch]
|
|
161
|
-
elif ch == "十":
|
|
162
|
-
section += (num or 1) * 10
|
|
163
|
-
num = 0
|
|
164
|
-
elif ch == "百":
|
|
165
|
-
section += (num or 1) * 100
|
|
166
|
-
num = 0
|
|
167
|
-
elif ch == "千":
|
|
168
|
-
section += (num or 1) * 1000
|
|
169
|
-
num = 0
|
|
170
|
-
return section + num
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
# ---------------------------------------------------------------- 护栏统计
|
|
174
|
-
def today_count(task_id, platform):
|
|
175
|
-
"""该任务在该平台今日已成功发布的章数(每日上限护栏的计数口径)。"""
|
|
176
|
-
day = time.strftime("%Y-%m-%d")
|
|
177
|
-
return sum(1 for r in _iter_records(2)
|
|
178
|
-
if r.get("action") == "upload_chapter" and r.get("ok")
|
|
179
|
-
and r.get("task_id") == task_id and r.get("platform") == platform
|
|
180
|
-
and r.get("day") == day)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def consecutive_failures(platform):
|
|
184
|
-
"""该平台最近连续失败的发布动作数(连败退避护栏,跨任务口径)。
|
|
185
|
-
|
|
186
|
-
记录时间序旧→新,倒着数到第一条成功为止;连败大概率是风控或改版,
|
|
187
|
-
此时继续自动重试只会火上浇油,应转人工检查。
|
|
188
|
-
"""
|
|
189
|
-
n = 0
|
|
190
|
-
for r in reversed(list(_iter_records(7))):
|
|
191
|
-
if r.get("platform") != platform:
|
|
192
|
-
continue
|
|
193
|
-
if r.get("action") not in ("upload_chapter", "create_book"):
|
|
194
|
-
continue
|
|
195
|
-
if r.get("ok"):
|
|
196
|
-
break
|
|
197
|
-
n += 1
|
|
198
|
-
return n
|
|
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")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _month_file(day):
|
|
31
|
+
return paths.PUBLISH_DIR / ("publish-%s.jsonl" % day[:7].replace("-", ""))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def record(platform, action, task_id="", chapter_no=0, book_id="",
|
|
35
|
+
title="", ok=True, error="", shot=""):
|
|
36
|
+
"""追加一条发布记录。异常全吞——记账失败绝不能影响发布主流程。"""
|
|
37
|
+
try:
|
|
38
|
+
rec = {
|
|
39
|
+
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
40
|
+
"day": time.strftime("%Y-%m-%d"),
|
|
41
|
+
"platform": str(platform)[:16],
|
|
42
|
+
"action": str(action)[:24],
|
|
43
|
+
"task_id": str(task_id)[:64],
|
|
44
|
+
"chapter_no": int(chapter_no or 0),
|
|
45
|
+
"book_id": str(book_id)[:80],
|
|
46
|
+
"title": str(title)[:120],
|
|
47
|
+
"ok": bool(ok),
|
|
48
|
+
"error": str(error or "")[:300],
|
|
49
|
+
"shot": str(shot)[:200],
|
|
50
|
+
}
|
|
51
|
+
with LOCK:
|
|
52
|
+
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
|
|
54
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _iter_records(days=90):
|
|
60
|
+
"""近 N 天的记录(月份文件粒度粗滤,行内再按 day 过滤)。"""
|
|
61
|
+
import datetime
|
|
62
|
+
cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
|
|
63
|
+
for fp in sorted(paths.PUBLISH_DIR.glob("publish-*.jsonl")):
|
|
64
|
+
try:
|
|
65
|
+
lines = fp.read_text(encoding="utf-8").splitlines()
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
for ln in lines:
|
|
69
|
+
try:
|
|
70
|
+
r = json.loads(ln)
|
|
71
|
+
except ValueError:
|
|
72
|
+
continue
|
|
73
|
+
if str(r.get("day") or "") >= cutoff:
|
|
74
|
+
yield r
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def published_chapters(task_id, platform):
|
|
78
|
+
"""该任务在该平台已成功发布的章节号集合(幂等跳过依据)。"""
|
|
79
|
+
out = set()
|
|
80
|
+
for r in _iter_records(3650):
|
|
81
|
+
if (r.get("action") == "upload_chapter" and r.get("ok")
|
|
82
|
+
and r.get("task_id") == task_id and r.get("platform") == platform):
|
|
83
|
+
n = r.get("chapter_no") or 0
|
|
84
|
+
if n > 0:
|
|
85
|
+
out.add(int(n))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def recent(task_id=None, platform=None, limit=50):
|
|
90
|
+
"""最近记录(新在前),详情页发布历史用。"""
|
|
91
|
+
out = []
|
|
92
|
+
for r in _iter_records(90):
|
|
93
|
+
if task_id and r.get("task_id") != task_id:
|
|
94
|
+
continue
|
|
95
|
+
if platform and r.get("platform") != platform:
|
|
96
|
+
continue
|
|
97
|
+
out.append(r)
|
|
98
|
+
out.reverse()
|
|
99
|
+
return out[:limit]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------- 作品登记
|
|
103
|
+
_BOOKS_FILE = paths.PUBLISH_DIR / "books.json"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_books():
|
|
107
|
+
try:
|
|
108
|
+
d = json.loads(_BOOKS_FILE.read_text(encoding="utf-8"))
|
|
109
|
+
return d if isinstance(d, dict) else {}
|
|
110
|
+
except Exception:
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_book(task_id, platform, info):
|
|
115
|
+
"""登记/更新任务在某平台的作品绑定。info: {book_id, title, url?}。"""
|
|
116
|
+
with LOCK:
|
|
117
|
+
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
books = load_books()
|
|
119
|
+
books.setdefault(str(task_id), {})[str(platform)] = {
|
|
120
|
+
"book_id": str(info.get("book_id") or ""),
|
|
121
|
+
"title": str(info.get("title") or "")[:120],
|
|
122
|
+
"url": str(info.get("url") or "")[:300],
|
|
123
|
+
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
124
|
+
}
|
|
125
|
+
tmp = _BOOKS_FILE.with_suffix(".tmp")
|
|
126
|
+
tmp.write_text(json.dumps(books, ensure_ascii=False, indent=1),
|
|
127
|
+
encoding="utf-8")
|
|
128
|
+
tmp.replace(_BOOKS_FILE)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def book_for(task_id, platform):
|
|
132
|
+
return ((load_books().get(str(task_id)) or {}).get(str(platform))) or None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def shot_path(platform, task_id, step):
|
|
136
|
+
"""截图存证路径(只算路径不落盘,由 browser.Page.screenshot 写)。"""
|
|
137
|
+
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
138
|
+
return paths.PUBLISH_DIR / "shots" / str(platform) / str(task_id) / \
|
|
139
|
+
("%s-%s.png" % (ts, step))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_CHAPTER_RE = re.compile(r"第\s*([0-90-9一二三四五六七八九十百千零两]+)\s*[章节回]")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_chapter_no(name):
|
|
146
|
+
"""从章节标题/文件名解析章号(第12章/第十二章),失败返回 0。"""
|
|
147
|
+
m = _CHAPTER_RE.search(str(name or ""))
|
|
148
|
+
if not m:
|
|
149
|
+
return 0
|
|
150
|
+
s = m.group(1)
|
|
151
|
+
if s.isdigit():
|
|
152
|
+
return int(s)
|
|
153
|
+
cn = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
|
|
154
|
+
"六": 6, "七": 7, "八": 8, "九": 9}
|
|
155
|
+
# 正序解析(十二=12、二十三=23、一百零五=105):数字暂存 num,
|
|
156
|
+
# 遇单位(十/百/千)把它乘上去并清零;万字内够用(章号没有更大的)
|
|
157
|
+
section, num = 0, 0
|
|
158
|
+
for ch in s:
|
|
159
|
+
if ch in cn:
|
|
160
|
+
num = cn[ch]
|
|
161
|
+
elif ch == "十":
|
|
162
|
+
section += (num or 1) * 10
|
|
163
|
+
num = 0
|
|
164
|
+
elif ch == "百":
|
|
165
|
+
section += (num or 1) * 100
|
|
166
|
+
num = 0
|
|
167
|
+
elif ch == "千":
|
|
168
|
+
section += (num or 1) * 1000
|
|
169
|
+
num = 0
|
|
170
|
+
return section + num
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------- 护栏统计
|
|
174
|
+
def today_count(task_id, platform):
|
|
175
|
+
"""该任务在该平台今日已成功发布的章数(每日上限护栏的计数口径)。"""
|
|
176
|
+
day = time.strftime("%Y-%m-%d")
|
|
177
|
+
return sum(1 for r in _iter_records(2)
|
|
178
|
+
if r.get("action") == "upload_chapter" and r.get("ok")
|
|
179
|
+
and r.get("task_id") == task_id and r.get("platform") == platform
|
|
180
|
+
and r.get("day") == day)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def consecutive_failures(platform):
|
|
184
|
+
"""该平台最近连续失败的发布动作数(连败退避护栏,跨任务口径)。
|
|
185
|
+
|
|
186
|
+
记录时间序旧→新,倒着数到第一条成功为止;连败大概率是风控或改版,
|
|
187
|
+
此时继续自动重试只会火上浇油,应转人工检查。
|
|
188
|
+
"""
|
|
189
|
+
n = 0
|
|
190
|
+
for r in reversed(list(_iter_records(7))):
|
|
191
|
+
if r.get("platform") != platform:
|
|
192
|
+
continue
|
|
193
|
+
if r.get("action") not in ("upload_chapter", "create_book"):
|
|
194
|
+
continue
|
|
195
|
+
if r.get("ok"):
|
|
196
|
+
break
|
|
197
|
+
n += 1
|
|
198
|
+
return n
|