codebee 0.1.10 → 0.1.12
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 +21 -0
- package/README.md +8 -6
- package/app/core/aiflavor.py +51 -0
- package/app/core/automation.py +9 -0
- package/app/core/gitmod.py +4 -0
- package/app/core/jobs.py +115 -13
- package/app/core/manager.py +49 -0
- package/app/core/modelhub.py +10 -1
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +316 -2
- package/app/core/publish/__init__.py +7 -0
- package/app/core/publish/auto.py +367 -0
- package/app/core/publish/browser.py +410 -0
- package/app/core/publish/fanqie.py +98 -0
- package/app/core/publish/flow.py +281 -0
- package/app/core/publish/ledger.py +198 -0
- package/app/core/publish/manager.py +427 -0
- package/app/core/publish/qimao.py +97 -0
- package/app/core/publish/ws.py +139 -0
- package/app/core/settings.py +18 -3
- package/app/core/store.py +24 -2
- package/app/main.py +174 -0
- package/app/ui/app.js +325 -26
- package/app/ui/i18n.js +5 -1
- package/app/ui/index.html +5 -3
- package/app/ui/style.css +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""自动发布:枚举任务待发章节,按护栏顺序发布(P2 护栏层)。
|
|
3
|
+
|
|
4
|
+
与 manager 的分工:manager 管「一次动作」(连接/建书/发一章),
|
|
5
|
+
auto 管「一批章节」——枚举任务成品里的章节文件,减去台账已发章号,
|
|
6
|
+
逐章调 manager 发,每章之间隔一段防风控节奏。
|
|
7
|
+
|
|
8
|
+
护栏(每章发起前复查,发布中途护栏状态变了也拦得住):
|
|
9
|
+
- 每日上限:ledger.today_count >= settings.publish_daily_cap(默认 10)
|
|
10
|
+
- 连败退避:ledger.consecutive_failures >= settings.publish_fail_streak(默认 3)
|
|
11
|
+
——连续失败说明疑似风控/改版,自动发布暂停转人工;
|
|
12
|
+
- 幂等:已成功发布的章号跳过(manager 内还有第二道闸);
|
|
13
|
+
- 单飞:同任务同时只有一个自动发布线程。
|
|
14
|
+
|
|
15
|
+
发布确认闸沿 manager 口径:auto_submit=False(默认)时每章只填好表单,
|
|
16
|
+
提交权留给用户在浏览器窗口里人工点——自动发布≠自动直发。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
PACE_S = 45 # 章间间隔(防风控节奏),测试里可置 0
|
|
24
|
+
IDLE_POLL_S = 2 # 等 manager busy 结束的轮询步长
|
|
25
|
+
IDLE_TIMEOUT_S = 420 # 单章最长等待(含浏览器操作与人工确认窗口)
|
|
26
|
+
|
|
27
|
+
_CAP_DEFAULT = 10
|
|
28
|
+
_STREAK_DEFAULT = 3
|
|
29
|
+
|
|
30
|
+
_running = {} # task_id → {platform, at, done, total, status, error}
|
|
31
|
+
_LOCK = threading.Lock()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------- 护栏配置
|
|
35
|
+
def _settings():
|
|
36
|
+
from .. import settings
|
|
37
|
+
try:
|
|
38
|
+
return settings.load() or {}
|
|
39
|
+
except Exception:
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def daily_cap():
|
|
44
|
+
try:
|
|
45
|
+
v = int(_settings().get("publish_daily_cap") or _CAP_DEFAULT)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
v = _CAP_DEFAULT
|
|
48
|
+
return max(1, min(50, v))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fail_streak():
|
|
52
|
+
try:
|
|
53
|
+
v = int(_settings().get("publish_fail_streak") or _STREAK_DEFAULT)
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
v = _STREAK_DEFAULT
|
|
56
|
+
return max(1, min(10, v))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def guards(task_id, platform):
|
|
60
|
+
"""两道护栏:每日上限 + 连败退避。返回 (ok, 人话原因)。"""
|
|
61
|
+
from . import ledger
|
|
62
|
+
used = ledger.today_count(task_id, platform)
|
|
63
|
+
cap = daily_cap()
|
|
64
|
+
if used >= cap:
|
|
65
|
+
return False, ("今日已发 %d 章(上限 %d),为防风控明天再发;"
|
|
66
|
+
"确需多发请在设置调 publish_daily_cap" % (used, cap))
|
|
67
|
+
streak = ledger.consecutive_failures(platform)
|
|
68
|
+
limit = fail_streak()
|
|
69
|
+
if streak >= limit:
|
|
70
|
+
return False, ("平台连续 %d 次发布失败(疑似风控或改版),自动发布已暂停,"
|
|
71
|
+
"请人工检查后再试" % streak)
|
|
72
|
+
return True, ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------- 待发枚举
|
|
76
|
+
def pending(task_id, platform):
|
|
77
|
+
"""待发章节清单:任务成品文件中的章节文件 − 台账已发章号,按章号升序。
|
|
78
|
+
|
|
79
|
+
返回 (list, err);list 项 {chapter_no, file, size},file 为工作目录相对
|
|
80
|
+
路径。章号解析不出的文件不进自动发布(防同章多文件误发),API 单章
|
|
81
|
+
发(manager 直调)不受此限。
|
|
82
|
+
"""
|
|
83
|
+
from .. import store
|
|
84
|
+
from . import ledger
|
|
85
|
+
task = store.get_task(task_id)
|
|
86
|
+
if not task:
|
|
87
|
+
return [], "任务不存在"
|
|
88
|
+
files = []
|
|
89
|
+
for r in store.task_runs(task_id):
|
|
90
|
+
_wd, fs = store.run_artifacts(r.get("id") or "", limit=800)
|
|
91
|
+
if fs:
|
|
92
|
+
files = fs # 任一 run 的成品口径都从任务首跑起,取到即够
|
|
93
|
+
break
|
|
94
|
+
done = ledger.published_chapters(task_id, platform)
|
|
95
|
+
out, seen = [], set()
|
|
96
|
+
for f in files:
|
|
97
|
+
name = str(f.get("name") or "")
|
|
98
|
+
if not name.lower().endswith((".md", ".txt")):
|
|
99
|
+
continue
|
|
100
|
+
n = ledger.parse_chapter_no(name)
|
|
101
|
+
if n <= 0 or n in done or n in seen:
|
|
102
|
+
continue
|
|
103
|
+
seen.add(n)
|
|
104
|
+
out.append({"chapter_no": n, "file": name, "size": f.get("size") or 0})
|
|
105
|
+
out.sort(key=lambda x: x["chapter_no"])
|
|
106
|
+
return out, ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def status(task_id):
|
|
110
|
+
"""前端视图:待发清单 + 护栏状态 + 自动发布进度。"""
|
|
111
|
+
from . import ledger
|
|
112
|
+
ent = ledger.load_books().get(str(task_id)) or {}
|
|
113
|
+
books = []
|
|
114
|
+
for plat, info in ent.items():
|
|
115
|
+
pend, err = pending(task_id, plat)
|
|
116
|
+
ok, why = guards(task_id, plat)
|
|
117
|
+
books.append({"platform": plat, "bound": True,
|
|
118
|
+
"title": info.get("title") or "",
|
|
119
|
+
"pending": len(pend), "guard_ok": ok, "guard_reason": why,
|
|
120
|
+
"calibrated": calibrated(plat)})
|
|
121
|
+
run = _running.get(task_id) or None
|
|
122
|
+
if run:
|
|
123
|
+
run = dict(run)
|
|
124
|
+
return {"books": books, "running": run}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------- 顺序发布
|
|
128
|
+
def _wait_idle(platform, timeout=IDLE_TIMEOUT_S):
|
|
129
|
+
"""等 manager 的平台动作结束(busy → 其他)。人工确认模式下用户在
|
|
130
|
+
浏览器里点提交的时间也算在内,超时给足。"""
|
|
131
|
+
from . import manager
|
|
132
|
+
deadline = time.time() + timeout
|
|
133
|
+
while time.time() < deadline:
|
|
134
|
+
try:
|
|
135
|
+
st = (manager.view().get("platforms") or {}).get(platform) or {}
|
|
136
|
+
if st.get("status") != "busy":
|
|
137
|
+
return True
|
|
138
|
+
except Exception:
|
|
139
|
+
return False # manager 异常:别傻等
|
|
140
|
+
time.sleep(IDLE_POLL_S)
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def calibrated(platform):
|
|
145
|
+
"""该平台的发布流程是否已校准(data/publish/flows-<plat>.json 在场)。
|
|
146
|
+
|
|
147
|
+
内置默认表的选择器是「合理推测」;auto_submit 无人值守直发必须先经
|
|
148
|
+
真机校准(探测→写 flows 覆盖文件),否则填错表单还会自动提交出去。"""
|
|
149
|
+
from .. import paths
|
|
150
|
+
return (paths.PUBLISH_DIR / ("flows-%s.json" % platform)).is_file()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def publish_pending_async(task_id, platform, auto_submit=False):
|
|
154
|
+
"""把任务的待发章节按章号顺序发出(后台线程)。返回 (ok, err)。
|
|
155
|
+
|
|
156
|
+
auto_submit=True(直发)逐章提交走完全程;False(人工确认)每轮只填
|
|
157
|
+
**一章**就停在 manual_pause——表单填好后提交权在用户,walker 若直接
|
|
158
|
+
填下一章会导航离开未提交的编辑器,把上一章内容丢掉(平台草稿自动
|
|
159
|
+
保存不可依赖)。用户在浏览器提交后再次发起即发下一章。"""
|
|
160
|
+
from .. import store
|
|
161
|
+
from . import ledger, manager
|
|
162
|
+
if platform not in manager.PLATFORMS:
|
|
163
|
+
return False, "未知平台"
|
|
164
|
+
if auto_submit and not calibrated(platform):
|
|
165
|
+
return False, ("自动提交模式需要先校准该平台发布流程:用「探测」按钮 dump "
|
|
166
|
+
"表单后把真实步骤写进 data/publish/flows-%s.json(缺省选择器"
|
|
167
|
+
"只是推测,未校准不许无人值守直发)" % platform)
|
|
168
|
+
with _LOCK:
|
|
169
|
+
cur = _running.get(task_id) or {}
|
|
170
|
+
if cur.get("status") == "running":
|
|
171
|
+
return False, "该任务已有自动发布进行中,请等本轮结束"
|
|
172
|
+
task = store.get_task(task_id)
|
|
173
|
+
if not task:
|
|
174
|
+
return False, "任务不存在"
|
|
175
|
+
if not ledger.book_for(task_id, platform):
|
|
176
|
+
return False, "该任务尚未在此平台建书,请先「创建作品」"
|
|
177
|
+
ok, why = guards(task_id, platform)
|
|
178
|
+
if not ok:
|
|
179
|
+
return False, why
|
|
180
|
+
pend, err = pending(task_id, platform)
|
|
181
|
+
if err:
|
|
182
|
+
return False, err
|
|
183
|
+
if not pend:
|
|
184
|
+
return False, "没有待发章节(全部已发布,或成品里没有可识别的章节文件)"
|
|
185
|
+
|
|
186
|
+
from pathlib import Path
|
|
187
|
+
wd = task.get("workdir") or ""
|
|
188
|
+
st = {"platform": platform, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
189
|
+
"done": 0, "total": len(pend), "status": "running", "error": "",
|
|
190
|
+
"auto_submit": bool(auto_submit), "last_chapter": 0}
|
|
191
|
+
with _LOCK:
|
|
192
|
+
_running[task_id] = st
|
|
193
|
+
|
|
194
|
+
def run():
|
|
195
|
+
try:
|
|
196
|
+
for item in pend:
|
|
197
|
+
g_ok, why = guards(task_id, platform) # 每章前复查(中途也能拦)
|
|
198
|
+
if not g_ok:
|
|
199
|
+
st["status"] = "error"
|
|
200
|
+
st["error"] = "第 %d 章前护栏拦截:%s" % (item["chapter_no"], why)
|
|
201
|
+
return
|
|
202
|
+
ok2, err2 = manager.upload_chapter_async(
|
|
203
|
+
task_id, platform, str(Path(wd) / item["file"]),
|
|
204
|
+
auto_submit=auto_submit)
|
|
205
|
+
if not ok2:
|
|
206
|
+
st["status"] = "error"
|
|
207
|
+
st["error"] = "第 %d 章发起失败:%s" % (item["chapter_no"], err2)
|
|
208
|
+
return
|
|
209
|
+
if not _wait_idle(platform):
|
|
210
|
+
st["status"] = "error"
|
|
211
|
+
st["error"] = ("第 %d 章发布等待超时(%.0f 分钟);若在等人工提交,"
|
|
212
|
+
"请提交后重跑剩余章节" % (item["chapter_no"],
|
|
213
|
+
IDLE_TIMEOUT_S / 60))
|
|
214
|
+
return
|
|
215
|
+
if item["chapter_no"] not in ledger.published_chapters(
|
|
216
|
+
task_id, platform):
|
|
217
|
+
st["status"] = "error"
|
|
218
|
+
st["error"] = ("第 %d 章发布失败,后续章节未发(详见发布台账与"
|
|
219
|
+
"截图存证)" % item["chapter_no"])
|
|
220
|
+
return
|
|
221
|
+
st["done"] += 1
|
|
222
|
+
st["last_chapter"] = item["chapter_no"]
|
|
223
|
+
if not auto_submit and st["done"] < st["total"]:
|
|
224
|
+
# 人工确认模式:填好一章就停,等用户在浏览器提交后再发起
|
|
225
|
+
st["status"] = "manual_pause"
|
|
226
|
+
st["message"] = ("第 %d 章已填好,请在浏览器里确认提交;"
|
|
227
|
+
"提交后再点一次发布即发下一章(剩 %d 章)"
|
|
228
|
+
% (item["chapter_no"], st["total"] - st["done"]))
|
|
229
|
+
return
|
|
230
|
+
if st["done"] < st["total"]:
|
|
231
|
+
time.sleep(PACE_S)
|
|
232
|
+
st["status"] = "done"
|
|
233
|
+
if not auto_submit:
|
|
234
|
+
st["message"] = "第 %d 章已填好,请在浏览器里确认提交" % st["last_chapter"]
|
|
235
|
+
except Exception as e: # 线程内绝不能悬挂无终态
|
|
236
|
+
st["status"] = "error"
|
|
237
|
+
st["error"] = "自动发布异常:%s" % e
|
|
238
|
+
finally:
|
|
239
|
+
st["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
240
|
+
|
|
241
|
+
threading.Thread(target=run, daemon=True,
|
|
242
|
+
name="pub-auto-%s" % platform).start()
|
|
243
|
+
return True, ""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------- 定时联动(P2.5)
|
|
247
|
+
# 任务级标记 task.auto_publish = {enabled, platform, time:"HH:MM", auto_submit}
|
|
248
|
+
# automation._tick 每 25s 调 fire_due():到点且今日未触发 → publish_pending_async。
|
|
249
|
+
# 「今日已触发」记在 data/publish/auto_publish.json(task_id → YYYY-MM-DD):
|
|
250
|
+
# 触发过就不再重试当日(护栏/单飞自身也防重),成败都等明天——连败退避
|
|
251
|
+
# 场景下避免到点后每 25s 撞一次护栏。
|
|
252
|
+
_AP_FILE = None # paths.PUBLISH_DIR / "auto_publish.json"(导入惰性定)
|
|
253
|
+
_AP_FIRED = None # 内存缓存 {task_id: "YYYY-MM-DD"}
|
|
254
|
+
_AP_LOCK = threading.RLock() # 可重入:_mark_fired 持锁内调 _load_fired 再进同锁
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _ap_file():
|
|
258
|
+
global _AP_FILE
|
|
259
|
+
if _AP_FILE is None:
|
|
260
|
+
from .. import paths
|
|
261
|
+
_AP_FILE = paths.PUBLISH_DIR / "auto_publish.json"
|
|
262
|
+
return _AP_FILE
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _load_fired():
|
|
266
|
+
global _AP_FIRED
|
|
267
|
+
with _AP_LOCK:
|
|
268
|
+
if _AP_FIRED is None:
|
|
269
|
+
try:
|
|
270
|
+
import json
|
|
271
|
+
d = json.loads(_ap_file().read_text(encoding="utf-8"))
|
|
272
|
+
_AP_FIRED = d if isinstance(d, dict) else {}
|
|
273
|
+
except Exception:
|
|
274
|
+
_AP_FIRED = {}
|
|
275
|
+
return _AP_FIRED
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _mark_fired(task_id, day):
|
|
279
|
+
import json
|
|
280
|
+
with _AP_LOCK:
|
|
281
|
+
fired = _load_fired()
|
|
282
|
+
fired[str(task_id)] = day
|
|
283
|
+
try:
|
|
284
|
+
_ap_file().parent.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
tmp = _ap_file().with_suffix(".tmp")
|
|
286
|
+
tmp.write_text(json.dumps(fired, ensure_ascii=False, indent=1),
|
|
287
|
+
encoding="utf-8")
|
|
288
|
+
tmp.replace(_ap_file())
|
|
289
|
+
except Exception:
|
|
290
|
+
pass # 记录失败:最坏是当日重复触发,护栏会拦
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def norm_auto_publish(ap):
|
|
294
|
+
"""校验并归一 auto_publish 配置。非法返回 (None, 人话原因)。"""
|
|
295
|
+
from . import manager
|
|
296
|
+
if not isinstance(ap, dict):
|
|
297
|
+
return None, "auto_publish 必须是对象"
|
|
298
|
+
platform = str(ap.get("platform") or "").strip()
|
|
299
|
+
if platform not in manager.PLATFORMS:
|
|
300
|
+
return None, "platform 必须是 fanqie 或 qimao"
|
|
301
|
+
hhmm = str(ap.get("time") or "").strip()
|
|
302
|
+
import re
|
|
303
|
+
m = re.match(r"^(\d{1,2}):(\d{2})$", hhmm)
|
|
304
|
+
if not m:
|
|
305
|
+
return None, "time 必须是 24 小时制 HH:MM"
|
|
306
|
+
h, mi = int(m.group(1)), int(m.group(2))
|
|
307
|
+
if not (0 <= h <= 23 and 0 <= mi <= 59):
|
|
308
|
+
return None, "time 超出 0-23:00-59"
|
|
309
|
+
return {"enabled": bool(ap.get("enabled")),
|
|
310
|
+
"platform": platform,
|
|
311
|
+
"time": "%02d:%02d" % (h, mi),
|
|
312
|
+
"auto_submit": bool(ap.get("auto_submit")),
|
|
313
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S")}, ""
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def due_tasks(now=None):
|
|
317
|
+
"""到期待触发的定时发布任务清单:[(task, auto_publish)]。
|
|
318
|
+
|
|
319
|
+
条件:enabled + 已在该平台建书 + 今日未触发 + 当前时间已过当日 time。
|
|
320
|
+
未建书的任务跳过(触发也只会被 publish_pending_async 拒绝,白记一次
|
|
321
|
+
fired 反而把当天额度烧掉)。"""
|
|
322
|
+
from .. import store
|
|
323
|
+
from . import ledger
|
|
324
|
+
now = now or time.localtime()
|
|
325
|
+
today = time.strftime("%Y-%m-%d", now)
|
|
326
|
+
hhmm_now = time.strftime("%H:%M", now)
|
|
327
|
+
fired = _load_fired()
|
|
328
|
+
out = []
|
|
329
|
+
for task in store.list_tasks(limit=10 ** 9):
|
|
330
|
+
ap = task.get("auto_publish")
|
|
331
|
+
if not isinstance(ap, dict) or not ap.get("enabled"):
|
|
332
|
+
continue
|
|
333
|
+
if str(ap.get("time") or "") <= hhmm_now and fired.get(task["id"]) != today:
|
|
334
|
+
plat = ap.get("platform")
|
|
335
|
+
if plat and ledger.book_for(task["id"], plat):
|
|
336
|
+
out.append((task, ap))
|
|
337
|
+
return out
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def fire_due(now=None):
|
|
341
|
+
"""tick 入口:把所有到期的定时发布触发一轮。返回触发条数;单条异常不拖累其余。"""
|
|
342
|
+
from . import ledger # 惰性导入(同模块惯例):漏了会 NameError
|
|
343
|
+
now = now or time.localtime() # 且被双层 except 双重静默成幽灵 0
|
|
344
|
+
today = time.strftime("%Y-%m-%d", now)
|
|
345
|
+
n = 0
|
|
346
|
+
for task, ap in due_tasks(now=now):
|
|
347
|
+
try:
|
|
348
|
+
ok, err = publish_pending_async(
|
|
349
|
+
task["id"], ap.get("platform"),
|
|
350
|
+
auto_submit=bool(ap.get("auto_submit")))
|
|
351
|
+
_mark_fired(task["id"], today) # 成败都记:当日不重试
|
|
352
|
+
if ok:
|
|
353
|
+
ledger.record(ap.get("platform"), "auto_fire", task_id=task["id"],
|
|
354
|
+
title="定时触发 %s" % (ap.get("time") or ""), ok=True)
|
|
355
|
+
n += 1
|
|
356
|
+
else:
|
|
357
|
+
ledger.record(ap.get("platform"), "auto_fire", task_id=task["id"],
|
|
358
|
+
title="定时触发 %s" % (ap.get("time") or ""),
|
|
359
|
+
ok=False, error=str(err)[:200])
|
|
360
|
+
except Exception as e:
|
|
361
|
+
try:
|
|
362
|
+
_mark_fired(task["id"], today)
|
|
363
|
+
ledger.record(ap.get("platform"), "auto_fire", task_id=task["id"],
|
|
364
|
+
ok=False, error=str(e)[:200])
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
return n
|