codebee 0.1.14 → 0.1.16

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.
@@ -71,7 +71,10 @@ def push_run_async(run_id):
71
71
 
72
72
 
73
73
  def push_run(run_id):
74
- """组装 run 结果摘要并推送(webhook 未配置时静默跳过)。"""
74
+ """组装 run 结果摘要并推送(webhook 未配置时静默跳过)。
75
+
76
+ 摘要末尾附分享页路径(notify_base_url 设置非空时拼完整链接,否则只给
77
+ run_id 供在本机 CodeBee 界面查找)。"""
75
78
  hook = _webhook()
76
79
  if not hook:
77
80
  return False
@@ -90,6 +93,13 @@ def push_run(run_id):
90
93
  if v.get("overall") is not None:
91
94
  lines.append("综合评分 %.1f(%s)" % (
92
95
  float(v["overall"]), "达标" if v.get("publishable") else "未达标"))
96
+ try:
97
+ from .settings import load as _sload
98
+ base = str(_sload().get("notify_base_url") or "").strip().rstrip("/")
99
+ if base:
100
+ lines.append("📄 详情:%s/api/runs/%s/share" % (base, run_id))
101
+ except Exception:
102
+ pass
93
103
  return push_text("\n".join(lines))
94
104
 
95
105
 
@@ -1,10 +1,14 @@
1
1
  # -*- coding: utf-8 -*-
2
- """扫榜选材:抓取七猫排行榜公开页,产出选题分析素材。
2
+ """扫榜选材:抓取公开排行榜页,产出选题分析素材。
3
3
 
4
- 数据源:www.qimao.com/paihang/(公开可抓,2026-09-19 实测 200/106KB,
5
- 书名与分类在 a 标签中文文本里)。下载走 curl 子进程(与 covergen 同款,
6
- Python 不经手响应体以外的东西),解析用宽松正则——页面结构变了宁可
7
- 返回空(调用方回落普通直连提示词),不做脆弱的强解析。
4
+ 数据源(双源聚合,任一失败不影响另一源):
5
+ - 七猫:www.qimao.com/paihang/(公开可抓,2026-09-19 实测 200/106KB)
6
+ - 番茄 Web 版:fanqienovel.com/rank(公开可抓,题材分类+书名在
7
+ 中文 a 标签里;App 端接口有 SecuritySign/X-Argus 签名——那才是难路,
8
+ Web 版榜单页不需要签名,别走弯路)
9
+
10
+ 下载走 curl 子进程(与 covergen 同款),解析用宽松正则——页面结构变了
11
+ 宁可返回空(调用方回落普通直连提示词),不做脆弱的强解析。
8
12
  """
9
13
  from __future__ import annotations
10
14
 
@@ -13,31 +17,40 @@ import re
13
17
 
14
18
  log = logging.getLogger(__name__)
15
19
 
16
- RANK_URL = "https://www.qimao.com/paihang/"
20
+ QIMAO_RANK_URL = "https://www.qimao.com/paihang/"
21
+ FANQIE_RANK_URL = "https://fanqienovel.com/rank"
17
22
  _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
18
23
  "(KHTML, like Gecko) Chrome/126 Safari/537.36")
19
24
 
20
- # 页面 UI 噪音词(非书目/分类)
25
+ # 页面 UI 噪音词(非书目/分类):页面 chrome、导航、tab 名、登录/搜索等
21
26
  _NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
22
- "排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生"}
27
+ "排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生",
28
+ "帮助中心", "作家助手", "客服", "登录", "注册", "立即登录",
29
+ "搜索", "热门作品排行", "大热榜", "新书榜", "完结榜", "收藏榜",
30
+ "更新榜", "日榜", "月榜", "蝉联", "榜首", "热度", "免费", "畅销",
31
+ "下载", "客户端", "首页", "我的", "设置", "更多", "返回", "关于"}
23
32
 
33
+ _CJK_RE = re.compile(r">([\u4e00-\u9fa5]{2,12})<")
24
34
 
25
- def fetch_rank_items(limit=40):
26
- """抓七猫排行榜页并提取中文元素材(书名/分类混排,去 UI 噪音)。
27
35
 
