codebee 0.1.98 → 0.1.100

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.
@@ -51,6 +51,15 @@ def document():
51
51
  "items": {"type": "string", "maxLength": 80}},
52
52
  "task_id": {"type": "string", "maxLength": 100}, "status": {"type": "string", "maxLength": 30},
53
53
  "metadata": {"type": "object"}}},
54
+ "BackendProfile": {"type": "object", "required": ["label", "command"], "properties": {
55
+ "id": {"type": "string"}, "agent_id": {"type": "string"},
56
+ "label": {"type": "string", "maxLength": 80}, "command": {"type": "string", "maxLength": 500},
57
+ "args": {"type": "array", "maxItems": 80, "items": {"type": "string"}},
58
+ "env": {"type": "object", "additionalProperties": {"type": "string"}},
59
+ "workspace_path": {"type": "string"}, "enabled": {"type": "boolean"}}},
60
+ "CommandAuth": {"type": "object", "properties": {
61
+ "mode": {"type": "string", "enum": ["legacy", "restricted"]},
62
+ "pending": {"type": "array"}, "rules": {"type": "array"}, "audit": {"type": "array"}}},
54
63
  }
55
64
 
56
65
  def body(schema):
@@ -106,6 +115,29 @@ def document():
106
115
  "post": {"summary": "Agent 心跳", "security": security, "requestBody": body(ref("Presence")),
107
116
  "responses": {"200": response("心跳已记录", {"type": "object"}), "400": errors["400"],
108
117
  "401": errors["401"], "423": errors["423"]}}},
