codebee 0.1.14 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
+ ## v0.1.15(2026-09-19)
8
+
9
+ - 禅道集成:定时扫描激活 Bug → 自动建修复任务 → 跑完自动合并 + resolve(fixed) + 报告回写 + 群通知(REST v1,SSRF 守卫;设置页「禅道」子页)
10
+ - 扫榜双源聚合:七猫+番茄 Web 版排行榜合并分析,跨平台重合题材单独标记(信号更强)
11
+ - 可分享报告页:运行报告导出为自包含单文件 HTML,发给任何人浏览器直接打开
12
+ - 出站 HTTPS 加固:统一证书校验上下文,修复 macOS python.org 发行版缺 CA 导致的全部外联失败(校验只增不减)
13
+ - 引擎调参卡:设置页可直接调压缩阈值/尾部保留/goal 轮数等引擎参数(schema 驱动,revision CAS 防并发覆盖)
14
+ - 发布浏览器 profile 迁至 ~/.codebee(登录态自动迁移),不再占用仓库目录与拖慢扫描
15
+ - 连载发布真机校准续:番茄编辑器结构校准、发章导航超时放宽、真实鼠标事件提交(合成点击被平台检测)
16
+
7
17
  ## v0.1.14(2026-09-19)
8
18
 
9
19
  - 新任务类型「扫榜选材」:抓取七猫排行榜公开数据,AI 提炼热门题材 Top3/高频人设套路/差异化切入建议,直出选题洞察报告
package/README.md CHANGED
@@ -21,14 +21,14 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
21
21
  不会把你的任务内容交给任何第三方。
22
22
 
23
23
  <!-- relnotes:start -->
24
- ### 最新版更新内容(v0.1.11
25
-
26
- - **连载发布闭环**:番茄/七猫作家后台一键发布——连接扫码后自动建书/发章(默认填好表单人工确认提交),出版台面板+发布台账+每日上限/连续失败暂停护栏
27
- - **定时自动发布**:任务到点自动批量发章,直发模式需流程校准文件解锁
28
- - 代码任务 Best-of-N 赛马兜底;goal 模糊时先问 1-3 个澄清问题再开跑
29
- - 任务队列自愈(看门狗+排队回填);同因连撞不再重复退避付费,退避期明示「将于 HH:MM 自动续跑」
30
- - 运行前自动同步 CLI 配置:绑定换供应商后不再撞上旧配置空跑
31
- - 评审新增「AI 味」参考线;任务留 .codebee/spec.md 规格档案;报告区空态可见
24
+ ### 最新版更新内容(v0.1.15
25
+
26
+ - **禅道集成**:定时扫描激活 Bug → 自动建修复任务 → 跑完自动合并 + resolve + 报告回写 + 群通知
27
+ - **扫榜选材(双源)**:七猫+番茄排行榜抓取聚合,AI 提炼热门题材/人设套路/差异化切入,跨平台重合题材单独标记
28
+ - **可分享报告页**:运行报告一键导出自包含单文件 HTML
29
+ - **引擎调参卡**:设置页直接调压缩阈值/goal 轮数等引擎参数(防并发覆盖)
30
+ - 出站 HTTPS 证书校验加固(修复部分 Mac 上的全外联失败);发布浏览器 profile 迁出仓库目录
31
+ - 群推送(钉钉/飞书/企微自动适配)与「扫榜选材」任务类型见 v0.1.14
32
32
  <!-- relnotes:end -->
33
33
 
34
34
  ---
@@ -157,6 +157,19 @@ npm install -g codebee
157
157
  用 Homebrew 装的 Node(prefix 在 `/opt/homebrew`)没有这个问题,可直接安装。
158
158
  验证 `which codebee` 指向 `~/.npm-global/bin/codebee` 即配置成功。
159
159
 
