codebee 0.1.66 → 0.1.67

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.
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import aiflavor, attachments, branching, catalog, chaptersafe, dispatch_log, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
22
+ from . import aiflavor, attachments, branching, catalog, chaptersafe, defectretro, dispatch_log, flows, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
23
23
  from . import builtin_agent
24
24
  from . import diagnostics
25
25
  from . import paths as paths_mod
@@ -365,6 +365,44 @@ def _binding_dead_msg(agent):
365
365
  "请在「模型调度(可选)」页为该 CLI 指定已启用的供应商")
366
366
 
367
367
 
368
+ def _binding_brief(agent):
369
+ """步骤级绑定速记(借鉴 WorkDSH ResolvedExecutionBinding:模型目录只是发现
370
+ 信息,解析结果才是权威)。把本步实际执行用的 provider 链与上游 host 压成
371
+ 一行——配额烧错家/模型漂移直接对账步骤行,不必翻日志文本里的审计行。
372
+ 只记身份不记密钥:env 形态的注入也只取 URL 的 host 部分。"""
373
+ from . import upstream as _up
374
+ entries = []
375
+ for item in (agent.get("call_chain") or [])[:4]:
376
+ if not isinstance(item, dict):
377
+ continue
378
+ pid = str(item.get("provider_id") or "").strip()
379
+ prov = item.get("provider") if isinstance(item.get("provider"), dict) else {}
380
+ host = ""
381
+ try:
382
+ host = _up.normalize_upstream(
383
+ str(prov.get("base_url") or prov.get("baseUrl") or ""))
384
+ except Exception:
385
+ host = ""
386
+ if pid and host and host not in pid:
387
+ entries.append("%s@%s" % (pid, host))
388
+ else:
389
+ entries.append(pid or host or "?")
390
+ if not entries:
391
+ # env 注入形态(如 ANTHROPIC_BASE_URL):URL 值的 host 不敏感,可入账
392
+ for k, v in sorted((agent.get("env") or {}).items()):
393
+ s = str(v or "")
394
+ if s.startswith(("http://", "https://")):
395
+ try:
396
+ host = _up.normalize_upstream(s)
397
+ except Exception:
398
+ continue
399
+ if host:
400
+ entries.append("%s=%s" % (str(k)[:24], host))
401
+ if len(entries) >= 3:
402
+ break
403
+ return ",".join(entries[:3])[:120]
404
+
405
+
368
406
  def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner.DEFAULT_TIMEOUT, note="", resume=None, images=None, require_tools=False, swap_guard=None):
