codebee 0.1.15 → 0.1.17

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.
@@ -82,6 +82,9 @@ def _agents():
82
82
  # 本轮运行的智能体池:execute_run 入口快照,_run_step 死链补位时扫描。
83
83
  # 直接调 _run_step 的场景(单测/内部工具)池为空 → 补位不触发,闸门语义不变。
84
84
  _CURRENT_AGENTS: list = []
85
+ # 本次 run 的任务宪章注入块(execute_run 起跑时从 .codebee/constitution.md 读入;
86
+ # 步骤函数在各提示词组装点引用,空串=未配置零噪音)
87
+ _RUN_CONSTITUTION = ""
85
88
 
86
89
 
87
90
  def _dead_binding_substitute(dead_id, resume=None):
@@ -660,6 +663,11 @@ CODE_REVIEW_PROMPT = """你是代码评审员(不要修改任何文件)。
660
663
  "summary": "一句话结论"
661
664
  }
662
665
 
666
+ ## 评审要求(findings 锚定证据)
667
+ 每个 issue 的 detail 必须给出「文件名:行号」(从 diff 的 hunk 头 @@ -a,b +c,d @@ 与上下文推算),
668
+ 并引用该处一行关键代码作为依据——没有证据定位的问题不要报,宁可少报不报猜测。
669
+ (借鉴 pr-af:findings grounded in code evidence 是评审可信度的根。)
670
+
663
671
  ## 任务目标
664
672
  __GOAL__
665
673
 
@@ -792,7 +800,7 @@ def _code_bestof(run, task, impl, difficulty, ev):
792
800
  agt_b = modelhub.bind_agent(impl, difficulty)
793
801
  res = None
794
802
  for i, sub in enumerate(subtasks):
795
- prompt = (CODE_IMPL_PROMPT
803
+ prompt = (_RUN_CONSTITUTION + CODE_IMPL_PROMPT
796
804
  .replace("__GOAL__", task["goal"])
797
805
  .replace("__SUBTASK__", sub["detail"] if sub["detail"] else sub["title"])
798
806
  .replace("__CONTEXT__", task.get("context") or "(无)")
@@ -941,7 +949,7 @@ def _run_code(run, task, agents, ev, stats, mode):
941
949
  (s.get("title") or "")[:40] for s in subtasks[:i]) or "(无)"
942
950
  prog = ("\n\n## 计划进度(第 %d/%d 项)\n已完成:%s。当前接着做下面这一项,"
943
951
  "不要重做已完成的。" % (i + 1, len(subtasks), done_titles))
944
- prompt = (CODE_IMPL_PROMPT
952
+ prompt = (_RUN_CONSTITUTION + CODE_IMPL_PROMPT
945
953
  .replace("__GOAL__", task["goal"])
946
954
  .replace("__SUBTASK__",
947
955
  sub["detail"] if sub["detail"] else sub["title"])
@@ -1396,10 +1404,25 @@ __GOAL__
1396
1404
  ## 背景与上下文
1397
1405
  __CONTEXT__
1398
1406
 
1407
+ ## 评审维度(写前自查)
1408
+ __RUBRIC__
1409
+ (按这些维度组织内容重点——评审会按它们打分)
1410
+
1399
1411
  ## 要求
1400
1412
  - 只修改 `__FILE__` 这一个文件;保持 Markdown 结构。
1401
1413
  - 完成后用 3 句话说明本轮写了什么。"""
1402
1414
 