160
+ ### macOS:HTTPS 请求报 SSL 证书错误(CERTIFICATE_VERIFY_FAILED)
161
+
162
+ python.org 安装的 Python 不读 macOS 系统钥匙串,本机又没有可用的 CA 束时,
163
+ 「获取模型列表 / 适配测试 / 插件市场」等所有 HTTPS 检测会报
164
+ `certificate verify failed: unable to get local issuer certificate`。
165
+ CodeBee 已内置兜底:出站请求自动加载 certifi 与 macOS 系统证书束
166
+ (`/etc/ssl/cert.pem`),多数机器无需任何操作。若仍报错,补齐证书后重试:
167
+
168
+ ```bash
169
+ python3 -m pip install --upgrade certifi # CodeBee 自动采用,装完即生效
170
+ # 或:Finder → 应用程序 → Python 3.x → 双击 Install Certificates.command
171
+ ```
172
+
160
173
  **方式二:源码运行(开发者)**
161
174
 
162
175
  ```bat
@@ -387,6 +387,13 @@ def _tick():
387
387
  log.info("automation: 定时发布触发 %d 个任务", n)
388
388
  except Exception:
389
389
  log.debug("automation: 定时发布检查跳过", exc_info=True)
390
+ # 禅道 Bug 扫描联动:fire_due 内部自节流(未启用/没到点零开销),
391
+ # 对账回写也在同一入口,这里只当调度宿主(同定时发布模式)。
392
+ try:
393
+ from . import zentao as _zentao
394
+ _zentao.fire_due()
395
+ except Exception:
396
+ log.debug("automation: 禅道扫描跳过", exc_info=True)
390
397
 
391
398
 
392
399
  def _loop():
@@ -248,7 +248,7 @@ def npm_pkg_name(cmd):
248
248
 
249
249
 
250
250
  def derive_uninstall(cmd):
251
- """从安装命令推导卸载命令(npm / winget / pip 三种本机渠道)。
251
+ """从安装命令推导卸载命令(npm / winget / pip / uv / brew 本机渠道)。
252
252
 
253
253
  卸载命令不单独维护一份,避免与安装命令不同步;认不出渠道返回 None,
254
254
  此时条目可显式配置 uninstall 字段覆盖。
@@ -275,6 +275,14 @@ def derive_uninstall(cmd):
275
275
  pkgs = [t for t in m.group(1).split() if not t.startswith("-")]
276
276
  if pkgs:
277
277
  return "uv tool uninstall %s" % pkgs[-1]
278
+ m = re.search(r"brew\s+install\b(.*)$", c)
279
+ if m:
280
+ # brew uninstall 按名字自动识别 formula/cask,--cask 这类 flag 丢了也
281
+ # 能卸对(Mac 渠道,AI 修复白名单已放行 brew install,编辑安装命令
282
+ # 也会填这种形态)
283
+ pkgs = [t for t in m.group(1).split() if not t.startswith("-")]
284
+ if pkgs:
285
+ return "brew uninstall %s" % pkgs[0]
278
286
  return None
279
287
 
280
288
 
@@ -16,9 +16,10 @@ _DENY_RE = re.compile(r"(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)", re.IGNOR
16
16
 
17
17
  # 白名单:精确匹配(不分大小写),用于覆盖黑名单。
18
18
  _ALLOW_EXACT = frozenset({
19
- # 系统/环境
20
- "PATH", "PATHEXT", "SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "USERDNSDOMAIN",
21
- "HOMEPATH", "HOMEDRIVE",
19
+ # 系统/环境(TEMP/TMP/PATHEXT 等是 Windows 名,TMPDIR 是 macOS 每用户
20
+ # 临时目录——三边各留各的,多出的名字在别的平台上不存在、无副作用)
21
+ "PATH", "PATHEXT", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR",
22
+ "USERPROFILE", "USERDNSDOMAIN", "HOMEPATH", "HOMEDRIVE",
22
23
  "LANG", "LC_ALL", "LC_CTYPE",
23
24
  "HOME", "SHELL", "TERM", "PYTHONIOENCODING",
24
25
  "PYTHONPATH", "PYTHONUNBUFFERED",
@@ -38,6 +39,7 @@ _ALLOW_PREFIX = (
38
39
  "TUTTI_", "ALLINONE_", "DSH_",
39
40
  "LANG", "LC_", # locale 系列
40
41
  "PYTHON", # PYTHONPATH / PYTHONIOENCODING 等
42
+ "XDG_", # XDG_CONFIG_HOME 等(macOS/Linux CLI 认它找配置/缓存落点)
41
43
  "CHROME", "EDGE", "PLAYWRIGHT", # 本机测试工具
42
44
  )
43
45
 
package/app/core/jobs.py CHANGED
@@ -5,11 +5,13 @@
5
5
  隔离,store 层有全局锁。目标并发数可在设置页调整;调小后多余线程在取到
6
6
  新任务前自行退出,调大即时补齐。安装/升级失败时自动触发 AI 诊断修复:
7
7
  由真实智能体读取失败日志与本机环境给出修正命令;仅当命令命中白名单前缀
8
- (npm/winget/pip 安装类)才自动执行,否则把建议命令记录在运行记录里等人工确认。
8
+ (npm/winget/brew/pip 安装类,按平台取对应渠道)才自动执行,否则把建议命令
9
+ 记录在运行记录里等人工确认。
9
10
  """
10
11
  from __future__ import annotations
11
12
 
12
13
  import queue
14
+ import sys
13
15
  import threading
14
16
  import traceback
15
17
 
@@ -27,10 +29,11 @@ WATCHDOG_INTERVAL_S = 60 # 队列看门狗巡检周期
27
29
  WATCHDOG_STALE_S = 120 # queued 超过该秒数视为掉队(正常入队到被拿起 ≤5s)
28
30
  _watchdog_started = False
29
31
 
30
- AI_REPAIR_PROMPT = """你是环境工程师。在 Windows 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
32
+ AI_REPAIR_PROMPT = """你是环境工程师。在 __OS__ 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
31
33
  只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
32
34
  {"diagnosis": "失败原因(一句话)", "command": "修正后的完整安装命令", "safe": true/false}
33
- 硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__ 之一。
35
+ 硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__ 之一,且不要带 sudo
36
+ (命令非交互执行,sudo 会挂死;brew/npm 本身也拒绝 sudo)。
34
37
  给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
35
38
 
36
39
  ## 失败的命令
@@ -43,14 +46,29 @@ __LOG__
43
46
  __ENV__"""
44
47
 
45
48
  # AI 修复命令白名单:只放行包管理器的安装类命令(提示词里的前缀清单由它生成,
46
- # 两处永远不会漂移)
47
- AI_REPAIR_ALLOW = ("npm install ", "winget install", "py -3.13 -m pip install",
48
- "uv tool install")
49
+ # 两处永远不会漂移)。按平台分组:winget/py 启动器是 Windows 独有,brew/python3
50
+ # macOS/Linux 渠道——Mac 上不放行 brew 的话,AI 给出正确的修正命令也会被拒。
51
+ _AI_REPAIR_ALLOW_WIN = ("npm install ", "winget install", "py -3.13 -m pip install",
52
+ "uv tool install")
53
+ _AI_REPAIR_ALLOW_UNIX = ("npm install ", "brew install", "python3 -m pip install",
54
+ "uv tool install")
55
+ AI_REPAIR_ALLOW = _AI_REPAIR_ALLOW_WIN # 兼容旧引用:Windows 本机即此表
49
56
 
50
57
 
51
- def _repair_command_allowed(cmd):
58
+ def _repair_os_label(platform=None):
59
+ return {"win32": "Windows", "darwin": "macOS"}.get(platform or sys.platform, "Linux")
60
+
61
+
62
+ def _repair_allow(platform=None):
63
+ if (platform or sys.platform) == "win32":
64
+ return _AI_REPAIR_ALLOW_WIN
65
+ return _AI_REPAIR_ALLOW_UNIX
66
+
67
+
68
+ def _repair_command_allowed(cmd, platform=None):
52
69
  cmd = (cmd or "").strip()
53
- return cmd.startswith(AI_REPAIR_ALLOW) and "|" not in cmd and "&" not in cmd and ">" not in cmd
70
+ return (cmd.startswith(_repair_allow(platform)) and "|" not in cmd
71
+ and "&" not in cmd and ">" not in cmd)
54
72
 
55
73
 
56
74
  def configure(max_workers):
@@ -592,17 +610,21 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
592
610
  except Exception:
593
611
  log_tail = "(日志不可读)"
594
612
  import shutil
613
+ is_win = sys.platform == "win32"
595
614
  env_lines = [
596
- "OS: Windows",
615
+ "OS: %s" % _repair_os_label(),
597
616
  "node: %s" % (shutil.which("node") or "缺失"),
598
617
  "npm: %s" % (shutil.which("npm") or "缺失"),
599
618
  "pnpm: %s" % (shutil.which("pnpm") or "缺失"),
600
- "python: %s" % (shutil.which("python") or "缺失"),
619
+ "python: %s" % (shutil.which("python3" if not is_win else "python") or "缺失"),
601
620
  ]
621
+ if not is_win:
622
+ env_lines.append("brew: %s" % (shutil.which("brew") or "缺失"))
602
623
  prompt = (AI_REPAIR_PROMPT.replace("__CMD__", failed_cmd or "(未知)")
603
624
  .replace("__LOG__", log_tail)
604
625
  .replace("__ENV__", "\n".join(env_lines))
605
- .replace("__ALLOW__", " / ".join(p.strip() for p in AI_REPAIR_ALLOW)))
626
+ .replace("__OS__", _repair_os_label())
627
+ .replace("__ALLOW__", " / ".join(p.strip() for p in _repair_allow())))
606
628
  step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
607
629
  note="自动诊断修复")
608
630
  res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
@@ -251,9 +251,9 @@ def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
251
251
  last_err = (url + " 无模型列表接口(HTTP 404)——"
252
252
  "模型以导入/手动添加为准,不影响对话调用")
253
253
  else:
254
- last_err = "%s → HTTP %s" % (url, e.code)
254
+ last_err = tlsctx.humanize("%s → HTTP %s" % (url, e.code))
255
255
  except Exception as e:
256
- last_err = "%s → %r" % (url, e)
256
+ last_err = tlsctx.humanize("%s → %r" % (url, e))
257
257
  return None, last_err
258
258
 
259
259
 
@@ -2405,7 +2405,7 @@ def _post_json_http(url, headers, body, allow_private, timeout=20):
2405
2405
  raw = resp.read(1024 * 1024)
2406
2406
  return resp.status, json.loads(raw.decode("utf-8", "replace")), ""
2407
2407
  except Exception as e:
2408
- return 0, None, repr(e)[:300]
2408
+ return 0, None, tlsctx.humanize(repr(e)[:300])
2409
2409
 
2410
2410
 
2411
2411
  def _sse_parse(proto, obj):
@@ -2506,7 +2506,7 @@ def _post_sse_http(url, headers, body, allow_private, timeout, proto, on_delta):
2506
2506
  if sum(len(s) for s in parts) > 2 * 1024 * 1024: # 防失控
2507
2507
  break
2508
2508
  except Exception as e:
2509
- return 0, "", None, repr(e)[:300]
2509
+ return 0, "", None, tlsctx.humanize(repr(e)[:300])
2510
2510
  text = "".join(parts)
2511
2511
  if not usage.get("total"):
2512
2512
  usage["total"] = usage.get("input", 0) + usage.get("output", 0) + usage.get("cached", 0)
@@ -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,37 @@ 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
25
  # 页面 UI 噪音词(非书目/分类)
21
26
  _NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
22
- "排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生"}
27
+ "排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生",
28
+ "帮助中心", "作家助手", "客服", "登录", "注册"}
23
29
 
30
+ _CJK_RE = re.compile(r">([\u4e00-\u9fa5]{2,12})<")
24
31
 
25
- def fetch_rank_items(limit=40):
26
- """抓七猫排行榜页并提取中文元素材(书名/分类混排,去 UI 噪音)。
27
32
 
28
- 返回元素列表(保序去重,最多 limit 个);抓取失败返回 []——调用方
29
- 回落普通直连提示词,扫榜永远不挡任务。"""
33
+ def _curl_text(url):
34
+ """curl 子进程抓页面文本;失败返回空串。"""
30
35
  from . import runner
31
36
  try:
32
37
  r = runner.run_process(
33
- argv=["curl", "-sS", "--max-time", "20", "-A", _UA, RANK_URL],
38
+ argv=["curl", "-sS", "--max-time", "20", "-A", _UA, url],
34
39
  timeout=30)
35
40
  except Exception:
36
- return []
41
+ return ""
37
42
  if not r.get("ok"):
38
- return []
43
+ return ""
44
+ return r.get("stdout") or ""
45
+
46
+
47
+ def _extract_cjk(html, limit):
48
+ """从 HTML 提取中文元素材(去 UI 噪音、保序去重)。"""
39
49
  items, seen = [], set()
40
- for t in re.findall(r">([\u4e00-\u9fa5]{2,12})<", r.get("stdout") or ""):
50
+ for t in _CJK_RE.findall(html or ""):
41
51
  if t in _NOISE or t in seen:
42
52
  continue
43
53
  seen.add(t)
@@ -47,18 +57,49 @@ def fetch_rank_items(limit=40):
47
57
  return items
48
58
 
49
59
 
60
+ def fetch_qimao_rank(limit=30):
61
+ """七猫排行榜:返回元素列表,抓取失败返回 []。"""
62
+ return _extract_cjk(_curl_text(QIMAO_RANK_URL), limit)
63
+
64
+
65
+ def fetch_fanqie_rank(limit=30):
66
+ """番茄 Web 版榜单:返回元素列表,抓取失败返回 []。"""
67
+ return _extract_cjk(_curl_text(FANQIE_RANK_URL), limit)
68
+
69
+
70
+ def fetch_rank_items(limit=40):
71
+ """双源聚合:七猫+番茄各抓一份(去重合并,七猫在前)。
72
+
73
+ 两源都失败才返回 []——单源挂了另一源仍可用,扫榜永不因单点挡任务。"""
74
+ qm = fetch_qimao_rank(limit)
75
+ fq = fetch_fanqie_rank(limit)
76
+ merged, seen = [], set(qm)
77
+ for t in fq:
78
+ if t not in seen:
79
+ seen.add(t)
80
+ merged.append(t)
81
+ return qm + merged
82
+
83
+
50
84
  def rank_scan_prompt(goal):
51
- """组装扫榜选材分析提示词。榜单抓取失败返回 None(调用方回落直连提示词)。"""
52
- items = fetch_rank_items()
53
- if not items:
85
+ """组装扫榜选材分析提示词。双源都空返回 None(调用方回落直连提示词)。"""
86
+ qm = fetch_qimao_rank(30)
87
+ fq = fetch_fanqie_rank(30)
88
+ qm = [t for t in qm if t not in fq] # 双源重合的只留七猫份
89
+ if not qm and not fq:
54
90
  return None
55
- material = "、".join(items)
91
+ blocks = []
92
+ if qm:
93
+ blocks.append("### 七猫排行榜素材\n" + "、".join(qm))
94
+ if fq:
95
+ blocks.append("### 番茄排行榜素材\n" + "、".join(fq))
96
+ material = "\n\n".join(blocks)
56
97
  return (
57
- "你是网文选题分析师。以下是刚刚抓取的七猫排行榜页面的书目与分类素材:\n\n"
98
+ "你是网文选题分析师。以下是刚刚抓取的两个平台排行榜页面的书目与分类素材:\n\n"
58
99
  "## 榜单元素材\n%s\n\n"
59
100
  "## 用户想写的方向\n%s\n\n"
60
101
  "## 你的产出(Markdown 报告)\n"
61
- "1. **热门题材 Top3**:各自的共同特征与上榜代表书目\n"
102
+ "1. **热门题材 Top3**:各自的共同特征与上榜代表书目(跨平台重合的题材单独点出——双平台都热说明是真风口)\n"
62
103
  "2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
63
104
  "3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
64
105
  "直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"
@@ -1472,9 +1472,6 @@ def _plot_modules(workdir):
1472
1472
  """剧情模块库(oh-story 拆文沉淀式):工作目录里的 plot-modules.md
1473
1473
  (可复用的桥段/冲突/爽点/名场面素材模块),作者手工维护,每章起草与
1474
1474
  评审前自动注入。不存在/为空返回 ""——约定式功能,零配置零噪音。"""
1475
- p = os.path.abspath(os.path.join(str(workdir or ""), MODULES_FILE))
1476
- if not _inside(workdir, p) or not os.path.isfile(p):
1477
- return ""
1478
1475
  try:
1479
1476
  txt = _read_text_any_enc(p)[:_MODULES_MAX_CHARS].strip()
1480
1477
  except OSError:
@@ -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,17 @@ 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:新进程转交参数后秒退,调试端口是
143
+ # 老实例自己的(甚至自选的,真机实测指定 59852 实际跑 56394)。
144
+ # 等自己指定的端口必然超时——扫进程命令行找该 profile 的真端口接管。
145
+ for p in _debug_ports_for_profile(self.user_data_dir):
146
+ if p != self.port and port_alive(p, timeout=2.0):
147
+ self.port = p
148
+ self.proc = None # 老实例不是本对象起的:close 不杀它
149
+ return
150
+ raise BrowserError(
151
+ "浏览器调试端口 15 秒内没就绪:该平台可能已有浏览器实例在跑但"
152
+ "端口探测不到——关掉它的所有窗口后重试,或重启电脑后首次连接")
110
153
 
111
154
  # ------------------------------------------------------------ 生命周期
112
155
  def _wait_ready(self, timeout):
@@ -96,3 +96,19 @@ def tag_groups(meta):
96
96
  if isinstance(v, list) and v:
97
97
  out.append((key, [str(x) for x in v]))
98
98
  return out
99
+
100
+
101
+ def editor_url(book):
102
+ """章节编辑器直达(真机实测):/main/writer/<book_id>/publish/。"""
103
+ bid = str((book or {}).get("book_id") or "")
104
+ if bid:
105
+ return "https://fanqienovel.com/main/writer/%s/publish/" % bid
106
+ return CONFIG["home"]
107
+
108
+
109
+ def chapter_manage_url(book):
110
+ """章节管理页(上线验证用)。"""
111
+ bid = str((book or {}).get("book_id") or "")
112
+ if bid:
113
+ return "https://fanqienovel.com/main/writer/chapter-manage/" + bid
114
+ 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))
@@ -194,6 +195,27 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
194
195
  time.sleep(0.9)
195
196
  if not (r or {}).get("ok"):
196
197
  raise FlowError((r or {}).get("err") or "click_in 失败")
198
+ elif act == "click_arrow":
199
+ # 展开下拉按钮组(番茄「下一步▾」):点目标按钮组右缘 10px
200
+ note(i, "展开下拉箭头")
201
+ r = page.call(
202
+ "(t)=>{"
203
+ "const vis=e=>e.getBoundingClientRect().width>0;"
204
+ "const btn=[...document.querySelectorAll('button')].find(e=>"
205
+ "vis(e)&&(e.innerText||'').trim().includes(t));"
206
+ "if(!btn)return{ok:false,err:'按钮未找到'};"
207
+ "const host=btn.closest('[class*=group],[class*=dropdown]')||btn.parentElement;"
208
+ "const r=host.getBoundingClientRect();"
209
+ "return{ok:true,x:Math.round(r.x+r.width-10),y:Math.round(r.y+r.height/2)};}", str(st.get("target") or "下一步"))
210
+ if not (r or {}).get("ok"):
211
+ raise FlowError((r or {}).get("err") or "箭头定位失败")
212
+ page.send("Input.dispatchMouseEvent",
213
+ {"type": "mousePressed", "x": r["x"], "y": r["y"],
214
+ "button": "left", "clickCount": 1}, timeout=8.0)
215
+ page.send("Input.dispatchMouseEvent",
216
+ {"type": "mouseReleased", "x": r["x"], "y": r["y"],
217
+ "button": "left", "clickCount": 1}, timeout=8.0)
218
+ time.sleep(float(st.get("settle") or 1.2))
197
219
  elif act == "click_match":
198
220
  # 关键词选块(站点卡片等):点同时包含所有关键词的最小元素
199
221
  keys = [str(x) for x in (st.get("any") or []) if str(x).strip()]
@@ -0,0 +1,37 @@
1
+ {
2
+ "_note": "番茄真机校准(2026-09-19 凌晨实测编辑器全结构;登录态随浏览器崩溃丢失,发布按钮链最后一步待登录后复验):编辑器=/main/writer/<book_id>/publish/ 直达;章节号=input.serial-input(纯数字);标题=div.serial-editor-input-hint input(≥5字,30字上限);正文=.ProseMirror(ProseMirror 编辑器,≥1000字);「下一步」=button(保存+校验,右侧箭头展开下拉菜单含「定时发布」);建书入口=book-manage 页「创建新书」(表单未实测)。",
3
+ "check_login": [
4
+ {"do": "navigate", "url": "{home}"},
5
+ {"do": "url_any", "any": ["fanqienovel.com"]}
6
+ ],
7
+ "upload_chapter": [
8
+ {"do": "navigate", "url": "about:blank"},
9
+ {"do": "navigate", "url": "{editor_url}"},
10
+ {"do": "url_any", "any": ["fanqienovel.com"]},
11
+ {"do": "wait", "sel": ".ProseMirror", "timeout": 20},
12
+ {"do": "fill", "sel": "input.serial-input, input.byte-input", "key": "chapter_no"},
13
+ {"do": "fill", "sel": "div.serial-editor-input-hint input", "key": "chapter_title"},
14
+ {"do": "fill", "sel": ".ProseMirror", "key": "chapter_body"},
15
+ {"do": "shot", "name": "chapter-filled"},
16
+ {"do": "click_real", "text": "下一步", "scope": "button", "tries": 15},
17
+ {"do": "click_arrow", "note": "展开下一步下拉(箭头在按钮组右缘)"},
18
+ {"do": "click_real", "text": "定时发布", "tries": 12, "optional": True},
19
+ {"do": "click_real", "text": "发布", "tries": 12, "optional": True},
20
+ {"do": "verify", "url": "{chapter_manage_url}", "any": ["{chapter_title}"], "settle": 6}
21
+ ],
22
+ "probe_form": [
23
+ {"do": "navigate", "url": "about:blank"},
24
+ {"do": "navigate", "url": "https://fanqienovel.com/main/writer/book-manage"},
25
+ {"do": "url_any", "any": ["fanqienovel.com"]},
26
+ {"do": "click_text", "text": "创建新书", "contains": true},
27
+ {"do": "probe", "note": "建书表单(待登录后校准)"}
28
+ ],
29
+ "create_book": [
30
+ {"do": "navigate", "url": "about:blank"},
31
+ {"do": "navigate", "url": "https://fanqienovel.com/main/writer/book-manage"},
32
+ {"do": "url_any", "any": ["fanqienovel.com"]},
33
+ {"do": "click_text", "text": "创建新书", "contains": true},
34
+ {"do": "probe", "note": "建书表单"},
35
+ {"do": "shot", "name": "create-book-form"}
36
+ ]
37
+ }