codebee 0.1.12 → 0.1.14

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,19 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
+ ## v0.1.14(2026-09-19)
8
+
9
+ - 新任务类型「扫榜选材」:抓取七猫排行榜公开数据,AI 提炼热门题材 Top3/高频人设套路/差异化切入建议,直出选题洞察报告
10
+ - 运行结果群推送:设置群机器人 webhook(钉钉/飞书/企微自动适配),任务跑完自动汇报状态与评分
11
+
12
+ ## v0.1.13(2026-09-19)
13
+
14
+ - 封面图生成:连载任务建书面板新增「生成封面」——调编排者供应商图像 API(cogview 候选,竖版优先)产出 cover.png 到运行目录,SSRF 边界校验 + curl 子进程落盘(Python 不经手图像字节)
15
+ - 需求拷问采访态:目标描述过短时先问 1-3 个澄清问题(选项芯片点选),创建更准
16
+ - 代码任务 Best-of-N:单路实现失败后多候选 worktree 隔离赛马择优
17
+ - 计划落盘任务档案:.codebee/ 三件套(spec.md 意图 / task_plan.md 计划 / evidence.md 验证证据)
18
+ - 步骤卡「实际派发模型」徽章 + 类型菜单成本预估行
19
+
7
20
  ## v0.1.12(2026-09-19)
8
21
 
9
22
  - 代码任务计划落盘:每次运行把编排计划写进工作目录 .codebee/task_plan.md,与任务规格(spec.md)、验证证据(evidence.md)同居「任务档案」,断点续跑/复盘全程可查
package/README.md CHANGED
@@ -136,6 +136,27 @@ codebee
136
136
  数据存放在用户目录(Windows `%APPDATA%\CodeBee`,macOS/Linux `~/.codebee`),
137
137
  升级/重装不影响;老版本 Tutti 目录(`%APPDATA%\Tutti`)会被自动沿用,无需迁移。
138
138
 
139
+ ### macOS:npm 全局安装报 EACCES(permission denied)
140
+
141
+ 官方 pkg 安装的 Node,全局目录 `/usr/local/lib/node_modules` 归 root,直接
142
+ `npm install -g codebee` 会报
143
+ `EACCES: permission denied, mkdir '/usr/local/lib/node_modules/codebee'`。
144
+ **不要用 sudo 装**:装完目录归 root,应用内「一键升级」(以普通用户跑
145
+ `npm install -g codebee@latest`)之后每次都会撞同样的权限错误。
146
+ 正确做法是把 npm 全局目录改到用户目录下,一次配好、安装与自动升级都畅通:
147
+
148
+ ```bash
149
+ mkdir -p ~/.npm-global
150
+ npm config set prefix "~/.npm-global"
151
+ echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc
152
+ source ~/.zshrc
153
+ npm install -g codebee
154
+ ```
155
+
156
+ 默认 shell 是 bash 的话,把第 3 行的 `~/.zshrc` 换成 `~/.bash_profile`。
157
+ 用 Homebrew 装的 Node(prefix 在 `/opt/homebrew`)没有这个问题,可直接安装。
158
+ 验证 `which codebee` 指向 `~/.npm-global/bin/codebee` 即配置成功。
159
+
139
160
  **方式二:源码运行(开发者)**
140
161
 