1415
+ # 调研报告类稿件的追加要求(借鉴 gpt-researcher 迭代深研):有网络/读文件工具时
1416
+ # 多源交叉验证,单源结论降权——调研的可信度来自证据链而非文采
1417
+ RESEARCH_APPENDIX = """
1418
+
1419
+ ## 调研要求(证据链)
1420
+ - 有联网/检索工具就先搜集资料再写:同一关键结论至少两个独立来源交叉验证,
1421
+ 单源信息要标注「仅单一来源」。
1422
+ - 引用来源在文中用行内链接或脚注标明(域名即可,不编造 URL)。
1423
+ - 区分「事实」与「观点」:数据/时间/版本号给来源,预测/评价标明是分析。
1424
+ - 结构建议:结论先行 → 分层论证 → 风险与局限(说明哪些结论证据不足)。"""
1425
+
1403
1426
  NOVEL_REVISE_PROMPT = """你是一名专业作者。请根据下方汇总评审意见修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
1404
1427
 
1405
1428
  ## 原始写作任务
@@ -1465,13 +1488,19 @@ def _story_bible(workdir):
1465
1488
  if not txt:
1466
1489
  return ""
1467
1490
  return ("## 故事圣经(story-bible.md:人物/世界观/伏笔台账,本书一切写作与评审以此为准,"
1468
- "与其冲突处以圣经为准)\n\n" + txt)
1491
+ "与其冲突处以圣经为准)\n\n"
1492
+ "**连续性锁**(借鉴 drama-skills):人物外貌/性格口头禅/物品/能力等需要跨章一致"
1493
+ "的设定,写作时原样沿用圣经中的表述(可整句贴入正文),不要同义改写——每章用词"
1494
+ "一致读者才不会出戏。\n\n" + txt)
1469
1495
 
1470
1496
 
1471
1497
  def _plot_modules(workdir):
1472
1498
  """剧情模块库(oh-story 拆文沉淀式):工作目录里的 plot-modules.md
1473
1499
  (可复用的桥段/冲突/爽点/名场面素材模块),作者手工维护,每章起草与