28
- 返回元素列表(保序去重,最多 limit 个);抓取失败返回 []——调用方
29
- 回落普通直连提示词,扫榜永远不挡任务。"""
36
+ def _curl_text(url):
37
+ """curl 子进程抓页面文本;失败返回空串。"""
30
38
  from . import runner
31
39
  try:
32
40
  r = runner.run_process(
33
- argv=["curl", "-sS", "--max-time", "20", "-A", _UA, RANK_URL],
41
+ argv=["curl", "-sS", "--max-time", "20", "-A", _UA, url],
34
42
  timeout=30)
35
43
  except Exception:
36
- return []
44
+ return ""
37
45
  if not r.get("ok"):
38
- return []
46
+ return ""
47
+ return r.get("stdout") or ""
48
+
49
+
50
+ def _extract_cjk(html, limit):
51
+ """从 HTML 提取中文元素材(去 UI 噪音、保序去重)。"""
39
52
  items, seen = [], set()
40
- for t in re.findall(r">([\u4e00-\u9fa5]{2,12})<", r.get("stdout") or ""):
53
+ for t in _CJK_RE.findall(html or ""):
41
54
  if t in _NOISE or t in seen:
42
55
  continue
43
56
  seen.add(t)
@@ -47,18 +60,49 @@ def fetch_rank_items(limit=40):
47
60
  return items
48
61
 
49
62
 
63
+ def fetch_qimao_rank(limit=30):
64
+ """七猫排行榜:返回元素列表,抓取失败返回 []。"""
65
+ return _extract_cjk(_curl_text(QIMAO_RANK_URL), limit)
66
+
67
+
68
+ def fetch_fanqie_rank(limit=30):
69
+ """番茄 Web 版榜单:返回元素列表,抓取失败返回 []。"""
70
+ return _extract_cjk(_curl_text(FANQIE_RANK_URL), limit)
71
+
72
+
73
+ def fetch_rank_items(limit=40):
74
+ """双源聚合:七猫+番茄各抓一份(去重合并,七猫在前)。
75
+
76
+ 两源都失败才返回 []——单源挂了另一源仍可用,扫榜永不因单点挡任务。"""
77
+ qm = fetch_qimao_rank(limit)
78
+ fq = fetch_fanqie_rank(limit)
79
+ merged, seen = [], set(qm)
80
+ for t in fq:
81
+ if t not in seen:
82
+ seen.add(t)
83
+ merged.append(t)
84
+ return qm + merged
85
+
86
+
50
87
  def rank_scan_prompt(goal):
51
- """组装扫榜选材分析提示词。榜单抓取失败返回 None(调用方回落直连提示词)。"""
52
- items = fetch_rank_items()
53
- if not items:
88
+ """组装扫榜选材分析提示词。双源都空返回 None(调用方回落直连提示词)。"""
89
+ qm = fetch_qimao_rank(30)
90
+ fq = fetch_fanqie_rank(30)
91
+ qm = [t for t in qm if t not in fq] # 双源重合的只留七猫份
92
+ if not qm and not fq:
54
93
  return None
55
- material = "、".join(items)
94
+ blocks = []
95
+ if qm:
96
+ blocks.append("### 七猫排行榜素材\n" + "、".join(qm))
97
+ if fq:
98
+ blocks.append("### 番茄排行榜素材\n" + "、".join(fq))
99
+ material = "\n\n".join(blocks)
56
100
  return (
57
- "你是网文选题分析师。以下是刚刚抓取的七猫排行榜页面的书目与分类素材:\n\n"
101
+ "你是网文选题分析师。以下是刚刚抓取的两个平台排行榜页面的书目与分类素材:\n\n"
58
102
  "## 榜单元素材\n%s\n\n"
59
103
  "## 用户想写的方向\n%s\n\n"
60
104
  "## 你的产出(Markdown 报告)\n"
61
- "1. **热门题材 Top3**:各自的共同特征与上榜代表书目\n"
105
+ "1. **热门题材 Top3**:各自的共同特征与上榜代表书目(跨平台重合的题材单独点出——双平台都热说明是真风口)\n"
62
106
  "2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
63
107
  "3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
64
108
  "直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"
@@ -1485,6 +1485,49 @@ def _plot_modules(workdir):
1485
1485
  "鼓励化用,不要照抄原句)\n\n" + txt)
1486
1486
 
1487
1487
 
1488
+ def _shrink_context_block(sk_block, bible, budget=12000):
1489
+ """分层上下文降级(长提示词在容量受限通道上会挂起/秒拒,2026-09-17 讯飞实测)。
1490
+
1491
+ 四层优先级:故事圣经(最高,设定冲突以它为准)> 剧情模块库 > 经验库 > 大纲/前情
1492
+ (后两者在提示词正文里,永不动)。超预算时按优先级保序截断:
1493
+ - 圣经截断保整段(按二级标题边界,无边界才硬截)
1494
+ - 模块库截断保整模块(按「## 」标题边界)
1495
+ - 经验库直接硬截(条目本身短,损失最小)
1496
+ 返回 (新 sk_block, 降级说明)。无降级返回原样。"""
1497
+ total = len(sk_block or "") + len(bible or "")
1498
+ if total <= budget:
1499
+ return sk_block, bible, ""
1500
+ notes = []
1501
+ # 1) 先压经验库到 4K(条目短、损失最小)
1502
+ if len(sk_block or "") > 4000:
1503
+ sk_block = sk_block[:4000] + "\n\n(经验库已因上下文容量限制精简)"
1504
+ notes.append("经验库→4K")
1505
+ if len(sk_block) + len(bible or "") <= budget:
1506
+ return sk_block, bible, ";".join(notes)
1507
+ # 2) 模块库按模块边界截断
1508
+ if bible and "## 剧情模块库" in bible:
1509
+ head, sep, mods = bible.partition("## 剧情模块库")
1510
+ mods = sep + mods
1511
+ keep = mods[:6000]
1512
+ cut = keep.rfind("\n## ")
1513
+ if cut > 200:
1514
+ keep = keep[:cut]
1515
+ bible = head + keep + "\n\n(模块库已因上下文容量限制精简)"
1516
+ notes.append("模块库→边界截断")
1517
+ if len(sk_block) + len(bible) <= budget:
1518
+ return sk_block, bible, ";".join(notes)
1519
+ # 3) 圣经按二级标题边界截断到预算
1520
+ if bible:
1521
+ budget_left = max(2000, budget - len(sk_block or ""))
1522
+ keep = bible[:budget_left]
1523
+ cut = keep.rfind("\n## ")
1524
+ if cut > 500:
1525
+ keep = keep[:cut]
1526
+ bible = keep + "\n\n(圣经已因上下文容量限制精简)"
1527
+ notes.append("圣经→边界截断")
1528
+ return sk_block, bible, ";".join(notes)
1529
+
1530
+
1488
1531
  def _critic_lens(critics, agent):
1489
1532
  """该评审的专属视角;单评审/手动指定时不播种(无从轮换,也别稀释注意力)。"""
1490
1533
  try:
@@ -1780,6 +1823,19 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1780
1823
  if t:
1781
1824
  tails.append("(第 %d 章结尾)…%s" % (j, t[-260:].strip()))
1782
1825
  prev = "\n".join(tails) or "(无)"
1826
+ # findings 中期记忆(借鉴 agentmemory 持久记忆):此前各章的关键事实
1827
+ # 追加在 .codebee/findings.md,注入时只取前一章之前的记录(当章发现
1828
+ # 会在当章评审后追加进来)。300+ 章长篇的前情只看近 2 章不够,
1829
+ # findings 填补中期记忆空洞。失败静默。
1830
+ try:
1831
+ fd_p = os.path.join(workdir, ".codebee", "findings.md")
1832
+ if os.path.isfile(fd_p):
1833
+ fd_txt = _read_text_any_enc(fd_p)
1834
+ if fd_txt:
1835
+ # 截到 3000 字防无限膨胀;只在尾部追加时自动增长,注入头固定
1836
+ prev += "\n\n## 此前章节发现摘要\n" + fd_txt[:3000]
1837
+ except Exception:
1838
+ pass
1783
1839
 
1784
1840
  # 评审-修订(每章至多 1 轮修订)
1785
1841
  rounds_used = 1
@@ -1929,10 +1985,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1929
1985
  time.sleep(30 * draft_attempt) # 30s / 60s 退避
1930
1986
  if draft_attempt and len(prompt) > 12000 and sk_block and sk_block in prompt:
1931
1987
  # 长提示词在容量受限通道(讯飞托管 35B 等)上会挂起/秒拒
1932
- # ——降级重试:经验库块截到 4K 字,保留大纲/前情/本章要点
1933
- # 2026-09-17 七猫实测:全量 30KB 对讯飞必挂)
1934
- use_prompt = prompt.replace(
1935
- sk_block, sk_block[:4000] + "\n\n(经验库已因通道容量限制精简)")
1988
+ # ——分层降级:经验库→4K、模块库按模块边界、圣经按二级标题
1989
+ # 边界,保大纲/前情/本章要点(2026-09-17 七猫实测:全量
1990
+ # 30KB 对讯飞必挂)
1991
+ sk2, bible2, _note = _shrink_context_block(sk_block, bible, budget=12000)
1992
+ use_prompt = prompt.replace(sk_block, sk2).replace(bible, bible2)
1936
1993
  res = _run_step(run_id, "draft-c%d" % i, modelhub.bind_agent(impl, difficulty), use_prompt,
1937
1994
  step_wd, readonly=False, ev=ev, timeout=2400,
1938
1995
  resume=resume_ctx["session"] if resume_ctx else None,
@@ -2188,6 +2245,22 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2188
2245
  "words": _wc(_read_chapter(workdir, i))})
2189
2246
  # 每章即时持久化:长篇中断/超时后可断点续跑,不丢已完成章的分数
2190
2247
  store.update_run(run_id, chapter_scores=chapter_scores)
2248
+ # findings 沉淀(借鉴 agentmemory 持久记忆):章节标题+要点追加到
2249
+ # .codebee/findings.md——后续章节起草时随圣经/模块库注入,弥补
2250
+ # 前情提要只看近 2 章结尾的中期记忆空洞。失败静默。
2251
+ try:
2252
+ fd_p = os.path.join(workdir, ".codebee", "findings.md")
2253
+ os.makedirs(os.path.dirname(fd_p), exist_ok=True)
2254
+ header_needed = not os.path.isfile(fd_p)
2255
+ with open(fd_p, "a", encoding="utf-8") as f:
2256
+ if header_needed:
2257
+ f.write("# 章节发现(每章评审达标后自动追加,供后续章节参考)\n\n")
2258
+ f.write("- 第%d章《%s》:%s(%d 字,%s)\n" % (
2259
+ i, ch["title"],
2260
+ ch.get("beats") or "按大纲推进", chapter_scores[-1]["words"],
2261
+ "%.1f 分" % means.get("情节", 0.0) if means else "无评分"))
2262
+ except Exception:
2263
+ pass
2191
2264
 
2192
2265
  # ---- 3) 全局一致性评审(覆盖 1..end 全书:续写批次必须连同旧章一起查一致性)
2193
2266
  full_text = "\n\n".join(_read_chapter(workdir, i) for i in range(1, end + 1))
@@ -78,6 +78,39 @@ def _free_port():
78
78
  return port
79
79
 
80
80
 
81
+ def _debug_ports_for_profile(user_data_dir):
82
+ """扫描本机浏览器进程命令行,返回使用该 profile 的主实例的调试端口。
83
+
84
+ 跨平台:Windows 走 wmic(PS 启动太慢),POSIX 走 ps。只认主进程
85
+ (--type= 子进程没有调试端口)。路径按小写+正反斜杠归一后比对。"""
86
+ import subprocess as _sp
87
+ try:
88
+ if sys.platform == "win32":
89
+ out = _sp.check_output(
90
+ ["wmic", "process", "where", "Name='msedge.exe'", "get", "CommandLine"],
91
+ stderr=_sp.DEVNULL, timeout=12).decode("utf-8", "replace")
92
+ lines = out.splitlines()
93
+ else:
94
+ out = _sp.check_output(["ps", "-axo", "command"], timeout=12).decode()
95
+ lines = [l for l in out.splitlines()
96
+ if "msedge" in l or "chrome" in l or "chromium" in l]
97
+ except Exception:
98
+ return []
99
+ key = str(user_data_dir).replace("\\", "/").strip("/").lower()
100
+ ports = []
101
+ for ln in lines:
102
+ ln = ln.strip()
103
+ if not ln or "--type=" in ln or "--remote-debugging-port=" not in ln:
104
+ continue
105
+ norm = ln.replace("\\\\", "/").replace("\\", "/").lower()
106
+ if key not in norm:
107
+ continue
108
+ m = re.search(r"--remote-debugging-port=(\d+)", ln)
109
+ if m:
110
+ ports.append(int(m.group(1)))
111
+ return ports
112
+
113
+
81
114
  class Browser:
82
115
  """一个浏览器子进程(一个 profile 一个实例)+ 它的调试端口。"""
83
116
 
@@ -106,7 +139,24 @@ class Browser:
106
139
  except OSError as e:
107
140
  raise BrowserError("浏览器启动失败:%s" % e)
108
141
  if not self._wait_ready(15.0):
109
- raise BrowserError("浏览器调试端口 15 秒内没就绪(可能被安全软件拦截)")
142
+ # Edge 对已有实例的 profile:新进程转交参数后秒退(stdout 会打
143
+ # 「正在现有浏览器会话中打开」),调试端口是老实例自己的(甚至
144
+ # 自选的,真机实测指定 59852 实际跑 56394)。等自己指定的端口必然
145
+ # 超时——先扫进程命令行找该 profile 的真端口接管(含隐藏的后台
146
+ # 常驻实例:Edge「启动加速/后台运行」的进程锁着 profile 不可见)。
147
+ for p in _debug_ports_for_profile(self.user_data_dir):
148
+ if p != self.port and port_alive(p, timeout=2.0):
149
+ self.port = p
150
+ self.proc = None # 老实例不是本对象起的:close 不杀它
151
+ return
152
+ if self.proc.poll() == 0:
153
+ raise BrowserError(
154
+ "浏览器把启动请求转给了已在运行的会话(该平台浏览器有隐藏的"
155
+ "后台实例占着档案)——请在任务管理器结束所有 Edge 进程,"
156
+ "或重启电脑后重试;登录态不受影响")
157
+ raise BrowserError(
158
+ "浏览器调试端口 15 秒内没就绪:该平台可能已有浏览器实例在跑但"
159
+ "端口探测不到——关掉它的所有窗口后重试,或重启电脑后首次连接")
110
160
 
111
161
  # ------------------------------------------------------------ 生命周期
112
162
  def _wait_ready(self, timeout):
@@ -404,25 +454,32 @@ class Page:
404
454
  raise BrowserError("点击失败:%s" % ((r or {}).get("err") or sel))
405
455
  return True
406
456
 
407
- 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):
408
459
  """按文本真实点击:JS 定位元素中心坐标 → CDP Input 派发鼠标事件序列。
409
460
 
410
461
  qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
411
462
  el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
463
+ y_min/y_max 限定元素纵向范围(区分同名 select、限制弹层内点击)。
412
464
  返回 {ok, tag?, via};找不到元素返回 {ok: False, err}。"""
413
465
  r = self.call(
414
- "(t,scope,c)=>{"
415
- "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);"
416
469
  "let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
417
470
  "return x&&(c?x.includes(t):x===t);});"
418
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);"
419
474
  "if(!cands.length)return{ok:false,err:'nf'};"
420
475
  "cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
421
476
  "const el=cands[0];el.scrollIntoView({block:'center'});"
422
477
  "const rc=el.getBoundingClientRect();"
423
478
  "return{ok:true,x:Math.round(rc.x+rc.width/2),y:Math.round(rc.y+rc.height/2),"
424
479
  "tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
425
- 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)
426
483
  if not (r or {}).get("ok"):
427
484
  return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素" % text}
428
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
 
@@ -96,3 +110,19 @@ def tag_groups(meta):
96
110
  if isinstance(v, list) and v:
97
111
  out.append((key, [str(x) for x in v]))
98
112
  return out
113
+
114
+
115
+ def editor_url(book):
116
+ """章节编辑器直达(真机实测):/main/writer/<book_id>/publish/。"""
117
+ bid = str((book or {}).get("book_id") or "")
118
+ if bid:
119
+ return "https://fanqienovel.com/main/writer/%s/publish/" % bid
120
+ return CONFIG["home"]
121
+
122
+
123
+ def chapter_manage_url(book):
124
+ """章节管理页(上线验证用)。"""
125
+ bid = str((book or {}).get("book_id") or "")
126
+ if bid:
127
+ return "https://fanqienovel.com/main/writer/chapter-manage/" + bid
128
+ return CONFIG["home"] + "book-manage"
@@ -109,7 +109,8 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
109
109
  for k, v in (values or {}).items(): # editor_url/draft_url 等任务级占位
110
110
  url = url.replace("{%s}" % k, str(v))
111
111
  note(i, "打开 %s" % url)
112
- page.navigate(url)
112
+ # 重页(编辑器/管理列表)可按步放宽;缺省 45s(30s 对慢网偏紧)
113
+ page.navigate(url, timeout=float(st.get("timeout") or 45))
113
114
  elif act == "wait":
114
115
  note(i, "等待 %s" % st["sel"])
115
116
  page.wait_for(st["sel"], timeout=float(st.get("timeout") or 12))
@@ -145,6 +146,8 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
145
146
  # 真实鼠标事件点击(CDP Input 派发):qm-btn 等自定义按钮
146
147
  # 只认真实事件序列,el.click() 无效。发布确认链全靠它。
147
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))
148
151
  for k, v in (values or {}).items():
149
152
  text = text.replace("{%s}" % k, str(v))
150
153
  if not text:
@@ -153,7 +156,9 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
153
156
  r = None
154
157
  for _try in range(int(st.get("tries") or 25)):
155
158
  r = page.real_click_text(text, st.get("scope") or
156
- "a,button,[class*=btn]")
159
+ "a,button,[class*=btn]",
160
+ y_min=st.get("y_min"),
161
+ y_max=st.get("y_max"))
157
162
  if (r or {}).get("ok"):
158
163
  break
159
164
  time.sleep(0.4)
@@ -194,6 +199,27 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
194
199
  time.sleep(0.9)
195
200
  if not (r or {}).get("ok"):
196
201
  raise FlowError((r or {}).get("err") or "click_in 失败")
202
+ elif act == "click_arrow":
203
+ # 展开下拉按钮组(番茄「下一步▾」):点目标按钮组右缘 10px
204
+ note(i, "展开下拉箭头")
205
+ r = page.call(
206
+ "(t)=>{"
207
+ "const vis=e=>e.getBoundingClientRect().width>0;"
208
+ "const btn=[...document.querySelectorAll('button')].find(e=>"
209
+ "vis(e)&&(e.innerText||'').trim().includes(t));"
210
+ "if(!btn)return{ok:false,err:'按钮未找到'};"
211
+ "const host=btn.closest('[class*=group],[class*=dropdown]')||btn.parentElement;"
212
+ "const r=host.getBoundingClientRect();"
213
+ "return{ok:true,x:Math.round(r.x+r.width-10),y:Math.round(r.y+r.height/2)};}", str(st.get("target") or "下一步"))
214
+ if not (r or {}).get("ok"):
215
+ raise FlowError((r or {}).get("err") or "箭头定位失败")
216
+ page.send("Input.dispatchMouseEvent",
217
+ {"type": "mousePressed", "x": r["x"], "y": r["y"],
218
+ "button": "left", "clickCount": 1}, timeout=8.0)
219
+ page.send("Input.dispatchMouseEvent",
220
+ {"type": "mouseReleased", "x": r["x"], "y": r["y"],
221
+ "button": "left", "clickCount": 1}, timeout=8.0)
222
+ time.sleep(float(st.get("settle") or 1.2))
197
223
  elif act == "click_match":
198
224
  # 关键词选块(站点卡片等):点同时包含所有关键词的最小元素
199
225
  keys = [str(x) for x in (st.get("any") or []) if str(x).strip()]
@@ -312,6 +338,26 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
312
338
  note(i, "提交 %s" % st.get("sel") or "")
313
339
  page.wait_for(st["sel"], timeout=8)
314
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, "已消失")
315
361
  elif act == "verify":
316
362
  # 上线验证:导航到验证页断言文本在场——防「流程完成但平台
317
363
  # 静默未发布」的假成功(0 字草稿案)。url/any 支持 values 占位。