369
407
  """执行一个智能体步骤并记录。返回 runner 统一结果。
370
408
 
@@ -402,7 +440,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
402
440
  note = ((note + ";") if note else "") + "⚠ " + dead_msg
403
441
  step, log_abs = store.add_step(run_id, role, agent["id"],
404
442
  agent.get("label", agent["id"]), note=note,
405
- model=agent.get("model") or "")
443
+ model=agent.get("model") or "",
444
+ provider=_binding_brief(agent))
406
445
  start = time.time()
407
446
  if dead_binding:
408
447
  from .error_codes import ErrorCode
@@ -473,7 +512,8 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
473
512
  _wait_gate(run_id, ev)
474
513
  deadline = _ensure_budget(run_id)
475
514
  step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note,
476
- model=bi.get("model") or "")
515
+ model=bi.get("model") or "",
516
+ provider=str(bi.get("provider_id") or "")[:120])
477
517
  start = time.time()
478
518
  agent_pseudo = {"id": "builtin", "label": "CodeBee", "kind": "builtin", "mode": "real",
479
519
  "provider": {"id": bi.get("provider_id") or "",
@@ -650,9 +690,15 @@ def _finish_step_result(run_id, step, res, role, agent, start):
650
690
  status = "timeout"
651
691
  else:
652
692
  status = "done" if res["ok"] else "failed"
693
+ step_summary = (res.get("text") or res.get("error") or "")[:600]
694
+ if raw.get("quiescence_unknown"):
695
+ # 杀树未确认清空(借鉴 WorkDSH 取消结算分层:kill 完成 ≠ 停稳):
696
+ # 孙进程可能仍在跑,孤儿清扫稍后兜底。只提示不改变终态语义。
697
+ step_summary = ((step_summary + "\n") if step_summary else "") + \
698
+ "⚠ 进程树未确认清空:孙进程可能仍在运行(孤儿清扫稍后兜底)"
653
699
  store.finish_step(run_id, step["n"],
654
700
  status,
655
- summary=((res.get("text") or res.get("error") or "")[:600]),
701
+ summary=step_summary,
656
702
  exit_code=res.get("raw", {}).get("exit_code"),
657
703
  cost_usd=res.get("cost_usd", 0.0),
658
704
  tokens=res.get("tokens", 0),
@@ -1915,6 +1961,10 @@ def _run_direct(run, task, agents, ev, stats, mode):
1915
1961
  # 扫榜选材(借鉴 oh-story 扫榜):抓七猫排行榜公开数据注入,
1916
1962
  # AI 做选题洞察;抓取失败回落普通直连提示词
1917
1963
  prompt = paihang.rank_scan_prompt(task.get("goal") or "") or ""
1964
+ if task.get("type") == "defect_retro" and bi is not None:
1965
+ # 缺陷复盘(借鉴 test-defect-retrospective):三视角分析框架
1966
+ # 注入,缺陷导出 CSV 走任务附件通道(框架恒有、不回落)
1967
+ prompt = defectretro.retro_prompt(task.get("goal") or "")
1918
1968
  if not prompt:
1919
1969
  if bi is not None:
1920
1970
  prompt = (BUILTIN_DIRECT_PROMPT
@@ -2144,10 +2194,10 @@ NOVEL_CRITIQUE_PROMPT = """你是严格的评审(不要使用任何工具、
2144
2194
  请输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
2145
2195
  {
2146
2196
  "scores": {"__DIMKEYS__"},
2147
- "issues": [{"dim": "维度名", "severity": "major|minor", "note": "具体问题"}],
2197
+ "issues": [{"dim": "维度名", "severity": "major|minor", "note": "具体问题", "quote": "支撑该问题的稿件原文连续片段(≥8字,逐字摘录不许改写)"}],
2148
2198
  "summary": "一句话总评"
2149
2199
  }
2150
- 每个维度打 1-10 分(可为小数),宁严勿宽。
2200
+ 每个维度打 1-10 分(可为小数),宁严勿宽。major 问题必须给 quote(系统会逐条校验引文是否真在稿件中——编造的引文会被降档标记)。
2151
2201
 
2152
2202
  ## 待评审稿件
2153
2203
  ---
@@ -2260,6 +2310,18 @@ def _read_ledger(workdir):
2260
2310
  return txt or "(暂无记录)"
2261
2311
 
2262
2312
 
2313
+ def _read_ledger_raw(workdir):
2314
+ """账本原文(缺文件/读失败返回空串)——供 _ledger_watchlist 解析,
2315
+ 不带注入面的兜底文案。"""
2316
+ p = os.path.abspath(os.path.join(str(workdir or ""), LEDGER_FILE))
2317
+ if not _inside(workdir, p) or not os.path.isfile(p):
2318
+ return ""
2319
+ try:
2320
+ return _read_text_any_enc(p)
2321
+ except OSError:
2322
+ return ""
2323
+
2324
+
2263
2325
  def _shrink_context_block(sk_block, bible, budget=12000):
2264
2326
  """分层上下文降级(长提示词在容量受限通道上会挂起/秒拒,2026-09-17 讯飞实测)。
2265
2327
 
@@ -2369,10 +2431,11 @@ SERIAL_GLOBAL_PROMPT = """你是网文主编(不要修改任何文件)。全
2369
2431
  请输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
2370
2432
  {
2371
2433
  "scores": {"__DIMKEYS__"},
2372
- "issues": [{"dim": "维度名", "severity": "major|minor", "note": "具体问题(指明哪一章)"}],
2434
+ "issues": [{"dim": "维度名", "severity": "major|minor", "note": "具体问题(指明哪一章)", "quote": "支撑该问题的稿件原文连续片段(≥8字,逐字摘录不许改写)"}],
2373
2435
  "summary": "一句话总评:是否达到可签约水平"
2374
2436
  }
2375
2437
  每个维度打 1-10 分,宁严勿宽。重点关注:主线一致性、人物弧光、节奏、爽点密度、完本感。
2438
+ major 问题必须给 quote(系统会逐条校验引文是否真在稿件中——编造的引文会被降档标记)。
2376
2439
 
2377
2440
  ## 全书目标
2378
2441
  __GOAL__
@@ -2446,6 +2509,87 @@ def _wc(text):
2446
2509
  return len(re.sub(r"\s", "", text or ""))
2447
2510
 
2448
2511
 
2512
+ _ANCHOR_EDGE_PUNCT = "。!?,、;:…—·,.!?;:\"'“”‘’()()[]【】《》<>*#-"
2513
+
2514
+
2515
+ def _norm_anchor(s):
2516
+ """锚校验归一:去全部空白 + 剥首尾标点(引文摘录常多带一个句号/引号,
2517
+ 中英混排空格差异也不应判假)。"""
2518
+ s = re.sub(r"\s+", "", str(s or ""))
2519
+ return s.strip(_ANCHOR_EDGE_PUNCT)
2520
+
2521
+
2522
+ def _anchor_issues(issues, manuscript):
2523
+ """评审证据锚(ARIS Anti-Autoresearch 借鉴):issue 带 quote(稿件原文
2524
+ 摘录)时做确定性 containment 校验——模型只提议发现,锚定事实由规则裁决。
2525
+
2526
+ 三态:quote 命中稿件 → anchored=True;quote 给了但对不上 → anchored=False
2527
+ (降档殿后+标记,不删除:模型改写式引用时保留可见性);没给 quote →
2528
+ anchored=None(老格式评审灰度兼容,不标记不排序影响)。返回 (issues, 未锚定数)。"""
2529
+ ms = _norm_anchor(manuscript)
2530
+ unanchored = 0
2531
+ for it in issues or []:
2532
+ q = _norm_anchor(it.get("quote"))
2533
+ if not q:
2534
+ it["anchored"] = None
2535
+ continue
2536
+ it["anchored"] = len(q) >= 6 and q in ms
2537
+ if it["anchored"] is False:
2538
+ unanchored += 1
2539
+ return issues, unanchored
2540
+
2541
+
2542
+ def _major_lines(majors, limit=8):
2543
+ """majors → 修订面意见行:锚定的在前,未锚定殿后并带标记(模型据此
2544
+ 分配采信权重);无 quote 的老格式行居中不动。"""
2545
+ ranked = sorted(majors or [],
2546
+ key=lambda x: {True: 0, False: 2}.get(x.get("anchored"), 1))
2547
+ lines = []
2548
+ for x in ranked[:limit]:
2549
+ mark = ("(未锚定:引文与稿件不符,酌情采信)"
2550
+ if x.get("anchored") is False else "")
2551
+ lines.append("- [%s] %s%s" % (x.get("dim", "?"),
2552
+ str(x.get("note", ""))[:140], mark))
2553
+ return lines
2554
+
2555
+
2556
+ _LEDGER_DONE_WORDS = ("已兑现", "已回收", "已解除", "已完成", "已了结", "已交付")
2557
+
2558
+
2559
+ def _ledger_watchlist(ledger_text, upto_chapter, min_age=3, limit=8):
2560
+ """陈年承诺督促(continuum 承诺/伏笔账借鉴):按「## 第 N 章」段解析
2561
+ 资源账本,承诺/伏笔类条目现状未了结、且距本章 ≥min_age 章未更新 →
2562
+ 观查清单(推送起草面督促推进/回收)。账本按章追加、同名条目以最后
2563
+ 出现的章为准;老账本无该类条目返回空。"""
2564
+ last = {} # (类型, 名称) -> (最后章号, 现状)
2565
+ cur = 0
2566
+ for ln in str(ledger_text or "").splitlines():
2567
+ m = re.match(r"##\s*第\s*(\d+)\s*章", ln.strip())
2568
+ if m:
2569
+ cur = int(m.group(1))
2570
+ continue
2571
+ s = ln.strip().lstrip("-*").strip()
2572
+ if not s or "|" not in s:
2573
+ continue
2574
+ parts = [p.strip() for p in s.split("|")]
2575
+ if len(parts) < 3:
2576
+ continue
2577
+ typ, name, status = parts[0], parts[1], "|".join(parts[2:])
2578
+ if "承诺" not in typ and "伏笔" not in typ:
2579
+ continue
2580
+ last[(typ, name)] = (cur, status)
2581
+ out = []
2582
+ for (typ, name), (ch_no, status) in last.items():
2583
+ if any(w in status for w in _LEDGER_DONE_WORDS):
2584
+ continue
2585
+ age = upto_chapter - ch_no
2586
+ if age >= min_age:
2587
+ out.append({"type": typ, "name": name, "status": status,
2588
+ "age": age, "last_chapter": ch_no})
2589
+ out.sort(key=lambda x: -x["age"])
2590
+ return out[:limit]
2591
+
2592
+
2449
2593
  def _write_chapter(workdir, i, text):
2450
2594
  # 安全落盘委托(inkos 安全章节工作区借鉴):tmp+原子改名,写一半
2451
2595
  # 崩溃不留半章冒充成稿;路径守卫在 chaptersafe(resolve+parents)
@@ -2702,6 +2846,19 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2702
2846
  ev_check_lines = [[]] # 每章重置:第一份非空评审的逐项判定
2703
2847
  ledger_lines = [] # 本章全部评审的账本增量并集
2704
2848
  ledger_txt = _read_ledger(workdir) # 截至上一章的资源账本(起草注入)
2849
+ # 陈年承诺督促(continuum 借鉴):账本里 ≥3 章未推进且未了结的承诺/
2850
+ # 伏笔点名给起草面——「本章推进/回收其一,或明确有意留白」;老账本
2851
+ # 无承诺类条目时静默不占字节
2852
+ try:
2853
+ _wl = _ledger_watchlist(_read_ledger_raw(workdir), i)
2854
+ if _wl:
2855
+ ledger_txt += ("\n\n### ⚠ 陈年承诺/伏笔(多章未推进)\n"
2856
+ + "\n".join("- %s「%s」:%s(已 %d 章未更新——本章应推进、"
2857
+ "回收其一,或在大纲允许处明确留白)"
2858
+ % (w["type"], w["name"], w["status"], w["age"])
2859
+ for w in _wl))
2860
+ except Exception:
2861
+ pass
2705
2862
  prev = ""
2706
2863
  draft_sid = "" # §07 T1.1:本轮 draft/复用章的会话 id(revise 复用;reuse 时为空)
2707
2864
  if i > 1:
@@ -3234,9 +3391,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3234
3391
  rounds_used = 2
3235
3392
  majors = [x for x in issues_all if x.get("chapter") == i
3236
3393
  and x.get("severity") == "major"][:8]
3394
+ majors, _unanchored = _anchor_issues(majors, text)
3237
3395
  crit_lines = ["- %s:%.1f(章阈值 %.1f)" % (d, means[d], threshold_ch) for d in dims]
3238
- crit_lines += ["- [%s] %s" % (x.get("dim", "?"), str(x.get("note", ""))[:140])
3239
- for x in majors]
3396
+ crit_lines += _major_lines(majors)
3397
+ if _unanchored:
3398
+ crit_lines.append("- ⚠ %d 条 major 意见的引文未能在稿件中锚定,已降序殿后" % _unanchored)
3240
3399
  if rnd == 1 and race_losers and impl.get("mode") == "real":
3241
3400
  # 赛马败者精华回收(连载版):一次选择器调用提炼落选稿优点,
3242
3401
  # 与评审意见一并喂给首轮修订;失败静默不影响修订
@@ -3962,8 +4121,10 @@ def _run_content_review(run, task, agents, ev, stats, mode):
3962
4121
  # 修订
3963
4122
  crit_lines = ["- %s:%.1f(阈值 %.1f)" % (d, means[d], threshold) for d in dims]
3964
4123
  majors = [i for i in issues_all if i.get("severity") == "major"][:8]
3965
- for i in majors:
3966
- crit_lines.append("- [%s] %s" % (i.get("dim", "?"), str(i.get("note", ""))[:120]))
4124
+ majors, _unanchored = _anchor_issues(majors, manuscript)
4125
+ crit_lines += _major_lines(majors)
4126
+ if _unanchored:
4127
+ crit_lines.append("- ⚠ %d 条 major 意见的引文未能在稿件中锚定,已降序殿后" % _unanchored)
3967
4128
  if r == 1 and bestof_improvements:
3968
4129
  # 败者精华回收(freebuff suggestedImprovements 的落地):终审从落选
3969
4130
  # 候选稿提炼的优点,首轮修订时与评审意见一并喂给作者
@@ -4387,6 +4548,39 @@ def execute_run(run_id):
4387
4548
  store.update_run(run_id, expected_status="running", status="failed",
4388
4549
  error="找不到任务 %s" % run.get("task_id"), ended_at=_now())
4389
4550
  return
4551
+ # 流程修订漂移诊断(借鉴 WorkDSH ADR-0010 修订钉住):任务参数创建时已
4552
+ # 固化,改流程不影响固化字段;但历史任务缺失字段的兜底仍取当前流程定义,
4553
+ # 续跑/重试可能静默吃到新流程。漂移不阻断——「改完流程再重跑」是合法
4554
+ # 路径——但必须留痕可对账:run 记结构化 flow_drift + 用户可见 warning。
4555
+ _drift = flows.flow_drift(task)
4556
+ if _drift:
4557
+ store.update_run(run_id, flow_drift=_drift)
4558
+ try:
4559
+ _warns = (store.get_run(run_id) or {}).get("warnings") or []
4560
+ store.update_run(run_id, warnings=_warns + [
4561
+ "流程「%s」在任务创建后已修改(%s → %s);任务固化参数不受影响,"
4562
+ "历史任务缺失字段的兜底取值已随新定义"
4563
+ % (_drift["flow"], _drift["pinned"][:8], _drift["current"][:8])])
4564
+ except Exception:
4565
+ pass
4566
+ print("[CodeBee] 流程漂移:%s(任务 %s 创建后流程已修改 %s → %s)"
4567
+ % (run_id, task.get("id"), _drift["pinned"][:8], _drift["current"][:8]),
4568
+ flush=True)
4569
+ # 附件完整性对账(借鉴 WorkDSH 输入引用修订):任务钉的附件指纹 vs 当前
4570
+ # 文件内容。换过/删过给明确诊断——续跑吃到「不是当初那份附件」不能无声。
4571
+ try:
4572
+ _adrift = attachments.verify_task(task, task.get("workdir"))
4573
+ if _adrift:
4574
+ store.update_run(run_id, attachment_drift=_adrift)
4575
+ _names = "、".join(d["name"] for d in _adrift[:3]) + (
4576
+ " 等 %d 项" % len(_adrift) if len(_adrift) > 3 else "")
4577
+ _warns2 = (store.get_run(run_id) or {}).get("warnings") or []
4578
+ store.update_run(run_id, warnings=_warns2 + [
4579
+ "附件在任务创建后已变化(%s):%s;本次执行使用当前文件内容"
4580
+ % (", ".join(sorted({d["status"] for d in _adrift})), _names)])
4581
+ print("[CodeBee] 附件漂移:%s(%s)" % (run_id, _names), flush=True)
4582
+ except Exception:
4583
+ pass
4390
4584
  try:
4391
4585
  _ensure_budget(run_id)
4392
4586
  except TaskTimeout:
@@ -133,6 +133,8 @@ CODE_PLAN_PROMPT = """你是技术负责人。请先判定任务难度,再按
133
133
  子任务拆分:easy 一律只拆 1 个子任务——一个子任务把目标完整做完并自检通过即收尾,
134
134
  禁止拆出独立的核查/联调/收尾子任务;只有 hard 才允许多个子任务(至多 __N__ 个),
135
135
  粒度要可独立验证,且最后一个子任务必须包含整体联调/收尾。
136
+ hard 也不做任务没要的防御性扩展子任务(缓存/重试/监控/通用抽象层)——
137
+ 发现真实风险写进对应子任务的 detail 里备注,由人决定要不要做。
136
138
 
137
139
  ## 先探索再计划(重要,借鉴 OpenSpec explore)
138
140
  拆解之前先用你的读文件/搜索工具**实际查看工作目录**,找到目标相关的真实文件与函数,再据此拆解。
@@ -248,7 +250,8 @@ def _norm_chapters(data, n, vol_per=0, vol_start=1, vol_plan=None):
248
250
  # 分卷:只收本批真正用到的卷的卷名/卷弧光(模型可能多写或乱写卷号,一律按
249
251
  # 本批章号范围过滤)。卷边界不在这里——它由 volumes.build_plan 从章号推导。
250
252
  if vol_plan:
251
- named = volumes.norm_volumes(data.get("volumes"), vol_per, vol_start, n)
253
+ named = volumes.norm_volumes(data.get("volumes"), vol_per, vol_start, n,
254
+ plan=vol_plan)
252
255
  if named:
253
256
  res["volumes"] = named
254
257
  return res
@@ -342,6 +342,12 @@ class Page:
342
342
  pass
343
343
  time.sleep(0.4)
344
344
  return
345
+ host = _up(url).netloc
346
+ if not host:
347
+ # 无主机名=无效地址(占位符未替换等)。放行会在下方被「host 为空
348
+ # 视为已到位」吞掉——页面停在 about:blank 还假装导航成功(0924 案)。
349
+ raise BrowserError("无效的导航地址(无主机名,占位符可能未替换):%s"
350
+ % str(url)[:90])
345
351
  try:
346
352
  self.send("Page.navigate", {"url": url}, timeout=timeout)
347
353
  except BrowserError:
@@ -126,3 +126,34 @@ def chapter_manage_url(book):
126
126
  if bid:
127
127
  return "https://fanqienovel.com/main/writer/chapter-manage/" + bid
128
128
  return CONFIG["home"] + "book-manage"
129
+
130
+
131
+ def resolve_book_id(page, book):
132
+ """登记缺 book_id 时按书名在作家后台找回:点书卡进详情,从 URL 提取。
133
+
134
+ 建书成功但 id 提取落空(跳转链没走完/auto_submit=false 人工提交)的书,
135
+ 发章前经 manager._resolve_book_id 调到这里补账;找不回返回空串不拦死。"""
136
+ import re
137
+ import time
138
+ from . import flow as _flow
139
+ title = str((book or {}).get("title") or "").strip()
140
+ if not title:
141
+ return ""
142
+ try:
143
+ page.navigate(CONFIG["home"], timeout=30)
144
+ except Exception:
145
+ return ""
146
+ r = None
147
+ for _try in range(6): # 书列表慢渲染
148
+ r = page.call(_flow._click_match_js(), [title], 300)
149
+ if (r or {}).get("ok"):
150
+ break
151
+ time.sleep(1.0)
152
+ if not (r or {}).get("ok"):
153
+ return ""
154
+ time.sleep(2.5) # 详情页跳转收尾
155
+ m = re.search(r"book-info/(\d+)|chapter-manage/(\d+)|writer/(\d+)/publish",
156
+ str(page.url() or ""))
157
+ if not m:
158
+ return ""
159
+ return m.group(1) or m.group(2) or m.group(3) or ""
@@ -22,6 +22,7 @@
22
22
  from __future__ import annotations
23
23
 
24
24
  import json
25
+ import re
25
26
  import time
26
27
 
27
28
  from .browser import BrowserError
@@ -122,6 +123,13 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
122
123
  url = url.replace("{%s}" % k, str(v))
123
124
  for k, v in (values or {}).items(): # editor_url/draft_url 等任务级占位
124
125
  url = url.replace("{%s}" % k, str(v))
126
+ # 占位符没被吃掉=values 缺键(平台模块缺 URL 生成器/登记不全)。
127
+ # 直接放行会让浏览器停在 about:blank,到 url_any 才误报
128
+ # 「未登录或改版」——在这里点名缺哪个键(0924 番茄三连败)。
129
+ left = re.findall(r"\{[A-Za-z_][A-Za-z0-9_]*\}", url)
130
+ if left:
131
+ raise FlowError("URL 占位符 %s 没有对应值:%s"
132
+ % (",".join(left), url[:90]))
125
133
  note(i, "打开 %s" % url)
126
134
  # 重页(编辑器/管理列表)可按步放宽;缺省 45s(30s 对慢网偏紧)
127
135
  page.navigate(url, timeout=float(st.get("timeout") or 45))
@@ -410,7 +418,7 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
410
418
  else:
411
419
  msg += "(常见原因:表单校验未过,如简介字数不足)"
412
420
  raise FlowError(msg)
413
- note(i, "页面标记校验通过")
421
+ note(i, "页面标记校验通过(%s)" % u[:80])
414
422
  else:
415
423
  raise FlowError("未知步骤类型:%s" % act)
416
424
  except FlowError:
@@ -24,7 +24,8 @@ from .. import paths
24
24
  LOCK = threading.RLock()
25
25
 
26
26
  FIELDS = ("ts", "day", "platform", "action", "task_id", "chapter_no",
27
- "book_id", "title", "ok", "error", "shot")
27
+ "book_id", "title", "ok", "error", "shot", "operation_id",
28
+ "operation_status", "remote_receipt")
28
29
 
29
30
 
30
31
  def _month_file(day):
@@ -32,7 +33,8 @@ def _month_file(day):
32
33
 
33
34
 
34
35
  def record(platform, action, task_id="", chapter_no=0, book_id="",
35
- title="", ok=True, error="", shot=""):
36
+ title="", ok=True, error="", shot="", operation_id="",
37
+ operation_status="", remote_receipt=""):
36
38
  """追加一条发布记录。异常全吞——记账失败绝不能影响发布主流程。"""
37
39
  try:
38
40
  rec = {
@@ -47,6 +49,9 @@ def record(platform, action, task_id="", chapter_no=0, book_id="",
47
49
  "ok": bool(ok),
48
50
  "error": str(error or "")[:300],
49
51
  "shot": str(shot)[:200],
52
+ "operation_id": str(operation_id or "")[:80],
53
+ "operation_status": str(operation_status or "")[:20],
54
+ "remote_receipt": str(remote_receipt or "")[:300],
50
55
  }
51
56
  with LOCK:
52
57
  paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
@@ -19,10 +19,11 @@ view()(SSE/轮询)看进度;线程内任何异常都落终态,绝不悬
19
19
  from __future__ import annotations
20
20
 
21
21
  import json
22
+ import re
22
23
  import threading
23
24
  import time
24
25
 
25
- from .. import paths
26
+ from .. import operations, paths
26
27
  from . import fanqie, flow, ledger, qimao
27
28
  from .browser import Browser, BrowserError, Page
28
29
 
@@ -406,9 +407,13 @@ def create_book_async(task_id, plat, auto_submit=False):
406
407
  data = meta["data"]
407
408
  mod = PLATFORMS[plat]
408
409
  logs = []
410
+ book_name = (data.get("book_name") or "").strip()
411
+ operation_id = operations.begin(
412
+ "publish:%s:create_book" % plat,
413
+ {"task_id": task_id, "title": book_name}, task_id=task_id,
414
+ metadata={"platform": plat, "action": "create_book"})
409
415
 
410
416
  def run():
411
- book_name = (data.get("book_name") or "").strip()
412
417
  try:
413
418
  b, page = _open_page(plat)
414
419
  values = mod.values_create_book(data)
@@ -419,27 +424,49 @@ def create_book_async(task_id, plat, auto_submit=False):
419
424
  shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
420
425
  log=logs.append)
421
426
  # book_id:创建成功后平台跳书籍详情/编辑器,从 URL 提取
422
- # (番茄 book-info/<id>;七猫 information?id=<id>)。
423
- # 提取不到就先用书名登记,发章按书名找书。
427
+ # (番茄 book-info/<id>;七猫 information?id=<id>)。url_any 过了
428
+ # 但页面还在跳转链上时再等几轮;提取落空把最终 URL 记进日志,
429
+ # 别再静默登记空 id(发章只能兜底找书,直达编辑器就废了)。
424
430
  book_id = ""
425
431
  try:
426
- m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
427
- str(page.url() or ""))
428
- if m_url:
429
- book_id = m_url.group(1) or m_url.group(2)
432
+ for _try in range(6):
433
+ m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
434
+ str(page.url() or ""))
435
+ if m_url:
436
+ book_id = m_url.group(1) or m_url.group(2)
437
+ break
438
+ time.sleep(0.8)
439
+ if not book_id:
440
+ logs.append("book_id 提取落空,最终页面:%s"
441
+ % str(page.url() or "")[:120])
430
442
  except Exception:
431
443
  pass
444
+ operation_status = "confirmed" if book_id else "unknown"
445
+ if book_id:
446
+ operations.confirm(operation_id, remote_receipt=book_id,
447
+ metadata={"platform": plat, "action": "create_book"})
448
+ else:
449
+ operations.mark_unknown(
450
+ operation_id,
451
+ "建书流程完成但未取得远端 book_id,请先在平台核对后再重试",
452
+ metadata={"platform": plat, "action": "create_book"})
432
453
  ledger.record(plat, "create_book", task_id=task_id, title=book_name,
433
- book_id=book_id, ok=True,
434
- error="\n".join(logs)[:2000],
435
- shot=str(ledger.shot_path(plat, task_id, "")))
454
+ book_id=book_id, ok=bool(book_id),
455
+ error=("\n".join(logs)[:2000] if book_id else
456
+ "建书结果未知:未取得远端 book_id;请先对账"),
457
+ shot=str(ledger.shot_path(plat, task_id, "")),
458
+ operation_id=operation_id, operation_status=operation_status,
459
+ remote_receipt=book_id)
436
460
  ledger.save_book(task_id, plat, {"book_id": book_id, "title": book_name})
437
461
  _set(plat, status="connected", error="")
438
462
  except Exception as e:
463
+ operations.finish_exception(operation_id, e)
464
+ operation_status = (operations.get(operation_id) or {}).get("status") or "failed"
439
465
  _set(plat, status="error", error="建书失败:%s" % e)
440
466
  ledger.record(plat, "create_book", task_id=task_id, title=book_name,
441
467
  ok=False, error=str(e)[:300],
442
- shot=str(ledger.shot_path(plat, task_id, "")))
468
+ shot=str(ledger.shot_path(plat, task_id, "")),
469
+ operation_id=operation_id, operation_status=operation_status)
443
470
  finally:
444
471
  _save()
445
472
 
@@ -515,6 +542,32 @@ def _login_guard(plat):
515
542
  return False, "浏览器不可用:%s" % e
516
543
 
517
544
 
545
+ def _url_values(mod, book):
546
+ """平台模块提供的 URL 占位值(chapter_manage_url/draft_url/editor_url),
547
+ 逐键取、缺哪个跳哪个。曾按序连赋 + except AttributeError 兜底:draft_url
548
+ 只有七猫有,番茄走到第二行即断,editor_url 永远赋不上——{editor_url}
549
+ 残进 navigate,页面停在 about:blank 误报「未登录或改版」(0924 三连败)。"""
550
+ out = {}
551
+ for key in ("chapter_manage_url", "draft_url", "editor_url"):
552
+ fn = getattr(mod, key, None)
553
+ if callable(fn):
554
+ out[key] = fn(book)
555
+ return out
556
+
557
+
558
+ def _resolve_book_id(plat, page, book):
559
+ """登记缺 book_id 时按书名在作家后台找回(平台模块可选提供
560
+ resolve_book_id)。找不回返回空串:流程按无 id 兜底路径继续,让后续
561
+ 步骤如实报错,绝不在这里拦死。找回后回写 books.json,下次直达。"""
562
+ fn = getattr(PLATFORMS[plat], "resolve_book_id", None)
563
+ if not callable(fn) or str((book or {}).get("book_id") or "").strip():
564
+ return ""
565
+ try:
566
+ return str(fn(page, book) or "").strip()
567
+ except Exception:
568
+ return ""
569
+
570
+
518
571
  # ---------------------------------------------------------------- 动作:发章
519
572
  def read_chapter(fp):
520
573
  """读章节文件 → (章号, 标题, 正文, 错误)。UTF-8→GBK 回退(章稿乱码教训)。
@@ -558,6 +611,10 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
558
611
  book = ledger.book_for(task_id, plat)
559
612
  if not book:
560
613
  return False, "该任务尚未在此平台建书,请先「创建作品」"
614
+ # 闭包中需要在找回 book_id 后更新书籍引用;使用独立副本避免
615
+ # Python 将 book 误判为 run() 的局部变量,导致前置解析触发
616
+ # UnboundLocalError。
617
+ book_ref = dict(book)
561
618
  if _st(plat).get("status") == "busy":
562
619
  return False, "该平台有操作正在进行中"
563
620
  ch_no, title, body, err = read_chapter(chapter_file)
@@ -583,30 +640,42 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
583
640
 
584
641
  mod = PLATFORMS[plat]
585
642
  values = {"chapter_title": title, "chapter_body": body,
586
- "book_name": book.get("title") or ""}
587
- try: # verify/draft 页 URL(流程占位)
588
- values["chapter_manage_url"] = mod.chapter_manage_url(book)
589
- values["draft_url"] = mod.draft_url(book)
590
- values["editor_url"] = mod.editor_url(book)
591
- except AttributeError:
592
- pass
643
+ "book_name": book_ref.get("title") or ""}
644
+ values.update(_url_values(mod, book_ref)) # verify/draft/editor 页 URL(流程占位)
593
645
  logs = []