1474
1500
  评审前自动注入。不存在/为空返回 ""——约定式功能,零配置零噪音。"""
1501
+ p = os.path.abspath(os.path.join(str(workdir or ""), MODULES_FILE))
1502
+ if not _inside(workdir, p) or not os.path.isfile(p):
1503
+ return ""
1475
1504
  try:
1476
1505
  txt = _read_text_any_enc(p)[:_MODULES_MAX_CHARS].strip()
1477
1506
  except OSError:
@@ -1482,6 +1511,49 @@ def _plot_modules(workdir):
1482
1511
  "鼓励化用,不要照抄原句)\n\n" + txt)
1483
1512
 
1484
1513
 
1514
+ def _shrink_context_block(sk_block, bible, budget=12000):
1515
+ """分层上下文降级(长提示词在容量受限通道上会挂起/秒拒,2026-09-17 讯飞实测)。
1516
+
1517
+ 四层优先级:故事圣经(最高,设定冲突以它为准)> 剧情模块库 > 经验库 > 大纲/前情
1518
+ (后两者在提示词正文里,永不动)。超预算时按优先级保序截断:
1519
+ - 圣经截断保整段(按二级标题边界,无边界才硬截)
1520
+ - 模块库截断保整模块(按「## 」标题边界)
1521
+ - 经验库直接硬截(条目本身短,损失最小)
1522
+ 返回 (新 sk_block, 降级说明)。无降级返回原样。"""
1523
+ total = len(sk_block or "") + len(bible or "")
1524
+ if total <= budget:
1525
+ return sk_block, bible, ""
1526
+ notes = []
1527
+ # 1) 先压经验库到 4K(条目短、损失最小)
1528
+ if len(sk_block or "") > 4000:
1529
+ sk_block = sk_block[:4000] + "\n\n(经验库已因上下文容量限制精简)"
1530
+ notes.append("经验库→4K")
1531
+ if len(sk_block) + len(bible or "") <= budget:
1532
+ return sk_block, bible, ";".join(notes)
1533
+ # 2) 模块库按模块边界截断
1534
+ if bible and "## 剧情模块库" in bible:
1535
+ head, sep, mods = bible.partition("## 剧情模块库")
1536
+ mods = sep + mods
1537
+ keep = mods[:6000]
1538
+ cut = keep.rfind("\n## ")
1539
+ if cut > 200:
1540
+ keep = keep[:cut]
1541
+ bible = head + keep + "\n\n(模块库已因上下文容量限制精简)"
1542
+ notes.append("模块库→边界截断")
1543
+ if len(sk_block) + len(bible) <= budget:
1544
+ return sk_block, bible, ";".join(notes)
1545
+ # 3) 圣经按二级标题边界截断到预算
1546
+ if bible:
1547
+ budget_left = max(2000, budget - len(sk_block or ""))
1548
+ keep = bible[:budget_left]
1549
+ cut = keep.rfind("\n## ")
1550
+ if cut > 500:
1551
+ keep = keep[:cut]
1552
+ bible = keep + "\n\n(圣经已因上下文容量限制精简)"
1553
+ notes.append("圣经→边界截断")
1554
+ return sk_block, bible, ";".join(notes)
1555
+
1556
+
1485
1557
  def _critic_lens(critics, agent):
1486
1558
  """该评审的专属视角;单评审/手动指定时不播种(无从轮换,也别稀释注意力)。"""
1487
1559
  try:
@@ -1687,6 +1759,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1687
1759
  mods = _plot_modules(workdir)
1688
1760
  if mods:
1689
1761
  bible = (bible + "\n\n" + mods) if bible else mods
1762
+ # 任务宪章(spec-kit constitution):作者定的质量原则拼在注入块最前——
1763
+ # 优先级最高的约束放最前面,写作者先读原则再读设定
1764
+ if _RUN_CONSTITUTION:
1765
+ bible = _RUN_CONSTITUTION + (bible or "")
1690
1766
 
1691
1767
 
1692
1768
  def crit_prompt_for(text, note=""):
@@ -1777,6 +1853,19 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1777
1853
  if t:
1778
1854
  tails.append("(第 %d 章结尾)…%s" % (j, t[-260:].strip()))
1779
1855
  prev = "\n".join(tails) or "(无)"
1856
+ # findings 中期记忆(借鉴 agentmemory 持久记忆):此前各章的关键事实
1857
+ # 追加在 .codebee/findings.md,注入时只取前一章之前的记录(当章发现
1858
+ # 会在当章评审后追加进来)。300+ 章长篇的前情只看近 2 章不够,
1859
+ # findings 填补中期记忆空洞。失败静默。
1860
+ try:
1861
+ fd_p = os.path.join(workdir, ".codebee", "findings.md")
1862
+ if os.path.isfile(fd_p):
1863
+ fd_txt = _read_text_any_enc(fd_p)
1864
+ if fd_txt:
1865
+ # 截到 3000 字防无限膨胀;只在尾部追加时自动增长,注入头固定
1866
+ prev += "\n\n## 此前章节发现摘要\n" + fd_txt[:3000]
1867
+ except Exception:
1868
+ pass
1780
1869
 
1781
1870
  # 评审-修订(每章至多 1 轮修订)
1782
1871
  rounds_used = 1
@@ -1926,10 +2015,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1926
2015
  time.sleep(30 * draft_attempt) # 30s / 60s 退避
1927
2016
  if draft_attempt and len(prompt) > 12000 and sk_block and sk_block in prompt:
1928
2017
  # 长提示词在容量受限通道(讯飞托管 35B 等)上会挂起/秒拒
1929
- # ——降级重试:经验库块截到 4K 字,保留大纲/前情/本章要点
1930
- # 2026-09-17 七猫实测:全量 30KB 对讯飞必挂)
1931
- use_prompt = prompt.replace(
1932
- sk_block, sk_block[:4000] + "\n\n(经验库已因通道容量限制精简)")
2018
+ # ——分层降级:经验库→4K、模块库按模块边界、圣经按二级标题
2019
+ # 边界,保大纲/前情/本章要点(2026-09-17 七猫实测:全量
2020
+ # 30KB 对讯飞必挂)
2021
+ sk2, bible2, _note = _shrink_context_block(sk_block, bible, budget=12000)
2022
+ use_prompt = prompt.replace(sk_block, sk2).replace(bible, bible2)
1933
2023
  res = _run_step(run_id, "draft-c%d" % i, modelhub.bind_agent(impl, difficulty), use_prompt,
1934
2024
  step_wd, readonly=False, ev=ev, timeout=2400,
1935
2025
  resume=resume_ctx["session"] if resume_ctx else None,
@@ -2185,6 +2275,22 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2185
2275
  "words": _wc(_read_chapter(workdir, i))})
2186
2276
  # 每章即时持久化:长篇中断/超时后可断点续跑,不丢已完成章的分数
2187
2277
  store.update_run(run_id, chapter_scores=chapter_scores)
2278
+ # findings 沉淀(借鉴 agentmemory 持久记忆):章节标题+要点追加到
2279
+ # .codebee/findings.md——后续章节起草时随圣经/模块库注入,弥补
2280
+ # 前情提要只看近 2 章结尾的中期记忆空洞。失败静默。
2281
+ try:
2282
+ fd_p = os.path.join(workdir, ".codebee", "findings.md")
2283
+ os.makedirs(os.path.dirname(fd_p), exist_ok=True)
2284
+ header_needed = not os.path.isfile(fd_p)
2285
+ with open(fd_p, "a", encoding="utf-8") as f:
2286
+ if header_needed:
2287
+ f.write("# 章节发现(每章评审达标后自动追加,供后续章节参考)\n\n")
2288
+ f.write("- 第%d章《%s》:%s(%d 字,%s)\n" % (
2289
+ i, ch["title"],
2290
+ ch.get("beats") or "按大纲推进", chapter_scores[-1]["words"],
2291
+ "%.1f 分" % means.get("情节", 0.0) if means else "无评分"))
2292
+ except Exception:
2293
+ pass
2188
2294
 
2189
2295
  # ---- 3) 全局一致性评审(覆盖 1..end 全书:续写批次必须连同旧章一起查一致性)
2190
2296
  full_text = "\n\n".join(_read_chapter(workdir, i) for i in range(1, end + 1))
@@ -2321,10 +2427,25 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2321
2427
  fixed.append(i)
2322
2428
  store.update_run(run_id, chapter_scores=chapter_scores)
2323
2429
  _check_cancel(ev)
2430
+ if not fixed:
2431
+ # 最弱章重改全部失败(供应商拥堵/流断等)→ 章稿没有任何变化,
2432
+ # 继续跑全书重评只会白烧评审链,组长步骤一挂「工作中」就是几十
2433
+ # 分钟(2026-09-19 实案:polish-c18/c11 双败后仍进全书重评,
2434
+ # 单个评审 35 分钟,用户侧只见打磨 2/3 久卡不动)。直接收尾。
2435
+ store.finish_step(run_id, pstep["n"], "failed",
2436
+ summary="重改未成功(%s 全部失败),本轮打磨中止;"
2437
+ "已落盘章稿不受影响"
2438
+ % "、".join("第 %d 章" % c["chapter"] for c in weak))
2439
+ break
2324
2440
  # 重评全书一致性(同一评审闭包;本轮全挂则保留上一轮结论——评审链挂了
2325
2441
  # 不代表书变差,不能拿「无法评审」覆盖真实分数)
2326
- full_text = "\n\n".join(_read_chapter(workdir, i2) for i2 in range(1, end + 1))
2327
- gscored2, gmeans_acc2 = run_global_round(critics)
2442
+ try:
2443
+ full_text = "\n\n".join(_read_chapter(workdir, i2) for i2 in range(1, end + 1))
2444
+ gscored2, gmeans_acc2 = run_global_round(critics)
2445
+ except BaseException:
2446
+ store.finish_step(run_id, pstep["n"], "failed",
2447
+ summary="打磨后全书重评异常中止,已落盘章稿不受影响")
2448
+ raise
2328
2449
  if gscored2:
2329
2450
  global_means = {d: round(sum(xs) / len(xs), 1) for d, xs in gmeans_acc2.items()}
2330
2451
  global_pass = _all_ge(global_means, threshold)
@@ -2583,13 +2704,19 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2583
2704
  except Exception:
2584
2705
  pass
2585
2706
  else:
2707
+ is_research = task.get("type") == "research"
2708
+
2586
2709
  def _draft_prompt_for(vfile):
2587
2710
  p = (_tpl(task, "draft_prompt", NOVEL_DRAFT_PROMPT).replace("__FILE__", vfile)
2588
2711
  .replace("__GOAL__", task["goal"])
2589
- .replace("__CONTEXT__", task.get("context") or "(无)"))
2712
+ .replace("__CONTEXT__", task.get("context") or "(无)")
2713
+ .replace("__RUBRIC__", "、".join(dims) if dims else "(按流程默认维度)"))
2590
2714
  if outline:
2591
2715
  p += "\n\n## 编排者大纲(按要点组织稿件)\n" + \
2592
2716
  "\n".join("- " + i for i in outline["items"])
2717
+ if is_research:
2718
+ # 调研报告追加证据链要求(gpt-researcher 借鉴)
2719
+ p += RESEARCH_APPENDIX
2593
2720
  return p
2594
2721
 
2595
2722
  best_of = max(1, min(3, int(task.get("best_of") or 1)))
@@ -2795,6 +2922,23 @@ def _run_serial_qa(run, task, agents, ev):
2795
2922
  error="答疑失败(执行/评审链不可用)——" + ";".join(errors[-3:]))
2796
2923
 
2797
2924
 
2925
+ def _read_constitution(workdir):
2926
+ """任务宪章(借鉴 spec-kit constitution):工作目录 .codebee/constitution.md
2927
+ (作者手工维护的质量原则——代码规范/文风/测试要求)。存在且非空时返回注入块,
2928
+ 每次 run 的所有智能体提示词都会带上;否则返回 ""(零配置零噪音)。"""
2929
+ p = os.path.abspath(os.path.join(str(workdir or ""), ".codebee", "constitution.md"))
2930
+ if not _inside(workdir, p) or not os.path.isfile(p):
2931
+ return ""
2932
+ try:
2933
+ txt = _read_text_any_enc(p)[:6000].strip()
2934
+ except OSError:
2935
+ return ""
2936
+ if not txt:
2937
+ return ""
2938
+ return ("## 项目宪章(constitution.md:本项目一切产出的质量原则,优先级最高,"
2939
+ "与其他要求冲突时以宪章为准)\n\n" + txt + "\n\n")
2940
+
2941
+
2798
2942
  def _write_task_spec(task, workdir):
2799
2943
  """任务规格落盘 .codebee/spec.md(借鉴 agent-orchestrator 的 .spec/PROMPT.md 与
2800
2944
  planning-with-files 的文件化计划):任务定义随工作目录留存、随任务分支版本化,
@@ -2952,6 +3096,11 @@ def execute_run(run_id):
2952
3096
  # 任务规格文件化(借鉴 planning-with-files/agent-orchestrator):任何任务都在
2953
3097
  # 工作目录留一份 .codebee/spec.md——原始意图可见、随任务分支版本化
2954
3098
  _write_task_spec(task, task["workdir"])
3099
+ # 任务宪章(借鉴 spec-kit constitution):工作目录 .codebee/constitution.md
3100
+ # 是作者定下的质量原则(代码规范/文风/测试要求),每次 run 注入所有智能体
3101
+ # 提示词——代码/文章/翻译全类型通用,一次定义持续生效。缺省零噪音。
3102
+ global _RUN_CONSTITUTION
3103
+ _RUN_CONSTITUTION = _read_constitution(task["workdir"])
2955
3104
  agents = _agents()
2956
3105
  global _CURRENT_AGENTS
2957
3106
  _CURRENT_AGENTS = agents
@@ -139,14 +139,21 @@ class Browser:
139
139
  except OSError as e:
140
140
  raise BrowserError("浏览器启动失败:%s" % e)
141
141
  if not self._wait_ready(15.0):
142
- # Edge 对已有实例的 profile:新进程转交参数后秒退,调试端口是
143
- # 老实例自己的(甚至自选的,真机实测指定 59852 实际跑 56394)。
144
- # 等自己指定的端口必然超时——扫进程命令行找该 profile 的真端口接管。
142
+ # Edge 对已有实例的 profile:新进程转交参数后秒退(stdout 会打
143
+ # 「正在现有浏览器会话中打开」),调试端口是老实例自己的(甚至
144
+ # 自选的,真机实测指定 59852 实际跑 56394)。等自己指定的端口必然
145
+ # 超时——先扫进程命令行找该 profile 的真端口接管(含隐藏的后台
146
+ # 常驻实例:Edge「启动加速/后台运行」的进程锁着 profile 不可见)。
145
147
  for p in _debug_ports_for_profile(self.user_data_dir):
146
148
  if p != self.port and port_alive(p, timeout=2.0):
147
149
  self.port = p
148
150
  self.proc = None # 老实例不是本对象起的:close 不杀它
149
151
  return
152
+ if self.proc.poll() == 0:
153
+ raise BrowserError(
154
+ "浏览器把启动请求转给了已在运行的会话(该平台浏览器有隐藏的"
155
+ "后台实例占着档案)——请在任务管理器结束所有 Edge 进程,"
156
+ "或重启电脑后重试;登录态不受影响")
150
157
  raise BrowserError(
151
158
  "浏览器调试端口 15 秒内没就绪:该平台可能已有浏览器实例在跑但"
152
159
  "端口探测不到——关掉它的所有窗口后重试,或重启电脑后首次连接")
@@ -447,25 +454,32 @@ class Page:
447
454
  raise BrowserError("点击失败:%s" % ((r or {}).get("err") or sel))
448
455
  return True
449
456
 
450
- def real_click_text(self, text, scope="", contains=True, exact_fallback=True):
457
+ def real_click_text(self, text, scope="", contains=True, exact_fallback=True,
458
+ y_min=None, y_max=None):
451
459
  """按文本真实点击:JS 定位元素中心坐标 → CDP Input 派发鼠标事件序列。
452
460
 
453
461
  qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
454
462
  el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
463
+ y_min/y_max 限定元素纵向范围(区分同名 select、限制弹层内点击)。
455
464
  返回 {ok, tag?, via};找不到元素返回 {ok: False, err}。"""
456
465
  r = self.call(
457
- "(t,scope,c)=>{"
458
- "const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li,[class*=btn]')];"
466
+ "(t,scope,c,y0,y1)=>{"
467
+ "const vis=e=>e.getBoundingClientRect().width>0;"
468
+ "const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
459
469
  "let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
460
470
  "return x&&(c?x.includes(t):x===t);});"
461
471
  "if(!cands.length&&c){cands=els.filter(e=>(e.innerText||'').trim()===t);}"
472
+ "if(y0!==null)cands=cands.filter(e=>e.getBoundingClientRect().y>=y0);"
473
+ "if(y1!==null)cands=cands.filter(e=>e.getBoundingClientRect().y<=y1);"
462
474
  "if(!cands.length)return{ok:false,err:'nf'};"
463
475
  "cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
464
476
  "const el=cands[0];el.scrollIntoView({block:'center'});"
465
477
  "const rc=el.getBoundingClientRect();"
466
478
  "return{ok:true,x:Math.round(rc.x+rc.width/2),y:Math.round(rc.y+rc.height/2),"
467
479
  "tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
468
- str(text), scope or "", bool(contains))
480
+ str(text), scope or "", bool(contains),
481
+ int(y_min) if y_min is not None else None,
482
+ int(y_max) if y_max is not None else None)
469
483
  if not (r or {}).get("ok"):
