codebee 0.1.97 → 0.1.99
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 +12 -2
- package/README.md +5 -3
- package/app/core/builtin_agent.py +36 -37
- package/app/core/jobs.py +8 -1
- package/app/core/manager.py +109 -11
- package/app/core/modelhub.py +21 -5
- package/app/core/pipeline.py +29 -14
- package/app/core/publish/auto.py +91 -20
- package/app/core/publish/fanqie.py +30 -0
- package/app/core/publish/flows-fanqie-calibrated.json +81 -0
- package/app/core/publish/flows-qimao-calibrated.json +58 -0
- package/app/core/publish/ledger.py +24 -1
- package/app/core/publish/manager.py +36 -9
- package/app/core/publish/qimao.py +17 -0
- package/app/core/quality_gate.py +7 -0
- package/app/core/runner.py +13 -4
- package/app/main.py +5 -2
- package/app/ui/app.js +86 -14
- package/app/ui/board.css +5 -1
- package/app/ui/board.js +16 -0
- package/app/ui/i18n.js +26 -1
- package/app/ui/index.html +1 -1
- package/package.json +1 -1
package/app/core/publish/auto.py
CHANGED
|
@@ -135,7 +135,9 @@ def pending(task_id, platform):
|
|
|
135
135
|
"""待发章节清单:任务工作目录里的章节文件 − 已发章号,按章号升序。
|
|
136
136
|
|
|
137
137
|
已发口径连载链合并(含校准所得平台实况),所以任何一个批次任务上发
|
|
138
|
-
|
|
138
|
+
起发布,看到的都是整本书的待发清单。已存草稿的章同样剔除(内容已在
|
|
139
|
+
平台草稿箱,再跑只会造重复稿;提交发布走平台草稿箱由用户手工完成)。
|
|
140
|
+
返回 (list, err);list 项
|
|
139
141
|
{chapter_no, file, size},file 为工作目录相对路径。章号解析不出的
|
|
140
142
|
文件不进自动发布(防同章多文件误发),API 单章发(manager 直调)
|
|
141
143
|
不受此限。
|
|
@@ -154,7 +156,8 @@ def pending(task_id, platform):
|
|
|
154
156
|
if fs:
|
|
155
157
|
files = fs # 任一 run 的成品口径都从任务首跑起,取到即够
|
|
156
158
|
break
|
|
157
|
-
done = ledger.published_chapters(task_id, platform)
|
|
159
|
+
done = ledger.published_chapters(task_id, platform) \
|
|
160
|
+
| ledger.drafted_chapters(task_id, platform)
|
|
158
161
|
# 分卷标注(2026-10-08 用户需求:待发清单按卷展示):卷计划解析不出时
|
|
159
162
|
# volume 为空串,前端不分组平铺。
|
|
160
163
|
plan = []
|
|
@@ -182,7 +185,12 @@ def pending(task_id, platform):
|
|
|
182
185
|
|
|
183
186
|
|
|
184
187
|
def status(task_id):
|
|
185
|
-
"""前端视图:待发清单 + 护栏状态 + 自动发布进度。
|
|
188
|
+
"""前端视图:待发清单 + 护栏状态 + 自动发布进度。
|
|
189
|
+
|
|
190
|
+
护栏分两层给前端:guard_ok/guard_reason=完整发布闸(硬护栏+质量闸),
|
|
191
|
+
管「发布」类按钮;draft_ok/draft_reason=硬护栏(每日上限/连败退避/建书
|
|
192
|
+
确认),管「存草稿」按钮——草稿不上线,质量闸拦它只会堵死「先落平台
|
|
193
|
+
再人工把关」的通路;quality_blockers 单独带出去做提示性展示。"""
|
|
186
194
|
from .. import store
|
|
187
195
|
from . import ledger, manager
|
|
188
196
|
ent = ledger.books_for(str(task_id)) # 沿连载链继承:续写批次同书同账
|
|
@@ -190,19 +198,25 @@ def status(task_id):
|
|
|
190
198
|
books = []
|
|
191
199
|
for plat, info in ent.items():
|
|
192
200
|
pend, err = pending(task_id, plat)
|
|
193
|
-
|
|
201
|
+
hard_ok, hard_why = guards(task_id, plat)
|
|
194
202
|
if not _book_ready(info):
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
hard_ok, hard_why = False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
|
|
204
|
+
blockers = []
|
|
205
|
+
if task and _book_ready(info):
|
|
197
206
|
quality = manager._quality_release_guard(task, plat, action="publish")
|
|
198
207
|
if not quality.get("allowed"):
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
blockers = [str(b) for b in (quality.get("blockers") or [])]
|
|
209
|
+
ok, why = hard_ok, hard_why
|
|
210
|
+
if ok and blockers:
|
|
211
|
+
ok, why = False, "质量门禁拦截:%s" % ";".join(blockers)
|
|
201
212
|
books.append({"platform": plat, "bound": True,
|
|
202
213
|
"title": info.get("title") or "",
|
|
203
214
|
"pending": len(pend),
|
|
204
215
|
"items": pend[:200],
|
|
205
216
|
"guard_ok": ok, "guard_reason": why,
|
|
217
|
+
"draft_ok": hard_ok, "draft_reason": hard_why,
|
|
218
|
+
"quality_blockers": blockers,
|
|
219
|
+
"drafted": len(ledger.drafted_chapters(task_id, plat)),
|
|
206
220
|
"calibrated": calibrated(plat)})
|
|
207
221
|
run = _running.get(task_id) or None
|
|
208
222
|
if run:
|
|
@@ -236,8 +250,27 @@ def calibrated(platform):
|
|
|
236
250
|
return (paths.PUBLISH_DIR / ("flows-%s.json" % platform)).is_file()
|
|
237
251
|
|
|
238
252
|
|
|
253
|
+
def flow_calibrated(plat, action):
|
|
254
|
+
"""某动作的步骤表是否有真机校准来源(用户校准文件或仓库校准模板里
|
|
255
|
+
有该动作的表)。内置推测表不算——存草稿虽不上线,批量乱点照样惹
|
|
256
|
+
风控/攒一箱废稿,无人值守跑之前必须有验证过的步骤表。"""
|
|
257
|
+
import json as _json
|
|
258
|
+
from pathlib import Path as _Path
|
|
259
|
+
from .. import paths as _paths
|
|
260
|
+
for fp in (_paths.PUBLISH_DIR / ("flows-%s.json" % plat),
|
|
261
|
+
_Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
|
|
262
|
+
try:
|
|
263
|
+
data = _json.loads(fp.read_text(encoding="utf-8"))
|
|
264
|
+
if isinstance(data, dict) and isinstance(data.get(action), list):
|
|
265
|
+
return True
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
239
271
|
def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
240
|
-
force=False, force_confirmed=False, force_reason=""
|
|
272
|
+
force=False, force_confirmed=False, force_reason="",
|
|
273
|
+
as_draft=False):
|
|
241
274
|
"""把任务的待发章节按章号顺序发出(后台线程)。返回 (ok, err)。
|
|
242
275
|
|
|
243
276
|
only=章号清单(批量选择发布):从待发清单里挑出所选章号,保持章号
|
|
@@ -245,12 +278,22 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
245
278
|
auto_submit=True(直发)逐章提交走完全程;False(人工确认)每轮只填
|
|
246
279
|
**一章**就停在 manual_pause——表单填好后提交权在用户,walker 若直接
|
|
247
280
|
填下一章会导航离开未提交的编辑器,把上一章内容丢掉(平台草稿自动
|
|
248
|
-
保存不可依赖)。用户在浏览器提交后再次发起即发下一章。
|
|
281
|
+
保存不可依赖)。用户在浏览器提交后再次发起即发下一章。
|
|
282
|
+
as_draft=True(全部发草稿,2026-10-09):逐章自动填稿并点「存草稿」,
|
|
283
|
+
不上线、不受质量闸拦、无需人工确认,一章接一章连跑到清空待发清单
|
|
284
|
+
(章间仍按 PACE_S 防风控节奏);提交发布由用户到平台草稿箱手工完成,
|
|
285
|
+
届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。"""
|
|
249
286
|
from .. import store
|
|
250
287
|
from . import ledger, manager
|
|
251
288
|
if platform not in manager.PLATFORMS:
|
|
252
289
|
return False, "未知平台"
|
|
253
|
-
if
|
|
290
|
+
if as_draft:
|
|
291
|
+
auto_submit = False # 草稿模式没有「提交」语义
|
|
292
|
+
if not flow_calibrated(platform, "upload_chapter_draft"):
|
|
293
|
+
return False, ("存草稿模式需要该平台有 upload_chapter_draft 步骤表"
|
|
294
|
+
"(仓库校准模板或 data/publish/flows-%s.json);"
|
|
295
|
+
"未校准不许无人值守批量动表单" % platform)
|
|
296
|
+
elif auto_submit and not calibrated(platform):
|
|
254
297
|
return False, ("自动提交模式需要先校准该平台发布流程:用「探测」按钮 dump "
|
|
255
298
|
"表单后把真实步骤写进 data/publish/flows-%s.json(缺省选择器"
|
|
256
299
|
"只是推测,未校准不许无人值守直发)" % platform)
|
|
@@ -271,9 +314,13 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
271
314
|
return False, "该任务尚未在此平台建书,请先「创建作品」"
|
|
272
315
|
if not _book_ready(book):
|
|
273
316
|
return False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
317
|
+
if as_draft:
|
|
318
|
+
# 草稿不上线:质量闸不适用(同 manager 口径),硬护栏照查
|
|
319
|
+
quality = {"allowed": True, "status": "draft_only"}
|
|
320
|
+
else:
|
|
321
|
+
quality = manager._quality_release_guard(
|
|
322
|
+
task, platform, action="publish", force=force,
|
|
323
|
+
force_confirmed=force_confirmed, force_reason=force_reason)
|
|
277
324
|
if not quality.get("allowed"):
|
|
278
325
|
return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
|
|
279
326
|
ok, why = guards(task_id, platform)
|
|
@@ -298,7 +345,8 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
298
345
|
wd = task.get("workdir") or ""
|
|
299
346
|
st = {"platform": platform, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
300
347
|
"done": 0, "total": len(pend), "status": "running", "error": "",
|
|
301
|
-
"auto_submit": bool(auto_submit), "
|
|
348
|
+
"auto_submit": bool(auto_submit), "as_draft": bool(as_draft),
|
|
349
|
+
"last_chapter": 0}
|
|
302
350
|
with _LOCK:
|
|
303
351
|
_running[task_id] = st
|
|
304
352
|
|
|
@@ -310,7 +358,7 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
310
358
|
st["status"] = "error"
|
|
311
359
|
st["error"] = "第 %d 章前护栏拦截:%s" % (item["chapter_no"], why)
|
|
312
360
|
return
|
|
313
|
-
upload_kwargs = {"auto_submit": auto_submit}
|
|
361
|
+
upload_kwargs = {"auto_submit": auto_submit, "as_draft": as_draft}
|
|
314
362
|
if force or force_confirmed or force_reason:
|
|
315
363
|
upload_kwargs.update(force=force,
|
|
316
364
|
force_confirmed=force_confirmed,
|
|
@@ -324,10 +372,29 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
324
372
|
return
|
|
325
373
|
if not _wait_idle(platform):
|
|
326
374
|
st["status"] = "error"
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
375
|
+
if as_draft:
|
|
376
|
+
st["error"] = ("第 %d 章存草稿等待超时(%.0f 分钟),后续"
|
|
377
|
+
"章节未存" % (item["chapter_no"],
|
|
378
|
+
IDLE_TIMEOUT_S / 60))
|
|
379
|
+
else:
|
|
380
|
+
st["error"] = ("第 %d 章发布等待超时(%.0f 分钟);若在等人工提交,"
|
|
381
|
+
"请提交后重跑剩余章节" % (item["chapter_no"],
|
|
382
|
+
IDLE_TIMEOUT_S / 60))
|
|
330
383
|
return
|
|
384
|
+
if as_draft:
|
|
385
|
+
# manager 走完草稿流程(含平台侧验证)才返回;台账落了
|
|
386
|
+
# upload_chapter_draft 才算数,防「流程跑完但平台没存上」。
|
|
387
|
+
if item["chapter_no"] not in ledger.drafted_chapters(
|
|
388
|
+
task_id, platform):
|
|
389
|
+
st["status"] = "error"
|
|
390
|
+
st["error"] = ("第 %d 章存草稿未确认,后续章节未存(详见"
|
|
391
|
+
"发布台账与截图存证)" % item["chapter_no"])
|
|
392
|
+
return
|
|
393
|
+
st["done"] += 1
|
|
394
|
+
st["last_chapter"] = item["chapter_no"]
|
|
395
|
+
if st["done"] < st["total"]:
|
|
396
|
+
time.sleep(PACE_S)
|
|
397
|
+
continue
|
|
331
398
|
if not auto_submit:
|
|
332
399
|
# The manager stopped before the submit step. There is no
|
|
333
400
|
# remote success receipt until the user clicks submit.
|
|
@@ -348,7 +415,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
348
415
|
if st["done"] < st["total"]:
|
|
349
416
|
time.sleep(PACE_S)
|
|
350
417
|
st["status"] = "done"
|
|
351
|
-
if
|
|
418
|
+
if as_draft:
|
|
419
|
+
st["message"] = ("已存草稿 %d 章(未发布,不受质量闸拦不代表可直发);"
|
|
420
|
+
"请到平台章节管理/草稿箱逐章检查后提交发布"
|
|
421
|
+
% st["done"])
|
|
422
|
+
elif not auto_submit:
|
|
352
423
|
st["message"] = "第 %d 章已填好,请在浏览器里确认提交,再重新校准" % st["last_chapter"]
|
|
353
424
|
except Exception as e: # 线程内绝不能悬挂无终态
|
|
354
425
|
st["status"] = "error"
|
|
@@ -110,6 +110,35 @@ UPLOAD_CHAPTER = [
|
|
|
110
110
|
"optional": True},
|
|
111
111
|
]
|
|
112
112
|
|
|
113
|
+
# 发章存草稿(2026-10-09 全部发草稿):与 UPLOAD_CHAPTER 同一张填表前段,
|
|
114
|
+
# 终点不同——点编辑器头部「存草稿」直接落草稿箱,不走「下一步→内容检测→
|
|
115
|
+
# 发布提示→提交」发布链。草稿不上线,批量连跑无需人工确认;提交发布由
|
|
116
|
+
# 用户到章节管理/草稿箱手工完成。verify 沿用章节管理页断言(草稿行同样
|
|
117
|
+
# 出现在列表里,标题/章号标记可命中);若平台改版把草稿挪去独立草稿箱页,
|
|
118
|
+
# 首轮失败的 step 截图会指出来,照 flows-fanqie.json 校准即可。
|
|
119
|
+
UPLOAD_CHAPTER_DRAFT = [
|
|
120
|
+
{"do": "navigate", "url": "{editor_url}"},
|
|
121
|
+
{"do": "url_any", "any": ["fanqienovel.com"]},
|
|
122
|
+
{"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
|
|
123
|
+
{"do": "volume", "key": "volume_name", "optional": True,
|
|
124
|
+
"open_sel": "input[type=number],input[class*=serial],div[class*=volume]",
|
|
125
|
+
"modal_sel": "[class*=volume][class*=modal],[class*=modal][class*=volume]",
|
|
126
|
+
"item_sel": "[class*=volume] li,[class*=volume-item]",
|
|
127
|
+
"add_sel": "[class*=add-volume],[class*=volume] button",
|
|
128
|
+
"confirm_sel": "[class*=confirm]", "settle": 1.5},
|
|
129
|
+
{"do": "fill", "sel": "input[type=number],input[class*=serial],input[class*=byte]", "key": "chapter_no"},
|
|
130
|
+
{"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题']", "key": "chapter_name"},
|
|
131
|
+
{"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
|
|
132
|
+
{"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
|
|
133
|
+
{"do": "shot", "name": "chapter-filled-draft"},
|
|
134
|
+
{"do": "submit", "text": "存草稿", "scope": "button", "tries": 15},
|
|
135
|
+
{"do": "js_click", "text": "存草稿", "scope": "button", "tries": 10,
|
|
136
|
+
"optional": True, "skip_if_modal": True},
|
|
137
|
+
{"do": "sleep", "s": 2.5},
|
|
138
|
+
{"do": "verify", "url": "{chapter_manage_url}",
|
|
139
|
+
"any": ["第{chapter_no}章", "{chapter_name}"], "settle": 6, "new_tab": True},
|
|
140
|
+
]
|
|
141
|
+
|
|
113
142
|
# 登录态探测:打开后台首页,URL 被踢到登录页 → 未登录
|
|
114
143
|
CHECK_LOGIN = [
|
|
115
144
|
{"do": "navigate", "url": "{home}"},
|
|
@@ -125,6 +154,7 @@ PROBE_FORM = [
|
|
|
125
154
|
]
|
|
126
155
|
|
|
127
156
|
FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
|
|
157
|
+
"upload_chapter_draft": UPLOAD_CHAPTER_DRAFT,
|
|
128
158
|
"check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
|
|
129
159
|
|
|
130
160
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
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。0928 再加固(弹层没开连败案):签约/读者 radio 点击后断言选中态(expect_checked);两处标签触发器点完 wait .category-modal 存在替代盲 sleep;real_click_text 落点 elementFromPoint 守卫,找不到目标时报错自带定位范围可见项诊断。2026-10-08 发章三修:values 补 chapter_no/chapter_name 键(此前序号框从未被填、被平台以「章节序号只支持阿拉伯数字」打回);标题位改填 chapter_name(read_chapter 拆掉「第X章」前缀,序号/标题分开填);verify 标记改「第{chapter_no}章」或「{chapter_name}」任一命中(用户手工改过标题时按名对版必假失败)。。2026-10-08 批量自动发布·自动分卷:upload_chapter 增加 volume 步骤(values.volume_name 由 manager 按工作目录卷计划解析:分卷.json 优先,其次 大纲/series-outline.md 的「## 第X卷:名」+「依次48、56…章」;平台卷弹窗 = .publish-header-volume-name 开 → .editor-volume-list-item 按去前缀名匹配选中,缺卷走 .editor-volume-footer-add-volume 新建→行内 input 输名→i.tomato-confirm 落名→选中→确定;新建卷名会追加进 values._volumes 由 manager 回写 books.json 缓存)。。2026-10-08 晚:平台发布确认 UI 已改——下一步(保存校验)后弹「发布提示」错别字确认框(取消/提交),不再有「定时发布」下拉组;流程撤 click_arrow/定时发布/发布,改点确认框「提交」(optional:无错别字时可能不弹,直接 verify)。。verify 改 new_tab(开新页签验证、编辑器页签不动):编辑器页签内导航触发 beforeunload 原生确认,渲染挂起+发布请求被取消——直发章全卡死在验证导航上的实案修法。。1008深夜终版:publish链=submit(下一步,gate)→js_click下一步兜底(skip_if_modal)→js_click仅基础检测(检测方式弹窗,tries60)→sleep5→js_click提交(错别字确认,tries75)→verify new_tab;真实鼠标在 headless/后台页签不触发平台处理器,DOM click 可靠。",
|
|
3
|
+
"upload_chapter_draft": "2026-10-09 全部发草稿:前段(volume/序号/标题/正文填表)与 upload_chapter 同源选择器;终点改点编辑器头部「存草稿」直接落草稿箱,不走下一步/检测/发布提示发布链。verify 沿用章节管理页断言(草稿行同样在列表里);若平台把草稿挪去独立草稿箱页,首轮失败截图会指出,届时按此校准。「存草稿」按钮文本未真机实证(校准时浏览器离线),首次批量跑请盯首章截图。",
|
|
3
4
|
"check_login": [
|
|
4
5
|
{
|
|
5
6
|
"do": "navigate",
|
|
@@ -111,6 +112,86 @@
|
|
|
111
112
|
"new_tab": true
|
|
112
113
|
}
|
|
113
114
|
],
|
|
115
|
+
"upload_chapter_draft": [
|
|
116
|
+
{
|
|
117
|
+
"do": "navigate",
|
|
118
|
+
"url": "about:blank"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"do": "navigate",
|
|
122
|
+
"url": "{editor_url}"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"do": "url_any",
|
|
126
|
+
"any": [
|
|
127
|
+
"fanqienovel.com"
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"do": "wait",
|
|
132
|
+
"sel": ".ProseMirror",
|
|
133
|
+
"timeout": 20
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"do": "volume",
|
|
137
|
+
"key": "volume_name",
|
|
138
|
+
"optional": true,
|
|
139
|
+
"open_sel": ".publish-header-volume-name",
|
|
140
|
+
"modal_sel": ".serial-modal.editor-volume",
|
|
141
|
+
"item_sel": ".editor-volume-list-item",
|
|
142
|
+
"add_sel": ".editor-volume-footer-add-volume",
|
|
143
|
+
"input_sel": ".editor-volume-list-item input",
|
|
144
|
+
"confirm_sel": "i.tomato-confirm",
|
|
145
|
+
"settle": 1.5
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"do": "fill",
|
|
149
|
+
"sel": "input.serial-input, input.byte-input",
|
|
150
|
+
"key": "chapter_no"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"do": "fill",
|
|
154
|
+
"sel": "div.serial-editor-input-hint input",
|
|
155
|
+
"key": "chapter_name"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"do": "fill",
|
|
159
|
+
"sel": ".ProseMirror",
|
|
160
|
+
"key": "chapter_body"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"do": "shot",
|
|
164
|
+
"name": "chapter-filled-draft"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"do": "submit",
|
|
168
|
+
"text": "存草稿",
|
|
169
|
+
"scope": "button",
|
|
170
|
+
"tries": 15
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"do": "js_click",
|
|
174
|
+
"text": "存草稿",
|
|
175
|
+
"scope": "button",
|
|
176
|
+
"tries": 10,
|
|
177
|
+
"optional": true,
|
|
178
|
+
"skip_if_modal": true
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"do": "sleep",
|
|
182
|
+
"s": 2.5
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"do": "verify",
|
|
186
|
+
"url": "{chapter_manage_url}",
|
|
187
|
+
"any": [
|
|
188
|
+
"第{chapter_no}章",
|
|
189
|
+
"{chapter_name}"
|
|
190
|
+
],
|
|
191
|
+
"settle": 6,
|
|
192
|
+
"new_tab": true
|
|
193
|
+
}
|
|
194
|
+
],
|
|
114
195
|
"probe_form": [
|
|
115
196
|
{
|
|
116
197
|
"do": "navigate",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
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 再点「添加标签」(下拉关闭动画吞点击致标签弹层未开、组名切换失败案);2026-09-24 夜修:平台编辑器「立即发布」变为弹重型确认框(点后页面假死、章节未提交、草稿箱亦空——旧「立即发布落草稿箱」语义过时),改点「存草稿」再走草稿箱三层发布(草稿行立即发布→更正序号→确认发布,22:47 实证成功路径)",
|
|
3
|
+
"upload_chapter_draft": "2026-10-09 全部发草稿:按 upload_chapter 的「存草稿」段拆出(填标题/正文→click_real 存草稿→draft_url 草稿箱 verify 行在场)。存草稿段是 0924 实证路径的前半段,选择器同源;提交发布(立即发布三层弹窗)留给用户在草稿箱手工完成。",
|
|
3
4
|
"probe_form": [
|
|
4
5
|
{
|
|
5
6
|
"do": "navigate",
|
|
@@ -226,6 +227,63 @@
|
|
|
226
227
|
"settle": 6
|
|
227
228
|
}
|
|
228
229
|
],
|
|
230
|
+
"upload_chapter_draft": [
|
|
231
|
+
{
|
|
232
|
+
"do": "navigate",
|
|
233
|
+
"url": "about:blank"
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"do": "navigate",
|
|
237
|
+
"url": "{editor_url}"
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"do": "url_any",
|
|
241
|
+
"any": [
|
|
242
|
+
"qimao.com"
|
|
243
|
+
]
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"do": "wait",
|
|
247
|
+
"sel": "textarea[placeholder*='章节名称']",
|
|
248
|
+
"timeout": 20
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"do": "fill",
|
|
252
|
+
"sel": "textarea[placeholder*='章节名称']",
|
|
253
|
+
"key": "chapter_title"
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"do": "wait",
|
|
257
|
+
"sel": ".q-contenteditable",
|
|
258
|
+
"timeout": 12
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"do": "fill",
|
|
262
|
+
"sel": ".q-contenteditable",
|
|
263
|
+
"key": "chapter_body"
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"do": "shot",
|
|
267
|
+
"name": "chapter-filled-draft"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"do": "click_real",
|
|
271
|
+
"text": "存草稿",
|
|
272
|
+
"tries": 25
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"do": "sleep",
|
|
276
|
+
"s": 2
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
"do": "verify",
|
|
280
|
+
"url": "{draft_url}",
|
|
281
|
+
"any": [
|
|
282
|
+
"{chapter_title}"
|
|
283
|
+
],
|
|
284
|
+
"settle": 5
|
|
285
|
+
}
|
|
286
|
+
],
|
|
229
287
|
"check_login": [
|
|
230
288
|
{
|
|
231
289
|
"do": "navigate",
|
|
@@ -111,6 +111,28 @@ def published_chapters(task_id, platform):
|
|
|
111
111
|
return out
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
def drafted_chapters(task_id, platform):
|
|
115
|
+
"""该任务在该平台已成功存草稿的章节号集合(发草稿模式的幂等口径)。
|
|
116
|
+
|
|
117
|
+
与 published_chapters 同款连载链合并;不含校准的平台实况(实况口径里
|
|
118
|
+
没有「草稿」桶,草稿章是否已由用户手工发布以重新校准后的
|
|
119
|
+
remote_published_nos 为准)。草稿章从待发清单剔除:已在平台存稿的章
|
|
120
|
+
再跑一遍只会造出重复草稿。"""
|
|
121
|
+
from .. import store
|
|
122
|
+
try:
|
|
123
|
+
ids = set(store.serial_chain_ids(task_id))
|
|
124
|
+
except Exception:
|
|
125
|
+
ids = {str(task_id)}
|
|
126
|
+
out = set()
|
|
127
|
+
for r in _iter_records(3650):
|
|
128
|
+
if (r.get("action") == "upload_chapter_draft" and r.get("ok")
|
|
129
|
+
and r.get("task_id") in ids and r.get("platform") == platform):
|
|
130
|
+
n = r.get("chapter_no") or 0
|
|
131
|
+
if n > 0:
|
|
132
|
+
out.add(int(n))
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
114
136
|
def recent(task_id=None, platform=None, limit=50):
|
|
115
137
|
"""最近记录(新在前),详情页发布历史用。task_id 给定时按连载链合并。"""
|
|
116
138
|
from .. import store
|
|
@@ -338,7 +360,8 @@ def consecutive_failures(platform):
|
|
|
338
360
|
for r in reversed(list(_iter_records(7))):
|
|
339
361
|
if r.get("platform") != platform:
|
|
340
362
|
continue
|
|
341
|
-
if r.get("action") not in ("upload_chapter", "create_book"
|
|
363
|
+
if r.get("action") not in ("upload_chapter", "create_book",
|
|
364
|
+
"upload_chapter_draft"):
|
|
342
365
|
continue
|
|
343
366
|
t = _rec_time(r)
|
|
344
367
|
if t is None:
|
|
@@ -872,8 +872,14 @@ def read_chapter(fp):
|
|
|
872
872
|
|
|
873
873
|
|
|
874
874
|
def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
|
|
875
|
-
force=False, force_confirmed=False, force_reason=""
|
|
876
|
-
|
|
875
|
+
force=False, force_confirmed=False, force_reason="",
|
|
876
|
+
as_draft=False):
|
|
877
|
+
"""把一章发到平台(或填好待人工确认/as_draft 只存草稿)。
|
|
878
|
+
|
|
879
|
+
幂等:已成功发布**或已存草稿**的章号拒绝重做——重复发会平台上出双章,
|
|
880
|
+
重复存草稿会攒一箱重复稿。as_draft=True 走「存草稿」流程表
|
|
881
|
+
(upload_chapter_draft):内容落平台草稿箱不上线,不构成发布动作,
|
|
882
|
+
质量闸不拦(发布时照拦);存稿动作无需人工确认,适合批量连跑。"""
|
|
877
883
|
from .. import store
|
|
878
884
|
if plat not in PLATFORMS:
|
|
879
885
|
return False, "未知平台"
|
|
@@ -894,9 +900,14 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
|
|
|
894
900
|
ch_no, title, body, err = read_chapter(chapter_file)
|
|
895
901
|
if err:
|
|
896
902
|
return False, err
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
903
|
+
if as_draft:
|
|
904
|
+
# 草稿不上线:质量闸不适用(evaluate_release action=draft 直通),
|
|
905
|
+
# 人工复核挪到「平台草稿箱 → 用户逐章检查后提交」那一步。
|
|
906
|
+
quality = {"allowed": True}
|
|
907
|
+
else:
|
|
908
|
+
quality = _quality_release_guard(
|
|
909
|
+
task, plat, action="publish", target_chapter=ch_no or None,
|
|
910
|
+
force=force, force_confirmed=force_confirmed, force_reason=force_reason)
|
|
900
911
|
if not quality.get("allowed"):
|
|
901
912
|
return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
|
|
902
913
|
n_chars = len(body.replace("\n", "").replace(" ", ""))
|
|
@@ -912,10 +923,14 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
|
|
|
912
923
|
done = ledger.published_chapters(task_id, plat)
|
|
913
924
|
if ch_no and ch_no in done:
|
|
914
925
|
return False, "第 %d 章已成功发布过(台账幂等拦截);确需重发请手工处理" % ch_no
|
|
926
|
+
if as_draft and ch_no and ch_no in ledger.drafted_chapters(task_id, plat):
|
|
927
|
+
return False, ("第 %d 章已存过草稿(台账幂等拦截);请到平台草稿箱检查后"
|
|
928
|
+
"提交发布,确需重存请先在平台删旧稿" % ch_no)
|
|
915
929
|
ok_login, why = _login_guard(plat)
|
|
916
930
|
if not ok_login:
|
|
917
931
|
return False, why
|
|
918
|
-
|
|
932
|
+
flow_key = "upload_chapter_draft" if as_draft else "upload_chapter"
|
|
933
|
+
_set(plat, status="busy", last_action=flow_key, error="")
|
|
919
934
|
|
|
920
935
|
mod = PLATFORMS[plat]
|
|
921
936
|
values = chapter_fill_values(ch_no, title, body,
|
|
@@ -934,7 +949,7 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
|
|
|
934
949
|
operation_id = operations.begin(
|
|
935
950
|
"publish:%s:upload_chapter" % plat,
|
|
936
951
|
{"task_id": task_id, "chapter_no": ch_no, "title": title}, task_id=task_id,
|
|
937
|
-
metadata={"platform": plat, "action":
|
|
952
|
+
metadata={"platform": plat, "action": flow_key})
|
|
938
953
|
|
|
939
954
|
def run():
|
|
940
955
|
try:
|
|
@@ -948,14 +963,26 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
|
|
|
948
963
|
elif not book_ref.get("book_id"):
|
|
949
964
|
raise RuntimeError("未按作品名找到远端作品,未打开章节编辑器;"
|
|
950
965
|
"请检查平台作品名或补填作品 ID")
|
|
951
|
-
flow.run_flow(page, load_flow(plat,
|
|
966
|
+
flow.run_flow(page, load_flow(plat, flow_key), values=values,
|
|
952
967
|
config=mod.CONFIG, auto_submit=auto_submit,
|
|
953
968
|
shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
|
|
954
969
|
log=logs.append)
|
|
955
970
|
new_vols = values.get("_volumes")
|
|
956
971
|
if isinstance(new_vols, list) and new_vols != (book_ref.get("volumes") or []):
|
|
957
972
|
ledger.update_book(task_id, plat, volumes=new_vols)
|
|
958
|
-
if
|
|
973
|
+
if as_draft:
|
|
974
|
+
# 草稿已落平台草稿箱(流程含验证步,走到这=验证过)。发布确认权
|
|
975
|
+
# 仍在用户:到平台草稿箱逐章检查后手工提交,质量闸在「发布」时
|
|
976
|
+
# 照常把关。
|
|
977
|
+
operations.confirm(operation_id, metadata={"platform": plat,
|
|
978
|
+
"action": "upload_chapter_draft",
|
|
979
|
+
"chapter_no": ch_no})
|
|
980
|
+
ledger.record(plat, "upload_chapter_draft", task_id=task_id,
|
|
981
|
+
chapter_no=ch_no, book_id=book_ref.get("book_id") or "",
|
|
982
|
+
title=title, ok=True, operation_id=operation_id,
|
|
983
|
+
operation_status="confirmed",
|
|
984
|
+
remote_receipt="platform-draft")
|
|
985
|
+
elif auto_submit:
|
|
959
986
|
operations.confirm(operation_id, metadata={"platform": plat,
|
|
960
987
|
"action": "upload_chapter",
|
|
961
988
|
"chapter_no": ch_no})
|
|
@@ -60,6 +60,22 @@ UPLOAD_CHAPTER = [
|
|
|
60
60
|
{"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
|
|
61
61
|
]
|
|
62
62
|
|
|
63
|
+
# 发章存草稿(2026-10-09 全部发草稿):内置推测表——真机校准表在
|
|
64
|
+
# flows-qimao-calibrated.json(按其 upload_chapter 的「存草稿」段拆出:
|
|
65
|
+
# 填好→存草稿→草稿箱验证行在场即成)。草稿不上线;提交发布由用户在
|
|
66
|
+
# 草稿箱手工点(立即发布→更正序号→确认发布三层弹窗照旧人工走)。
|
|
67
|
+
UPLOAD_CHAPTER_DRAFT = [
|
|
68
|
+
{"do": "navigate", "url": "{editor_url}"},
|
|
69
|
+
{"do": "url_any", "any": ["qimao.com"]},
|
|
70
|
+
{"do": "fill", "sel": "textarea[placeholder*='章节名称']", "key": "chapter_title"},
|
|
71
|
+
{"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],iframe", "timeout": 10},
|
|
72
|
+
{"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
|
|
73
|
+
{"do": "shot", "name": "chapter-filled-draft"},
|
|
74
|
+
{"do": "click_real", "text": "存草稿", "tries": 25},
|
|
75
|
+
{"do": "sleep", "s": 2},
|
|
76
|
+
{"do": "verify", "url": "{draft_url}", "any": ["{chapter_title}"], "settle": 5},
|
|
77
|
+
]
|
|
78
|
+
|
|
63
79
|
CHECK_LOGIN = [
|
|
64
80
|
{"do": "navigate", "url": "{home}"},
|
|
65
81
|
{"do": "url_any", "any": ["qimao.com"]},
|
|
@@ -73,6 +89,7 @@ PROBE_FORM = [
|
|
|
73
89
|
]
|
|
74
90
|
|
|
75
91
|
FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
|
|
92
|
+
"upload_chapter_draft": UPLOAD_CHAPTER_DRAFT,
|
|
76
93
|
"check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
|
|
77
94
|
|
|
78
95
|
|
package/app/core/quality_gate.py
CHANGED
|
@@ -127,6 +127,13 @@ def evaluate_release(verdict, action="publish", target_chapter=None,
|
|
|
127
127
|
if action == "continue":
|
|
128
128
|
return {"allowed": True, "status": "continue_only", "forced": False,
|
|
129
129
|
"blockers": [], "warnings": []}
|
|
130
|
+
if action == "draft":
|
|
131
|
+
# 存草稿不是发布:内容不公开展示(读者不可见),正式提交前用户还会
|
|
132
|
+
# 在平台上逐章过目——质量闸只在「发布/签约」这类上线动作上拦,
|
|
133
|
+
# 拦草稿只会把「先落平台再人工把关」的通路堵死(2026-10-09 全部
|
|
134
|
+
# 发草稿需求:质量未达标的 53 章先存草稿,发布仍受本闸约束)。
|
|
135
|
+
return {"allowed": True, "status": "draft_only", "forced": False,
|
|
136
|
+
"blockers": [], "warnings": []}
|
|
130
137
|
|
|
131
138
|
blockers = []
|
|
132
139
|
warnings = []
|
package/app/core/runner.py
CHANGED
|
@@ -73,18 +73,27 @@ _CLI_EXTS = (".exe", ".cmd", ".bat")
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
def _fallback_probe_cli(name):
|
|
76
|
-
"""which() 落空时按常见安装位兜底找 CLI
|
|
76
|
+
"""which() 落空时按常见安装位兜底找 CLI,返回全路径或 None。
|
|
77
77
|
|
|
78
78
|
服务进程的 PATH 是启动那一刻的快照:CLI 在服务起跑之后才安装/迁移(如
|
|
79
79
|
2026-09-30 claude 转 winget 原生安装)时,快照里没有新目录,which 解析
|
|
80
80
|
不到、裸名直传 Popen 直接 FileNotFoundError,直到重启服务才自愈。这里
|
|
81
81
|
只探测磁盘已知落点,不改 PATH、不缓存(装没装是低频事件,探几次不亏)。
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return None
|
|
82
|
+
Mac 同病:curl 脚本装的 uv/aider 落 ~/.local/bin,GUI 启动的服务常吃不到
|
|
83
|
+
这条 PATH;brew 在 Apple Silicon 落 /opt/homebrew/bin。"""
|
|
85
84
|
name = str(name)
|
|
86
85
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name):
|
|
87
86
|
return None # 带斜杠/参数形态的不是裸命令名,交给原路径逻辑
|
|
87
|
+
if os.name != "nt":
|
|
88
|
+
for d in (os.path.join(os.path.expanduser("~"), ".local", "bin"),
|
|
89
|
+
"/opt/homebrew/bin", "/usr/local/bin"):
|
|
90
|
+
p = os.path.join(d, name)
|
|
91
|
+
try:
|
|
92
|
+
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
93
|
+
return p
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
return None
|
|
88
97
|
local = os.environ.get("LOCALAPPDATA") or ""
|
|
89
98
|
home = os.path.expanduser("~")
|
|
90
99
|
appdata = os.environ.get("APPDATA") or os.path.join(home, "AppData", "Roaming")
|
package/app/main.py
CHANGED
|
@@ -2263,7 +2263,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
2263
2263
|
"""批量发布全部待发章节(publish/auto.py)。
|
|
2264
2264
|
|
|
2265
2265
|
护栏(每日上限/连败退避/幂等/单飞)在 auto 层,每章发起前复查;
|
|
2266
|
-
auto_submit 默认 false——每章表单填好后停,提交权留给用户。
|
|
2266
|
+
auto_submit 默认 false——每章表单填好后停,提交权留给用户。
|
|
2267
|
+
as_draft=true 走「全部发草稿」:逐章存草稿不上线、不受质量闸拦、
|
|
2268
|
+
无需人工确认连跑到清空(2026-10-09);草稿与直发互斥,草稿优先。"""
|
|
2267
2269
|
from core import store
|
|
2268
2270
|
from core.publish import auto as pub_auto
|
|
2269
2271
|
if not store.get_task(task_id):
|
|
@@ -2276,7 +2278,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
2276
2278
|
force_confirmed = bool(body.get("force_confirmed"))
|
|
2277
2279
|
force_reason = str(body.get("force_reason") or "").strip()
|
|
2278
2280
|
only = body.get("chapters") # 批量选择发布:章号数组(可省=全部待发)
|
|
2279
|
-
kwargs = {"only": only if isinstance(only, list) else None
|
|
2281
|
+
kwargs = {"only": only if isinstance(only, list) else None,
|
|
2282
|
+
"as_draft": bool(body.get("as_draft"))}
|
|
2280
2283
|
if force or force_confirmed or force_reason:
|
|
2281
2284
|
kwargs.update(force=force, force_confirmed=force_confirmed,
|
|
2282
2285
|
force_reason=force_reason)
|