646
+ operation_id = operations.begin(
647
+ "publish:%s:upload_chapter" % plat,
648
+ {"task_id": task_id, "chapter_no": ch_no, "title": title}, task_id=task_id,
649
+ metadata={"platform": plat, "action": "upload_chapter"})
594
650
 
595
651
  def run():
596
652
  try:
597
653
  b, page = _open_page(plat)
654
+ bid = _resolve_book_id(plat, page, book_ref)
655
+ if bid: # 建书时没提上 id 的书,发章前补账
656
+ book_ref.update(book_id=bid)
657
+ values.update(_url_values(mod, book_ref))
658
+ ledger.save_book(task_id, plat, book_ref)
659
+ logs.append("已按书名找回 book_id=%s 并更新登记" % bid)
598
660
  flow.run_flow(page, load_flow(plat, "upload_chapter"), values=values,
599
661
  config=mod.CONFIG, auto_submit=auto_submit,
600
662
  shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
601
663
  log=logs.append)
664
+ operations.confirm(operation_id, metadata={"platform": plat,
665
+ "action": "upload_chapter",
666
+ "chapter_no": ch_no})
602
667
  ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
603
- book_id=book.get("book_id") or "", title=title, ok=True)
668
+ book_id=book_ref.get("book_id") or "", title=title, ok=True,
669
+ operation_id=operation_id, operation_status="confirmed")
604
670
  _set(plat, status="connected", error="")
605
671
  except Exception as e:
672
+ operations.finish_exception(operation_id, e)
673
+ operation_status = (operations.get(operation_id) or {}).get("status") or "failed"
606
674
  _set(plat, status="error", error="发章失败:%s" % e)
607
675
  ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
608
- book_id=book.get("book_id") or "", title=title,
609
- ok=False, error=str(e)[:300])
676
+ book_id=book_ref.get("book_id") or "", title=title,
677
+ ok=False, error=str(e)[:300], operation_id=operation_id,
678
+ operation_status=operation_status)
610
679
  finally:
611
680
  _save()
612
681