470
484
  return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素" % text}
471
485
  x, y = r["x"], r["y"]
@@ -75,15 +75,29 @@ FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
75
75
  "check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
76
76
 
77
77
 
78
+ def _first(meta, key):
79
+ v = meta.get(key)
80
+ return str(v[0]).strip() if isinstance(v, list) and v else str(v or "").strip()
81
+
82
+
78
83
  def values_create_book(meta):
79
- """bookmeta 字段 → 建书表单值。标签/分类逐项点击由流程按 values.tags 展开
80
- (manager 组装时把 tags 列表拍平成 click_text 步骤追加)。"""
84
+ """bookmeta 字段 → 建书表单值。标签弹层的分区点选用单值字段
85
+ (每组选 1 个最稳:番茄主题/角色/情节各≤2、内容组各有上限),
86
+ 数组全量点选由 tag_groups 路径兼容。"""
81
87
  return {
82
88
  "title": (meta.get("book_name") or "").strip(),
83
89
  "summary": (meta.get("summary") or "").strip(),
84
90
  "protagonist": (meta.get("protagonist_1") or "").strip(),
91
+ "protagonist2": (meta.get("protagonist_2") or "").strip(),
85
92
  "signing_mode": (meta.get("signing_mode") or "连载模式").strip(),
86
93
  "category": (meta.get("category") or "").strip(),
94
+ "target_reader": (meta.get("target_reader") or "男频").strip(),
95
+ "tag_theme": _first(meta, "tags_theme"),
96
+ "tag_role": _first(meta, "tags_role"),
97
+ "tag_plot": _first(meta, "tags_plot"),
98
+ "tag_content_emotion": _first(meta, "content_emotion"),
99
+ "tag_content_character": _first(meta, "content_character"),
100
+ "tag_content_world": _first(meta, "content_world"),
87
101
  }