141
162
  ```bat
@@ -0,0 +1,200 @@
1
+ # -*- coding: utf-8 -*-
2
+ """封面图生成:调供应商图像 API 产出竖版封面插画,curl 直接落盘到运行目录。
3
+
4
+ 接口形状:OpenAI 兼容 POST {base}/images/generations(Z.ai cogview 系列、
5
+ 多数聚合网关都支持,默认返回图片 URL)。模型解析:环境变量
6
+ CODEBEE_IMAGE_MODEL 优先,否则逐个试候选(cogview-3-flash → cogview-4),
7
+ 第一个 2xx 的胜出;同一模型先试竖版尺寸再回落方图。
8
+ 状态机与建书生成同款(running/done/failed 写任务 cover_gen 字段,
9
+ bump_state 推 SSE)。仅支持 openai 协议供应商。
10
+
11
+ 安全边界:
12
+ - 图像 URL 仅接受 https,解析后 IP 命中私网/环回/链路本地一律拒绝
13
+ (防 SSRF 打内网与云元数据);禁用重定向(curl -L 不加)。
14
+ - Python 不经手图像字节:下载与落盘由 curl 子进程 -o 一步完成。
15
+ - 产物只落 paths.RUNS_DIR 受管路径(与 report.md 同款),不写任务工作目录。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import socket
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ _IMAGE_CANDIDATES = ("cogview-3-flash", "cogview-4")
29
+ _SIZES = ("768x1344", "1024x1024") # 竖版优先,方图兜底
30
+
31
+
32
+ def _cover_prompt(task):
33
+ """从任务与建书资料拼图像提示词:场景氛围向,不要文字(平台会自行压字)。"""
34
+ bm = ((task.get("book_meta") or {}).get("fanqie") or {}).get("data") or {}
35
+ if not isinstance(bm, dict):
36
+ bm = {}
37
+ title = bm.get("书名") or task.get("title") or ""
38
+ genre = bm.get("类型") or bm.get("分类") or ""
39
+ brief = (bm.get("一句话简介") or bm.get("简介") or task.get("goal") or "")
40
+ return ("竖版小说封面插画,画面中不要出现任何文字。题材:%s %s。故事梗概:%s。"
41
+ "商业网文封面质感:主体人物或核心场景突出,色彩浓郁有冲击力,"
42
+ "构图上方留白便于后期压标题。" % (genre, title, str(brief)[:300]))
43
+
44
+
45
+ def _pick_key(prov):
46
+ from . import modelhub
47
+ keys = modelhub._chain_keys(prov) or []
48
+ if keys and keys[0].get("key"):
49
+ return keys[0]["key"]
50
+ return prov.get("api_key") or ""
51
+
52
+
53
+ def _safe_image_url(url):
54
+ """SSRF 校验:仅 https;解析主机全部 IP,私网/环回/链路本地/保留段拒绝。"""
55
+ import ipaddress
56
+ from urllib.parse import urlparse
57
+ u = urlparse(url)
58
+ if u.scheme != "https":
59
+ raise ValueError("仅允许 https 图像地址")
60
+ host = u.hostname or ""
61
+ if not host:
62
+ raise ValueError("图像地址缺少主机")
63
+ infos = socket.getaddrinfo(host, u.port or 443, proto=socket.IPPROTO_TCP)
64
+ if not infos:
65
+ raise ValueError("图像主机无法解析")
66
+ for info in infos:
67
+ ip = ipaddress.ip_address(info[4][0])
68
+ if (ip.is_private or ip.is_loopback or ip.is_link_local
69
+ or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
70
+ raise ValueError("图像主机解析到受限地址,已拒绝")
71
+ return url
72
+
73
+
74
+ def _curl_to(url, out_path):
75
+ """curl 子进程下载落盘(Python 不经手图像字节;不加 -L 禁重定向)。"""
76
+ from . import runner
77
+ r = runner.run_process(
78
+ argv=["curl", "-sS", "--max-time", "180",
79
+ "--proto", "=https", "--fail",
80
+ "-o", str(out_path), url],
81
+ timeout=200)
82
+ if not r["ok"]:
83
+ raise RuntimeError((r.get("stderr") or r.get("stdout") or "下载失败")[:200])
84
+ p = Path(out_path)
85
+ if not p.is_file() or p.stat().st_size < 1024:
86
+ raise RuntimeError("下载内容过小或为空")
87
+
88
+
89
+ def _call_images(base, key, model, prompt, size, allow_private):
90
+ """POST /images/generations。返回 (data_item dict, 错误串);data_item 含 url 或 b64_json。"""
91
+ from . import builtin_agent
92
+ url = base.rstrip("/") + "/images/generations"
93
+ status, data, err = builtin_agent._post_json(
94
+ url,
95
+ {"Authorization": "Bearer " + key, "Content-Type": "application/json"},
96
+ {"model": model, "prompt": prompt, "size": size},
97
+ allow_private, 180)
98
+ if status == 0:
99
+ return None, err or "网络错误"
100
+ if 200 <= status < 300:
101
+ items = (data or {}).get("data") or []
102
+ if items and isinstance(items[0], dict):
103
+ return items[0], ""
104
+ return None, "响应缺少 data[0]"
105
+ last = str((data or {}).get("error", {}).get("message", "") if isinstance(data, dict) else "") \
106
+ or err or ("HTTP %s" % status)
107
+ return None, last
108
+
109
+
110
+ def make_cover(run_id, task):
111
+ """同步生成封面到运行目录。成功/失败返回 entry dict(写任务 cover_gen 用)。"""
112
+ from . import builtin_agent, modelhub, paths
113
+ try:
114
+ orch = modelhub.resolve_orchestrator()
115
+ if not orch:
116
+ raise RuntimeError("未配置编排者供应商(编排设置)")
117
+ prov, _m = orch
118
+ if str(prov.get("protocol") or "openai") not in ("openai", ""):
119
+ raise RuntimeError("封面生成仅支持 openai 协议供应商")
120
+ base = str(prov.get("base_url") or "").rstrip("/")
121
+ key = _pick_key(prov)
122
+ if not base or not key:
123
+ raise RuntimeError("供应商缺少 base_url 或密钥")
124
+ allow_private = bool(prov.get("allow_private"))
125
+ prompt = _cover_prompt(task)
126
+ models = []
127
+ env_model = os.environ.get("CODEBEE_IMAGE_MODEL", "").strip()
128
+ if env_model:
129
+ models.append(env_model)
130
+ models.extend(m for m in _IMAGE_CANDIDATES if m not in models)
131
+ last_err = ""
132
+ for model in models:
133
+ for size in _SIZES:
134
+ item, err = _call_images(base, key, model, prompt, size, allow_private)
135
+ if item is None:
136
+ last_err = err
137
+ continue
138
+ img_url = str(item.get("url") or "")
139
+ if not img_url:
140
+ last_err = "该供应商未返回图片 URL(仅内嵌数据),暂不支持"
141
+ continue
142
+ try:
143
+ safe_url = _safe_image_url(img_url)
144
+ except ValueError as e:
145
+ last_err = str(e)
146
+ continue
147
+ out_dir = paths.RUNS_DIR / str(run_id)
148
+ out_dir.mkdir(parents=True, exist_ok=True)
149
+ out = out_dir / "cover.png"
150
+ try:
151
+ _curl_to(safe_url, out)
152
+ except RuntimeError as e:
153
+ last_err = str(e)
154
+ continue
155
+ return {"status": "done", "file": "cover.png", "run_id": str(run_id),
156
+ "model": model, "size": size,
157
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
158
+ raise RuntimeError(last_err or "图像接口无可用模型")
159
+ except Exception as e:
160
+ return {"status": "failed", "error": str(e)[:300],
161
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
162
+
163
+
164
+ def generate_async(run_id, task_id, task):
165
+ """后台线程入口:生成封面并把终态写回任务 cover_gen(仿建书 generate_async)。"""
166
+ from . import store
167
+ if not store.get_task(task_id):
168
+ return
169
+ entry = make_cover(run_id, task)
170
+ cur = store.get_task(task_id)
171
+ if not cur:
172
+ return
173
+ prev = cur.get("cover_gen") or {}
174
+ if prev.get("status") != "running": # 期间被删/重置:丢弃结果
175
+ return
176
+ store.set_cover_gen(task_id, entry)
177
+
178
+
179
+ def start(task_id, run_id=None):
180
+ """起后台封面生成线程。run_id 缺省用该任务最近一次 run。返回 (ok, err)。running 幂等拒绝。"""
181
+ from . import store
182
+ task = store.get_task(task_id)
183
+ if not task:
184
+ return False, "任务不存在"
185
+ cur = task.get("cover_gen") or {}
186
+ if cur.get("status") == "running":
187
+ return True, ""
188
+ rid = run_id
189
+ if not rid:
190
+ runs = store.task_runs(task_id)
191
+ rid = (runs[-1].get("id") if runs else "")
192
+ if not rid:
193
+ return False, "没有可归属的运行记录(先跑一次任务再生成封面)"
194
+ if not store.set_cover_gen(task_id, {"status": "running",
195
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}):
196
+ return False, "任务不存在"
197
+ task = dict(task, cover_gen={"status": "running"})
198
+ threading.Thread(target=generate_async, daemon=True,
199
+ name="cover-gen-%s" % task_id, args=(rid, task_id, task)).start()
200
+ return True, ""
package/app/core/flows.py CHANGED
@@ -74,6 +74,9 @@ BUILTIN_FLOWS = [
74
74
  "threshold": 7.0, "rounds": 2,
75
75
  "goal_hint": "翻译什么(源文本位置 / 目标语言 / 要求)",
76
76
  "note": "起草 → 多维评审 → 修订循环 → 发布门禁"},
77
+ {"id": "rank_scan", "name": "扫榜选材", "icon": "i-chart", "engine": "direct", "builtin": True,
78
+ "goal_hint": "想写哪个方向(一句话,可留空默认分析总榜热门题材)",
79
+ "note": "抓取七猫排行榜公开数据 → AI 提炼热门题材/人设/差异化切入点(快档直出报告)"},
77
80
  {"id": "research", "name": "调研报告", "icon": "i-file-search", "engine": "review", "builtin": True,
78
81
  "manuscript": "report.md",
79
82
  "rubric": ["全面性", "深度", "论据可靠", "可读性", "结论质量"],
@@ -86,9 +86,18 @@ def get(goal_id: str):
86
86
  return dict(g) if g else None
87
87
 
88
88
 
89
- def create(title: str, description: str = "", *, rounds_max: int = 5,
89
+ def create(title: str, description: str = "", *, rounds_max: int | None = None,
90
90
  metadata: dict | None = None) -> dict:
91
- """创建新 goal。已有未完结 goal 时拒绝(单一当前目标,仿 dsh)。"""
91
+ """创建新 goal。已有未完结 goal 时拒绝(单一当前目标,仿 dsh)。
92
+
93
+ rounds_max 缺省读 settings_v2 orchestrator.max_goal_rounds(读不到回落 5)。"""
94
+ if rounds_max is None:
95
+ try:
96
+ from .settings_schema import get as ss_get, register_default_namespaces
97
+ register_default_namespaces()
98
+ rounds_max = int(ss_get("orchestrator", "max_goal_rounds") or 5)
99
+ except Exception:
100
+ rounds_max = 5
92
101
  with _LOCK:
93
102
  cur = current()
94
103
  if cur is not None:
package/app/core/jobs.py CHANGED
@@ -449,6 +449,11 @@ def _worker():
449
449
  _maybe_auto_resume(run_id) # 连载失败自动续跑(继承已完成章)
450
450
  except Exception:
451
451
  pass
452
+ try:
453
+ from . import notify
454
+ notify.push_run_async(run_id) # 结果推群(借鉴 agency-orchestrator --notify)
455
+ except Exception:
456
+ pass
452
457
  _QUEUE.task_done()
453
458
  finally:
454
459
  with _pool_lock:
@@ -36,7 +36,7 @@ import zipfile
36
36
  from pathlib import Path
37
37
  from urllib.parse import urlparse
38
38
 
39
- from . import market
39
+ from . import market, tlsctx
40
40
 
41
41
  _LOCK = market._LOCK # 安装/记账与 market 共用一把锁,避免交叉写 market.json
42
42
 
@@ -157,10 +157,13 @@ def _fetch(url, cap=_CAP_MANIFEST):
157
157
  return b"".join(chunks)
158
158
 
159
159
  try:
160
- return _read(lambda: urllib.request.urlopen(req, timeout=30)) # 默认 opener:含系统代理
160
+ return _read(lambda: urllib.request.urlopen(
161
+ req, timeout=30, context=tlsctx.context())) # 默认 opener:含系统代理
161
162
  except (urllib.error.HTTPError, urllib.error.URLError):
162
163
  # 直连重试:ProxyHandler({}) 显式清空代理
163
- opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
164
+ opener = urllib.request.build_opener(
165
+ urllib.request.ProxyHandler({}),
166
+ urllib.request.HTTPSHandler(context=tlsctx.context()))
164
167
  return _read(lambda: opener.open(req, timeout=30))
165
168
 
166
169
 
@@ -41,7 +41,7 @@ import threading
41
41
  import time
42
42
  import urllib.request
43
43
 
44
- from . import paths
44
+ from . import paths, tlsctx
45
45
 
46
46
 
47
47
  class _NoRedirect(urllib.request.HTTPRedirectHandler):
@@ -50,6 +50,13 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
50
50
  def redirect_request(self, req, fp, code, msg, headers, newurl):
51
51
  return None
52
52
 
53
+
54
+ def _opener():
55
+ """出站 opener:禁重定向 + tlsctx 兜底 CA(macOS python.org 版 Python
56
+ 缺系统证书,默认上下文验证必挂——见 tlsctx 模块 docstring)。"""
57
+ return urllib.request.build_opener(
58
+ _NoRedirect, urllib.request.HTTPSHandler(context=tlsctx.context()))
59
+
53
60
  _LOCK = threading.RLock()
54
61
  _FILE = paths.DATA_DIR / "models.json"
55
62
 
@@ -206,11 +213,11 @@ def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
206
213
  else:
207
214
  urls = std
208
215
  last_err = ""
209
- opener = urllib.request.build_opener(_NoRedirect)
216
+ opener = _opener()
210
217
  for url in urls:
211
- host_info = _validate_host(url, allow_private)
212
- if host_info is None:
213
- last_err = host_info[1]
218
+ host, herr = _validate_host(url, allow_private)
219
+ if host is None:
220
+ last_err = herr
214
221
  continue
215
222
  # auto 不知道是哪条 wire,鉴权头也按两种都试(google 那种单独补上)
216
223
  hdrs = _auth_header_variants(api_key, protocol)
@@ -237,6 +244,14 @@ def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
237
244
  if names:
238
245
  return names, ""
239
246
  last_err = url + " 返回 200 但未解析到模型"
247
+ except urllib.error.HTTPError as e:
248
+ if e.code == 404:
249
+ # zcode-plan / open.bigmodel.cn 等 anthropic 形网关没有
250
+ # GET /models——404 与密钥无关,模型以导入/手填为准
251
+ last_err = (url + " 无模型列表接口(HTTP 404)——"
252
+ "模型以导入/手动添加为准,不影响对话调用")
253
+ else:
254
+ last_err = "%s → HTTP %s" % (url, e.code)
240
255
  except Exception as e:
241
256
  last_err = "%s → %r" % (url, e)
242
257
  return None, last_err
@@ -1512,6 +1527,7 @@ def _src_zcode():
1512
1527
  default_model = str(d.get("model") or "")
1513
1528
  tail = default_model.split("/", 1)[1] if "/" in default_model else default_model
1514
1529
  bad = []
1530
+ nokey = []
1515
1531
  for pid, p in (d.get("provider") or {}).items():
1516
1532
  if not isinstance(p, dict):
1517
1533
  continue
@@ -1523,13 +1539,23 @@ def _src_zcode():
1523
1539
  "zcode", "zcode:%s" % pid,
1524
1540
  model=(tail if tail in names else ""), models=names)
1525
1541
  if pr:
1542
+ if not pr.get("api_key"):
1543
+ nokey.append(str(p.get("name") or pid))
1526
1544
  out["providers"].append(pr)
1527
1545
  else:
1528
1546
  bad.append(str(p.get("name") or pid))
1547
+ notes = []
1529
1548
  if bad:
1530
- out["note"] = "跳过 {0} 个(缺合法 baseURL):{1}".format(
1531
- len(bad), "、".join(bad[:4]))
1532
- out["note_args"] = [len(bad), "、".join(bad[:4])]
1549
+ notes.append("跳过 {0} 个(缺合法 baseURL):{1}".format(
1550
+ len(bad), "、".join(bad[:4])))
1551
+ if nokey:
1552
+ # ZCode 的 builtin Plan 条目(zcode.z.ai/api/v1/zcode-plan 等)密钥不入
1553
+ # config.json(实测 credentials.json 里的 OAuth token 也不能直接当 API
1554
+ # key 用)——静默导入空密钥条目只会让用户在拉列表/适配测试时一头雾水
1555
+ notes.append("{0} 个未带出密钥——请在「模型接入」页手填:{1}".format(
1556
+ len(nokey), "、".join(nokey[:4])))
1557
+ if notes:
1558
+ out["note"] = ";".join(notes)
1533
1559
  return out
1534
1560
 
1535
1561
 
@@ -2368,14 +2394,14 @@ def _post_json_http(url, headers, body, allow_private, timeout=20):
2368
2394
  p = urllib.parse.urlsplit(url)
2369
2395
  if p.scheme not in ("http", "https"):
2370
2396
  return 0, None, "协议必须是 http/https"
2371
- host_info = _validate_host(url, allow_private)
2372
- if host_info is None:
2373
- return 0, None, host_info[1]
2397
+ host, herr = _validate_host(url, allow_private)
2398
+ if host is None:
2399
+ return 0, None, herr
2374
2400
  try:
2375
2401
  req = urllib.request.Request(url, method="POST",
2376
2402
  headers=dict(headers, **{"Content-Type": "application/json"}),
2377
2403
  data=json.dumps(body).encode("utf-8"))
2378
- with urllib.request.build_opener(_NoRedirect).open(req, timeout=timeout) as resp:
2404
+ with _opener().open(req, timeout=timeout) as resp:
2379
2405
  raw = resp.read(1024 * 1024)
2380
2406
  return resp.status, json.loads(raw.decode("utf-8", "replace")), ""
2381
2407
  except Exception as e:
@@ -2440,15 +2466,16 @@ def _post_sse_http(url, headers, body, allow_private, timeout, proto, on_delta):
2440
2466
  p = urllib.parse.urlsplit(url)
2441
2467
  if p.scheme not in ("http", "https"):
2442
2468
  return 0, "", None, "协议必须是 http/https"
2443
- if _validate_host(url, allow_private) is None:
2444
- return 0, "", None, "目标地址校验未通过"
2469
+ host, herr = _validate_host(url, allow_private)
2470
+ if host is None:
2471
+ return 0, "", None, herr
2445
2472
  parts, usage = [], {}
2446
2473
  resp_status = 0
2447
2474
  try:
2448
2475
  req = urllib.request.Request(url, method="POST",
2449
2476
  headers=dict(headers, **{"Content-Type": "application/json"}),
2450
2477
  data=json.dumps(body).encode("utf-8"))
2451
- with urllib.request.build_opener(_NoRedirect).open(req, timeout=timeout) as resp:
2478
+ with _opener().open(req, timeout=timeout) as resp:
2452
2479
  resp_status = resp.status
2453
2480
  if not 200 <= resp.status < 300:
2454
2481
  raw = resp.read(65536).decode("utf-8", "replace")
@@ -0,0 +1,96 @@
1
+ # -*- coding: utf-8 -*-
2
+ """运行结果群推送(借鉴 agency-orchestrator 的 --notify):任务跑完把结果摘要
3
+ 推到钉钉/飞书/企业微信群机器人,配合定时自动化就是「AI 团队每天定点交活」。
4
+
5
+ webhook 一个地址全包——按域名自动适配三种机器人格式(同 agency-orchestrator
6
+ 思路);也接受任意 https 地址(按钉钉 text 形状发,自建 n8n 等自选)。
7
+ 推送在后台线程、失败只记日志——通知永远不影响任务本身。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ def _payload_for(webhook, text):
17
+ """按 webhook 域名适配群机器人消息体。返回 None = 无法识别的地址。"""
18
+ from urllib.parse import urlparse
19
+ host = (urlparse(webhook).hostname or "").lower()
20
+ if "dingtalk" in host:
21
+ return {"msgtype": "text", "text": {"content": text}}
22
+ if "feishu" in host or "larksuite" in host:
23
+ return {"msg_type": "text", "content": {"text": text}}
24
+ if "weixin" in host or "wechat" in host or "work.weixin" in host:
25
+ return {"msgtype": "text", "text": {"content": text}}
26
+ if host:
27
+ return {"msgtype": "text", "text": {"content": text}} # 未知域名按钉钉形状
28
+ return None
29
+
30
+
31
+ def _webhook():
32
+ try:
33
+ from . import settings
34
+ return str(settings.load().get("notify_webhook") or "").strip()
35
+ except Exception:
36
+ return ""
37
+
38
+
39
+ def _post(hook, text):
40
+ """curl 子进程 POST(Python 不经手响应体);网络失败只记日志。"""
41
+ from . import runner
42
+ import json as _json
43
+ body = _json.dumps(_payload_for(hook, text), ensure_ascii=False)
44
+ r = runner.run_process(
45
+ argv=["curl", "-sS", "--max-time", "20",
46
+ "-H", "Content-Type: application/json",
47
+ "-d", body, hook],
48
+ timeout=30)
49
+ return bool(r["ok"])
50
+
51
+
52
+ def push_text(text):
53
+ """推一条文本到群。配置了 webhook 才推;失败返回 False 不抛错。"""
54
+ hook = _webhook()
55
+ if not hook:
56
+ return False
57
+ if not hook.startswith("https://"):
58
+ log.warning("notify webhook 必须是 https")
59
+ return False
60
+ try:
61
+ return _post(hook, text)
62
+ except Exception as e:
63
+ log.warning("notify push failed: %s", e)
64
+ return False
65
+
66
+
67
+ def push_run_async(run_id):
68
+ """任务收尾后异步推送结果摘要(jobs 层调用;绝不阻塞/影响任务)。"""
69
+ threading.Thread(target=push_run, daemon=True,
70
+ name="notify-%s" % run_id, args=(run_id,)).start()
71
+
72
+
73
+ def push_run(run_id):
74
+ """组装 run 结果摘要并推送(webhook 未配置时静默跳过)。"""
75
+ hook = _webhook()
76
+ if not hook:
77
+ return False
78
+ from . import store
79
+ run = store.get_run(run_id) or {}
80
+ if not run:
81
+ return False
82
+ status = str(run.get("status") or "")
83
+ mark = {"done": "✅", "failed": "❌", "cancelled": "⚪"}.get(status, "🔔")
84
+ lines = ["%s CodeBee 任务%s" % (mark, {"done": "完成", "failed": "失败",
85
+ "cancelled": "已取消"}.get(status, status))]
86
+ lines.append("任务:%s" % (run.get("title") or run_id))
87
+ if run.get("error"):
88
+ lines.append("错误:%s" % str(run["error"])[:200])
89
+ v = run.get("verdict") or {}
90
+ if v.get("overall") is not None:
91
+ lines.append("综合评分 %.1f(%s)" % (
92
+ float(v["overall"]), "达标" if v.get("publishable") else "未达标"))
93
+ return push_text("\n".join(lines))
94
+
95
+
96
+ import threading # noqa: E402 (push_run_async 依赖;置底避免顶部循环导入)
@@ -0,0 +1,65 @@
1
+ # -*- coding: utf-8 -*-
2
+ """扫榜选材:抓取七猫排行榜公开页,产出选题分析素材。
3
+
4
+ 数据源:www.qimao.com/paihang/(公开可抓,2026-09-19 实测 200/106KB,
5
+ 书名与分类在 a 标签中文文本里)。下载走 curl 子进程(与 covergen 同款,
6
+ Python 不经手响应体以外的东西),解析用宽松正则——页面结构变了宁可
7
+ 返回空(调用方回落普通直连提示词),不做脆弱的强解析。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ RANK_URL = "https://www.qimao.com/paihang/"
17
+ _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
18
+ "(KHTML, like Gecko) Chrome/126 Safari/537.36")
19
+
20
+ # 页面 UI 噪音词(非书目/分类)
21
+ _NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
22
+ "排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生"}
23
+
24
+
25
+ def fetch_rank_items(limit=40):
26
+ """抓七猫排行榜页并提取中文元素材(书名/分类混排,去 UI 噪音)。
27
+
28
+ 返回元素列表(保序去重,最多 limit 个);抓取失败返回 []——调用方
29
+ 回落普通直连提示词,扫榜永远不挡任务。"""
30
+ from . import runner
31
+ try:
32
+ r = runner.run_process(
33
+ argv=["curl", "-sS", "--max-time", "20", "-A", _UA, RANK_URL],
34
+ timeout=30)
35
+ except Exception:
36
+ return []
37
+ if not r.get("ok"):
38
+ return []
39
+ items, seen = [], set()
40
+ for t in re.findall(r">([\u4e00-\u9fa5]{2,12})<", r.get("stdout") or ""):
41
+ if t in _NOISE or t in seen:
42
+ continue
43
+ seen.add(t)
44
+ items.append(t)
45
+ if len(items) >= limit:
46
+ break
47
+ return items
48
+
49
+
50
+ def rank_scan_prompt(goal):
51
+ """组装扫榜选材分析提示词。榜单抓取失败返回 None(调用方回落直连提示词)。"""
52
+ items = fetch_rank_items()
53
+ if not items:
54
+ return None
55
+ material = "、".join(items)
56
+ return (
57
+ "你是网文选题分析师。以下是刚刚抓取的七猫排行榜页面的书目与分类素材:\n\n"
58
+ "## 榜单元素材\n%s\n\n"
59
+ "## 用户想写的方向\n%s\n\n"
60
+ "## 你的产出(Markdown 报告)\n"
61
+ "1. **热门题材 Top3**:各自的共同特征与上榜代表书目\n"
62
+ "2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
63
+ "3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
64
+ "直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"
65
+ % (material, goal or "(用户未指定方向,按大盘热门分析)"))