118
+ "/api/backend-profiles": {
119
+ "get": {"summary": "读取 ACP Backend Profiles(密钥脱敏)", "security": security,
120
+ "responses": {"200": response("Profiles", {"type": "object"}), "401": errors["401"]}},
121
+ "post": {"summary": "保存 ACP Backend Profile", "security": security,
122
+ "requestBody": body(ref("BackendProfile")),
123
+ "responses": {"200": response("Profile", {"type": "object"}), "400": errors["400"], "401": errors["401"]}}},
124
+ "/api/backend-profiles/{profile_id}/delete": {
125
+ "post": {"summary": "删除 ACP Backend Profile", "security": security,
126
+ "responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"]}}},
127
+ "/api/command-auth": {
128
+ "get": {"summary": "读取命令授权策略与待处理请求", "security": security,
129
+ "responses": {"200": response("Authorization state", ref("CommandAuth")), "401": errors["401"]}},
130
+ "post": {"summary": "切换命令授权策略", "security": security,
131
+ "requestBody": body({"type": "object", "properties": {"mode": {"type": "string", "enum": ["legacy", "restricted"]}}}),
132
+ "responses": {"200": response("Authorization state", ref("CommandAuth")), "400": errors["400"], "401": errors["401"]}}},
133
+ "/api/command-auth/requests/{request_id}": {
134
+ "post": {"summary": "批准或拒绝等待中的命令", "security": security,
135
+ "requestBody": body({"type": "object", "required": ["approve"], "properties": {
136
+ "approve": {"type": "boolean"}, "scope": {"type": "string"}, "match": {"type": "string"}, "prefix": {"type": "string"}}}),
137
+ "responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"]}}},
138
+ "/api/command-auth/rules/{rule_id}/revoke": {
139
+ "post": {"summary": "撤销命令授权规则", "security": security,
140
+ "responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"]}}},
109
141
  "/api/retrieval/search": {"get": {"summary": "本地检索", "security": security,
110
142
  "parameters": [{"name": "q", "in": "query", "required": True,
111
143
  "schema": {"type": "string", "maxLength": 500}},
@@ -596,6 +596,16 @@ def _external_sandbox_block_reason(agent, workdir, sandbox):
596
596
  if normalized.get("env_allowlist"):
597
597
  return "沙箱拒绝:该 CLI 后端未接入环境变量白名单,拒绝运行受限任务"
598
598
  return ""
599
+ if (agent or {}).get("kind") == "acp":
600
+ if normalized.get("network") is False:
601
+ return "沙箱拒绝:ACP 后端进程未接入网络隔离,拒绝运行网络受限任务"
602
+ if normalized.get("env_allowlist"):
603
+ return "沙箱拒绝:ACP 后端进程未接入环境变量白名单,拒绝运行受限任务"
604
+ roots = [os.path.realpath(x) for x in normalized.get("allowed_roots") or []]
605
+ if roots and not any(root == allowed or root.startswith(allowed + os.sep)
606
+ for allowed in roots):
607
+ return "沙箱拒绝:任务目录不在 ACP 允许目录内"
608
+ return ""
599
609
  # 非 codex 外部 CLI:默认策略放行,收窄策略拒绝
600
610
  if normalized.get("network") is False:
601
611
  return "沙箱拒绝:该任务要求禁网,外部 CLI 无法执行网络受限任务"
@@ -628,14 +638,16 @@ def _create_task_from_builtin(payload):
628
638
 
629
639
 
630
640
  def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=None,
631
- followups=False):
641
+ followups=False, sandbox=None):
632
642
  """内置智能体步骤:直连模型 API + 工具循环(builtin_agent),不经 CLI 进程。
633
643
 
634
644
  与 _run_step 对齐的三件事:暂停/取消闸门、运行中指令 drain 注入、重复调用
635
645
  守门;结果同样经 _finish_step_result 落步骤(output=干净回答)并入用量台账。
636
646
  日志只有「迭代/工具」摘要行——对话视图吃 output,日志抽屉看工具轨迹。
637
647
  followups=True 时从回答末尾解析「建议追问」块(直连对话专用协议):
638
- 剥离出结构化列表落步骤记录,正文保持干净。"""
648
+ 剥离出结构化列表落步骤记录,正文保持干净。
649
+ sandbox:任务沙箱策略由调用方透传(本函数无 task 上下文),builtin_agent
650
+ 内部 normalize 后在工具执行期强制。"""
639
651
  _wait_gate(run_id, ev)
640
652
  deadline = _ensure_budget(run_id)
641
653
  step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note,
@@ -689,7 +701,7 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
689
701
  on_reason=_on_reason, on_stream=_on_stream,
690
702
  on_activity=_on_activity,
691
703
  task_creator=_create_task_from_builtin,
692
- sandbox=task.get("sandbox"), checkpoint_run_id=run_id)
704
+ sandbox=sandbox, checkpoint_run_id=run_id)
693
705
  if followups and res.get("ok"):
694
706
  clean, fups = _parse_followups(res.get("text") or "")
695
707
  if fups:
@@ -816,7 +828,13 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
816
828
  llm_caller = _make_llm_caller(agent, workdir, deadline=deadline)
817
829
  call_kwargs = dict(workdir=workdir, readonly=readonly,
818
830
  timeout=timeout, cancel_event=ev, log_path=str(log_abs),
819
- images=images, require_tools=require_tools, deadline=deadline)
831
+ images=images, require_tools=require_tools, deadline=deadline,
832
+ command_context={"run_id": session_run_id,
833
+ "sandbox": (store.get_task(
834
+ (store.get_run(session_run_id) or {}).get("task_id")
835
+ ) or {}).get("sandbox") or {},
836
+ "allow_terminal": not readonly},
837
+ checkpoint_run_id=session_run_id)
820
838
 
821
839
  def _call(p, **kw):
822
840
  nonlocal usage_recorded
@@ -844,7 +862,13 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
844
862
  res = runner.run_agent(agent, prompt, workdir=workdir, readonly=readonly,
845
863
  timeout=timeout, cancel_event=ev, log_path=str(log_abs),
846
864
  resume=resume, images=images, require_tools=require_tools,
847
- deadline=deadline)
865
+ deadline=deadline,
866
+ command_context={"run_id": session_run_id,
867
+ "sandbox": (store.get_task(
868
+ (store.get_run(session_run_id) or {}).get("task_id")
869
+ ) or {}).get("sandbox") or {},
870
+ "allow_terminal": not readonly},
871
+ checkpoint_run_id=session_run_id)
848
872
  # 默认关闭压缩和 resume 都走直通分支,也必须把真实 usage 送进预算表;否则
849
873
  # 下一步永远看到 used=0,max_tokens_per_run 只是一个无效设置。
850
874
  if not usage_recorded:
@@ -1559,7 +1583,8 @@ def _run_code(run, task, agents, ev, stats, mode):
1559
1583
 
1560
1584
  # 项目记忆既给规划器,也给快速路径的实现者;不能因省掉独立规划而漏掉。
1561
1585
  user_context_len = len(task.get("context") or "")
1562
- project_memory = _read_project_memory(workdir)
1586
+ project_memory = _read_project_memory(
1587
+ workdir, query=task.get("goal") or task.get("title") or "")
1563
1588
  if project_memory:
1564
1589
  task = dict(task, context=(task.get("context") or "") + "\n\n" + project_memory)
1565
1590
 
@@ -2334,7 +2359,7 @@ def _run_direct(run, task, agents, ev, stats, mode):
2334
2359
  if bi is not None:
2335
2360
  res = _run_builtin_step(run_id, "direct" if first else "chat", bi, prompt,
2336
2361
  step_wd, ev=ev, note=note, images=images,
2337
- followups=True)
2362
+ followups=True, sandbox=task.get("sandbox"))
2338
2363
  else:
2339
2364
  res = _run_step(run_id, "direct" if first else "chat", impl, prompt, step_wd,
2340
2365
  readonly=False, ev=ev, note=note,
@@ -5380,11 +5405,11 @@ def _write_project_memory(task, workdir, lines, source_run=""):
5380
5405
  return ""
5381
5406
 
5382
5407
 
5383
- def _read_project_memory(workdir, cap=4000):
5408
+ def _read_project_memory(workdir, cap=4000, query=""):
5384
5409
  """Only inject approved, unexpired and fingerprint-verified memory."""
5385
5410
  try:
5386
5411
  from . import project_memory
5387
- return project_memory.active_text(workdir, cap=cap)
5412
+ return project_memory.active_text(workdir, cap=cap, query=query)
5388
5413
  except (OSError, ValueError, TypeError):
5389
5414
  return ""
5390
5415
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import json
7
7
  import os
8
+ import re
8
9
  import stat
9
10
  import time
10
11
  from pathlib import Path
@@ -95,6 +96,8 @@ def list_entries(workdir, *, now=None):
95
96
  raw = row.get("content") or ""
96
97
  row["verified"] = hashlib.sha256(raw.encode("utf-8")).hexdigest() == row.get("content_sha256")
97
98
  row["expired"] = float(row.get("expires_at") or 0) <= stamp
99
+ row["feedback_score"] = int(row.get("feedback_score") or 0)
100
+ row["feedback_count"] = int(row.get("feedback_count") or 0)
98
101
  return sorted(rows, key=lambda x: (x.get("created_at", 0), x.get("id", "")), reverse=True)
99
102
 
100
103
 
@@ -126,14 +129,78 @@ def decide(workdir, entry_id, action, *, expected_version=None, now=None):
126
129
  return row
127
130
 
128
131
 
129
- def active_text(workdir, *, cap=4000, now=None):
132
+ def feedback(workdir, entry_id, *, useful, expected_version=None, now=None):
133
+ """Record explicit retrieval feedback as a versioned append-only update."""
134
+ if not isinstance(useful, bool):
135
+ raise ValueError("useful 必须是布尔值")
136
+ path = _file(workdir)
137
+ row = _read(path).get(str(entry_id or ""))
138
+ if not row:
139
+ raise KeyError("记忆条目不存在")
140
+ if expected_version is not None and int(expected_version) != int(row.get("version") or 1):
141
+ raise ValueError("记忆条目版本已变化,请刷新后重试")
142
+ raw = row.get("content") or ""
143
+ if hashlib.sha256(raw.encode("utf-8")).hexdigest() != row.get("content_sha256"):
144
+ raise ValueError("记忆内容指纹校验失败")
145
+ if row.get("status") != "approved":
146
+ raise ValueError("只有已批准的记忆条目可以反馈")
130
147
  stamp = float(now if now is not None else time.time())
148
+ row = dict(row, feedback_score=int(row.get("feedback_score") or 0) +
149
+ (1 if useful else -1),
150
+ feedback_count=int(row.get("feedback_count") or 0) + 1,
151
+ feedback_at=stamp, version=int(row.get("version") or 1) + 1,
152
+ updated_at=stamp)
153
+ _append(path, row)
154
+ return row
155
+
156
+
157
+ def _query_terms(query):
158
+ query = str(query or "").strip().lower()
159
+ words = re.findall(r"[a-z0-9_]+|[\u3400-\u9fff]+", query)
160
+ terms = set()
161
+ for word in words:
162
+ if len(word) > 2 and re.fullmatch(r"[\u3400-\u9fff]+", word):
163
+ terms.update(word[i:i + 2] for i in range(len(word) - 1))
164
+ else:
165
+ terms.add(word)
166
+ return terms
167
+
168
+
169
+ def _relevance(row, terms):
170
+ if not terms:
171
+ return 0
172
+ title = str(row.get("title") or "").lower()
173
+ content = str(row.get("content") or "").lower()
174
+ title_terms = _query_terms(title)
175
+ content_terms = _query_terms(content)
176
+ return 4 * len(terms & title_terms) + len(terms & content_terms)
177
+
178
+
179
+ def active_text(workdir, *, cap=4000, query="", now=None):
180
+ stamp = float(now if now is not None else time.time())
181
+ try:
182
+ cap = max(0, int(cap))
183
+ except (TypeError, ValueError):
184
+ cap = 4000
185
+ heading = "## 已批准项目记忆(内容已校验,过期条目不注入)\n\n"
186
+ cap = max(0, cap - len(heading) - 2)
187
+ terms = _query_terms(query)
131
188
  items = []
132
189
  for row in list_entries(workdir, now=stamp):
133
190
  if row.get("status") != "approved" or row.get("expired") or not row.get("verified"):
134
191
  continue
135
- items.append("### %s\n%s" % (row.get("title") or "项目记忆", row.get("content") or ""))
136
- text = "\n\n".join(items)
137
- if len(text) > cap:
138
- text = text[-cap:]
139
- return ("## 已批准项目记忆(内容已校验,过期条目不注入)\n\n" + text + "\n\n") if text else ""
192
+ block = "### %s\n%s" % (row.get("title") or "项目记忆", row.get("content") or "")
193
+ items.append((row, block))
194
+ items.sort(key=lambda item: (_relevance(item[0], terms),
195
+ item[0].get("feedback_score", 0),
196
+ item[0].get("created_at", 0)), reverse=True)
197
+ selected = []
198
+ used = 0
199
+ for _row, block in items:
200
+ cost = len(block) + (2 if selected else 0)
201
+ if used + cost > cap:
202
+ continue
203
+ selected.append(block)
204
+ used += cost
205
+ text = "\n\n".join(selected)
206
+ return (heading + text + "\n\n") if text else ""
@@ -135,7 +135,9 @@ def pending(task_id, platform):
135
135
  """待发章节清单:任务工作目录里的章节文件 − 已发章号,按章号升序。
136
136
 
137
137
  已发口径连载链合并(含校准所得平台实况),所以任何一个批次任务上发
138
- 起发布,看到的都是整本书的待发清单。返回 (list, err);list 项
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
- ok, why = guards(task_id, plat)
201
+ hard_ok, hard_why = guards(task_id, plat)
194
202
  if not _book_ready(info):
195
- ok, why = False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
196
- elif task:
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
- ok, why = False, "质量门禁拦截:%s" % ";".join(
200
- quality.get("blockers") or [])
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 auto_submit and not calibrated(platform):
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
- quality = manager._quality_release_guard(
275
- task, platform, action="publish", force=force,
276
- force_confirmed=force_confirmed, force_reason=force_reason)
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), "last_chapter": 0}
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
- st["error"] = ("第 %d 章发布等待超时(%.0f 分钟);若在等人工提交,"
328
- "请提交后重跑剩余章节" % (item["chapter_no"],
329
- IDLE_TIMEOUT_S / 60))
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 not auto_submit:
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",