88
102
 
89
103
 
@@ -146,6 +146,8 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
146
146
  # 真实鼠标事件点击(CDP Input 派发):qm-btn 等自定义按钮
147
147
  # 只认真实事件序列,el.click() 无效。发布确认链全靠它。
148
148
  text = str(st.get("text") or "")
149
+ for k, v in (values or {}).items(): # {target_reader} 等动态值
150
+ text = text.replace("{%s}" % k, str(v))
149
151
  for k, v in (values or {}).items():
150
152
  text = text.replace("{%s}" % k, str(v))
151
153
  if not text:
@@ -154,7 +156,9 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
154
156
  r = None
155
157
  for _try in range(int(st.get("tries") or 25)):
156
158
  r = page.real_click_text(text, st.get("scope") or
157
- "a,button,[class*=btn]")
159
+ "a,button,[class*=btn]",
160
+ y_min=st.get("y_min"),
161
+ y_max=st.get("y_max"))
158
162
  if (r or {}).get("ok"):
159
163
  break
160
164
  time.sleep(0.4)
@@ -334,6 +338,26 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
334
338
  note(i, "提交 %s" % st.get("sel") or "")
335
339
  page.wait_for(st["sel"], timeout=8)
336
340
  page.click(st["sel"])
341
+ elif act == "sleep":
342
+ # 固定等待(SPA 水合/动画缓冲):比 wait 更钝但最可靠
343
+ time.sleep(float(st.get("s") or 1.0))
344
+ note(i, "等待 %.1fs" % float(st.get("s") or 1.0))
345
+ elif act == "wait_gone":
346
+ # 等元素消失(弹层关闭动画对齐):连发点击被关闭动画的
347
+ # mask 吞掉是多层弹窗流程的经典竞态
348
+ sel = st["sel"]
349
+ note(i, "等待 %s 消失" % sel)
350
+ deadline = time.time() + float(st.get("timeout") or 10)
351
+ while time.time() < deadline:
352
+ try:
353
+ if not page.exists(sel, timeout=1.0):
354
+ break
355
+ except BrowserError:
356
+ break
357
+ time.sleep(0.4)
358
+ else:
359
+ raise FlowError("等待 %s 消失超时" % sel)
360
+ note(i, "已消失")
337
361
  elif act == "verify":
338
362
  # 上线验证:导航到验证页断言文本在场——防「流程完成但平台
339
363
  # 静默未发布」的假成功(0 字草稿案)。url/any 支持 values 占位。