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.
@@ -0,0 +1,324 @@
1
+ {
2
+ "_note": "番茄真机校准(2026-09-19 定稿,建书+发章结构全部实测):编辑器=/main/writer/<book_id>/publish/ 直达(章节号 input.serial-input 纯数字;标题 div.serial-editor-input-hint input ≥5字/30上限;正文 .ProseMirror ≥1000字;「下一步」保存校验后按钮组右缘箭头展开下拉含「定时发布」)。建书=/main/writer/create 直达表单:radio 签约模式/目标读者(label 真实点击)→ 阅读标签 select(y<430)弹层 arco-anchor 切主分类/主题/角色/情节 + 标签云点击 + 确认 → 内容标签 select(y>430)弹层 情感/人设/世界观 同构 → 名称/主角1/主角2/简介 → 立即创建 → 跳 book-info/<id>。",
3
+ "check_login": [
4
+ {
5
+ "do": "navigate",
6
+ "url": "about:blank"
7
+ },
8
+ {
9
+ "do": "navigate",
10
+ "url": "{home}"
11
+ },
12
+ {
13
+ "do": "url_any",
14
+ "any": [
15
+ "fanqienovel.com"
16
+ ]
17
+ }
18
+ ],
19
+ "upload_chapter": [
20
+ {
21
+ "do": "navigate",
22
+ "url": "about:blank"
23
+ },
24
+ {
25
+ "do": "navigate",
26
+ "url": "{editor_url}"
27
+ },
28
+ {
29
+ "do": "url_any",
30
+ "any": [
31
+ "fanqienovel.com"
32
+ ]
33
+ },
34
+ {
35
+ "do": "wait",
36
+ "sel": ".ProseMirror",
37
+ "timeout": 20
38
+ },
39
+ {
40
+ "do": "fill",
41
+ "sel": "input.serial-input, input.byte-input",
42
+ "key": "chapter_no"
43
+ },
44
+ {
45
+ "do": "fill",
46
+ "sel": "div.serial-editor-input-hint input",
47
+ "key": "chapter_title"
48
+ },
49
+ {
50
+ "do": "fill",
51
+ "sel": ".ProseMirror",
52
+ "key": "chapter_body"
53
+ },
54
+ {
55
+ "do": "shot",
56
+ "name": "chapter-filled"
57
+ },
58
+ {
59
+ "do": "click_real",
60
+ "text": "下一步",
61
+ "scope": "button",
62
+ "tries": 15
63
+ },
64
+ {
65
+ "do": "click_arrow",
66
+ "target": "下一步"
67
+ },
68
+ {
69
+ "do": "click_real",
70
+ "text": "定时发布",
71
+ "tries": 12,
72
+ "optional": true
73
+ },
74
+ {
75
+ "do": "click_real",
76
+ "text": "发布",
77
+ "tries": 12,
78
+ "optional": true
79
+ },
80
+ {
81
+ "do": "verify",
82
+ "url": "{chapter_manage_url}",
83
+ "any": [
84
+ "{chapter_title}"
85
+ ],
86
+ "settle": 6
87
+ }
88
+ ],
89
+ "probe_form": [
90
+ {
91
+ "do": "navigate",
92
+ "url": "about:blank"
93
+ },
94
+ {
95
+ "do": "navigate",
96
+ "url": "https://fanqienovel.com/main/writer/create"
97
+ },
98
+ {
99
+ "do": "url_any",
100
+ "any": [
101
+ "fanqienovel.com"
102
+ ]
103
+ },
104
+ {
105
+ "do": "wait",
106
+ "sel": "input[placeholder='请输入作品名称']",
107
+ "timeout": 20
108
+ },
109
+ {
110
+ "do": "probe",
111
+ "note": "建书表单"
112
+ }
113
+ ],
114
+ "create_book": [
115
+ {
116
+ "do": "navigate",
117
+ "url": "about:blank"
118
+ },
119
+ {
120
+ "do": "navigate",
121
+ "url": "https://fanqienovel.com/main/writer/create"
122
+ },
123
+ {
124
+ "do": "url_any",
125
+ "any": [
126
+ "fanqienovel.com"
127
+ ]
128
+ },
129
+ {
130
+ "do": "wait",
131
+ "sel": "input[placeholder='请输入作品名称']",
132
+ "timeout": 20
133
+ },
134
+ {
135
+ "do": "sleep",
136
+ "s": 3
137
+ },
138
+ {
139
+ "do": "click_real",
140
+ "text": "{signing_mode}",
141
+ "scope": "label.arco-radio,span.arco-radio,[class*=serial-radio]",
142
+ "tries": 8
143
+ },
144
+ {
145
+ "do": "click_real",
146
+ "text": "{target_reader}",
147
+ "scope": "label.arco-radio,span.arco-radio,[class*=serial-radio]",
148
+ "tries": 8
149
+ },
150
+ {
151
+ "do": "click_real",
152
+ "text": "知道了",
153
+ "scope": "button",
154
+ "tries": 4,
155
+ "optional": true
156
+ },
157
+ {
158
+ "do": "click_real",
159
+ "text": "请选择作品标签",
160
+ "scope": "[class*=select-view],div,span",
161
+ "y_max": 430,
162
+ "tries": 10
163
+ },
164
+ {
165
+ "do": "click_real",
166
+ "text": "{category}",
167
+ "scope": ".category-modal span,.category-modal div,.category-modal li",
168
+ "tries": 8
169
+ },
170
+ {
171
+ "do": "click_real",
172
+ "text": "主题",
173
+ "scope": ".category-modal .arco-anchor-link",
174
+ "tries": 8
175
+ },
176
+ {
177
+ "do": "click_real",
178
+ "text": "{tag_theme}",
179
+ "scope": ".category-modal span,.category-modal div,.category-modal li",
180
+ "y_min": 140,
181
+ "tries": 8,
182
+ "optional": true
183
+ },
184
+ {
185
+ "do": "click_real",
186
+ "text": "角色",
187
+ "scope": ".category-modal .arco-anchor-link",
188
+ "tries": 8
189
+ },
190
+ {
191
+ "do": "click_real",
192
+ "text": "{tag_role}",
193
+ "scope": ".category-modal span,.category-modal div,.category-modal li",
194
+ "y_min": 140,
195
+ "tries": 8,
196
+ "optional": true
197
+ },
198
+ {
199
+ "do": "click_real",
200
+ "text": "情节",
201
+ "scope": ".category-modal .arco-anchor-link",
202
+ "tries": 8
203
+ },
204
+ {
205
+ "do": "click_real",
206
+ "text": "{tag_plot}",
207
+ "scope": ".category-modal span,.category-modal div,.category-modal li",
208
+ "y_min": 140,
209
+ "tries": 8,
210
+ "optional": true
211
+ },
212
+ {
213
+ "do": "click_real",
214
+ "text": "确认",
215
+ "scope": ".category-modal button",
216
+ "tries": 8
217
+ },
218
+ {
219
+ "do": "wait_gone",
220
+ "sel": ".category-modal",
221
+ "timeout": 25
222
+ },
223
+ {
224
+ "do": "click_real",
225
+ "text": "请选择作品标签",
226
+ "scope": "[class*=select-view],div,span",
227
+ "tries": 15
228
+ },
229
+ {
230
+ "do": "click_real",
231
+ "text": "情感",
232
+ "scope": ".category-modal .arco-anchor-link",
233
+ "tries": 8
234
+ },
235
+ {
236
+ "do": "click_real",
237
+ "text": "{tag_content_emotion}",
238
+ "scope": ".category-modal span,.category-modal div,.category-modal li",
239
+ "y_min": 140,
240
+ "tries": 8,
241
+ "optional": true
242
+ },
243
+ {
244
+ "do": "click_real",
245
+ "text": "人设",
246
+ "scope": ".category-modal .arco-anchor-link",
247
+ "tries": 8
248
+ },
249
+ {
250
+ "do": "click_real",
251
+ "text": "{tag_content_character}",
252
+ "scope": ".category-modal span,.category-modal div,.category-modal li",
253
+ "y_min": 140,
254
+ "tries": 8,
255
+ "optional": true
256
+ },
257
+ {
258
+ "do": "click_real",
259
+ "text": "世界观",
260
+ "scope": ".category-modal .arco-anchor-link",
261
+ "tries": 8
262
+ },
263
+ {
264
+ "do": "click_real",
265
+ "text": "{tag_content_world}",
266
+ "scope": ".category-modal span,.category-modal div,.category-modal li",
267
+ "y_min": 140,
268
+ "tries": 8,
269
+ "optional": true
270
+ },
271
+ {
272
+ "do": "click_real",
273
+ "text": "确认",
274
+ "scope": ".category-modal button",
275
+ "tries": 8
276
+ },
277
+ {
278
+ "do": "wait_gone",
279
+ "sel": ".category-modal",
280
+ "timeout": 25
281
+ },
282
+ {
283
+ "do": "fill",
284
+ "sel": "input[placeholder='请输入作品名称']",
285
+ "key": "title"
286
+ },
287
+ {
288
+ "do": "fill",
289
+ "sel": "input[placeholder='请输入主角名1']",
290
+ "key": "protagonist"
291
+ },
292
+ {
293
+ "do": "fill",
294
+ "sel": "input[placeholder='请输入主角名2']",
295
+ "key": "protagonist2"
296
+ },
297
+ {
298
+ "do": "fill",
299
+ "sel": "textarea[placeholder*='请输入50-500字']",
300
+ "key": "summary"
301
+ },
302
+ {
303
+ "do": "shot",
304
+ "name": "create-book-filled"
305
+ },
306
+ {
307
+ "do": "click_real",
308
+ "text": "立即创建",
309
+ "scope": "button",
310
+ "tries": 10
311
+ },
312
+ {
313
+ "do": "wait",
314
+ "sel": "body",
315
+ "timeout": 10
316
+ },
317
+ {
318
+ "do": "url_any",
319
+ "any": [
320
+ "book-info"
321
+ ]
322
+ }
323
+ ]
324
+ }
@@ -322,15 +322,27 @@ def create_book_async(task_id, plat, auto_submit=False):
322
322
  b, page = _open_page(plat)
323
323
  values = mod.values_create_book(data)
324
324
  steps = _with_tag_steps(load_flow(plat, "create_book"),
325
- mod.tag_groups(data), values)
325
+ mod.tag_groups(data), values, mod)
326
326
  flow.run_flow(page, steps, values=values, config=mod.CONFIG,
327
327
  auto_submit=auto_submit,
328
328
  shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
329
329
  log=logs.append)
330
+ # book_id:创建成功后平台跳书籍详情/编辑器,从 URL 提取
331
+ # (番茄 book-info/<id>;七猫 information?id=<id>)。
332
+ # 提取不到就先用书名登记,发章按书名找书。
333
+ book_id = ""
334
+ try:
335
+ m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
336
+ str(page.url() or ""))
337
+ if m_url:
338
+ book_id = m_url.group(1) or m_url.group(2)
339
+ except Exception:
340
+ pass
330
341
  ledger.record(plat, "create_book", task_id=task_id, title=book_name,
331
- ok=True, shot=str(ledger.shot_path(plat, task_id, "")))
332
- # book_id 拿不到(DOM 深链未知):先用书名登记,发章按书名找书
333
- ledger.save_book(task_id, plat, {"book_id": "", "title": book_name})
342
+ book_id=book_id, ok=True,
343
+ error="\n".join(logs)[:2000],
344
+ shot=str(ledger.shot_path(plat, task_id, "")))
345
+ ledger.save_book(task_id, plat, {"book_id": book_id, "title": book_name})
334
346
  _set(plat, status="connected", error="")
335
347
  except Exception as e:
336
348
  _set(plat, status="error", error="建书失败:%s" % e)
@@ -345,14 +357,16 @@ def create_book_async(task_id, plat, auto_submit=False):
345
357
  return True, ""
346
358
 
347
359
 
348
- def _with_tag_steps(steps, groups, values):
360
+ def _with_tag_steps(steps, groups, values, mod=None):
349
361
  """标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
350
- "tags" 步骤按组切换点选。组显示名映射来自平台模块 TAG_GROUP_LABELS
351
- (缺省平台无映射时退化为纯标签)。兼容旧流程表(无 tags 步骤时插桩)。"""
352
- from . import qimao as _qm
362
+ "tags" 步骤按组切换点选。组显示名映射来自**对应平台模块**的
363
+ TAG_GROUP_LABELS(曾硬编码 qimao——番茄任务 key 撞名被套上七猫组名,
364
+ 插桩 text list repr)。兼容旧流程表(无 tags 步骤时插桩);流程表
365
+ 已用 {tag_x} 占位(番茄式 click_real)时不插桩——占位由 values 自答。"""
353
366
  flat = []
367
+ labels = getattr(mod, "TAG_GROUP_LABELS", {}) if mod else {}
354
368
  for key, tags in groups or []:
355
- grp = getattr(_qm, "TAG_GROUP_LABELS", {}).get(key, "")
369
+ grp = labels.get(key, "")
356
370
  flat.extend([grp, str(t)] if grp and str(t).strip() else str(t)
357
371
  for t in tags if str(t).strip())
358
372
  if not flat:
@@ -360,6 +374,8 @@ def _with_tag_steps(steps, groups, values):
360
374
  if any(st.get("do") == "tags" for st in steps):
361
375
  values["_tags"] = flat
362
376
  return steps
377
+ if any("{tag_" in str(st.get("text") or "") for st in steps if st.get("text")):
378
+ return steps # 流程表自带标签占位(番茄式):values 已就绪
363
379
  tag_steps = [{"do": "click_text", "text": str(t), "contains": False,
364
380
  "scope": "[class*=tag] li,span,label,[class*=label]"}
365
381
  for t in flat]
@@ -77,13 +77,28 @@ TAG_GROUP_LABELS = {"tags_style": "风格", "tags_role": "角色",
77
77
 
78
78
 
79
79
  def values_create_book(meta):
80
+ from .. import bookmeta_catalog as cat
81
+ target_reader = (meta.get("target_reader") or "").strip()
82
+ category_main = (meta.get("category_main") or "").strip()
83
+ category_sub = (meta.get("category_sub") or "").strip()
84
+ # 旧数据兜底:二级有值但一级空/与二级不自洽(如女生专属的「现实故事」
85
+ # 配了男生频道)会让一级 click_text 静默跳过、死在流程中段——按官方
86
+ # 目录跨频道反查对齐,频道与一级跟着二级走
87
+ if category_sub and not (category_main and
88
+ category_sub in (cat.QIMAO_CATS.get(target_reader)
89
+ or {}).get(category_main, [])):
90
+ loc = cat.qimao_locate_sub(category_sub)
91
+ if loc:
92
+ target_reader, category_main = loc
93
+ else:
94
+ category_sub = ""
80
95
  return {
81
96
  "title": (meta.get("book_name") or "").strip(),
82
97
  "summary": (meta.get("summary") or "").strip(),
83
98
  "protagonist": (meta.get("protagonist_1") or "").strip(),
84
- "target_reader": (meta.get("target_reader") or "").strip(),
85
- "category_main": (meta.get("category_main") or "").strip(),
86
- "category_sub": (meta.get("category_sub") or "").strip(),
99
+ "target_reader": target_reader,
100
+ "category_main": category_main,
101
+ "category_sub": category_sub,
87
102
  "status": (meta.get("status") or "连载中").strip(),
88
103
  }
89
104
 
@@ -90,8 +90,12 @@ def _npm_shim_bypass(argv):
90
90
 
91
91
 
92
92
  def _kill_tree(pid):
93
- """杀整棵进程树。Windows taskkill /T;POSIX 靠 spawn 时的
94
- start_new_session(子进程自成一个进程组,pgid==pid)用 killpg 连孙带杀。"""
93
+ """杀整棵进程树。Windows:先 TerminateProcess 直接杀根进程(毫秒级,绝不
94
+ 挂),再 taskkill /T 兜底扫子孙(短等待,卡死即放弃——真实案例 2026-09-19:
95
+ 某些机器状态下 taskkill /F /T 对普通进程也挂死 30s+,等满 15s 超时会让每次
96
+ 超时杀进程都卡 20s,看门狗/超时全被拖死;漏杀的孙进程由启动孤儿清扫兜底)。
97
+ POSIX 靠 spawn 时的 start_new_session(子进程自成一个进程组,pgid==pid)
98
+ 用 killpg 连孙带杀。"""
95
99
  if os.name != "nt":
96
100
  try:
97
101
  os.killpg(pid, signal.SIGKILL)
@@ -103,10 +107,21 @@ def _kill_tree(pid):
103
107
  except Exception:
104
108
  pass
105
109
  return
110
+ # Windows 没有 signal.SIGKILL(直接引用会 AttributeError);os.kill 带任意
111
+ # 信号值都走 TerminateProcess,用 SIGTERM
106
112
  try:
107
- subprocess.run(
113
+ os.kill(pid, signal.SIGTERM) # Windows 语义 = TerminateProcess,只杀根进程
114
+ except OSError:
115
+ pass # 根进程可能已被 taskkill 抢先杀掉
116
+ except Exception:
117
+ pass
118
+ try:
119
+ tk = subprocess.Popen(
108
120
  ["taskkill", "/F", "/T", "/PID", str(pid)],
109
- capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=15)
121
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
122
+ stderr=subprocess.DEVNULL, close_fds=True, # 不继承被杀进程的管道句柄,否则 EOF 永不来、drain 烧满超时
123
+ creationflags=CREATE_NO_WINDOW)
124
+ tk.wait(timeout=1.2) # 健康机器 <1s;卡死就不再陪等(后台自行结束)
110
125
  except Exception:
111
126
  pass
112
127
 
@@ -18,7 +18,7 @@ _FILE = paths.DATA_DIR / "settings.json"
18
18
  # 成功发章上限、平台连续失败几次后暂停自动发布(publish/auto.py 读取)
19
19
  DEFAULTS = {"max_concurrent_jobs": 6, "default_workdir": "", "hooks_token": "",
20
20
  "telemetry_errors": True, "publish_daily_cap": 10,
21
- "publish_fail_streak": 3, "notify_webhook": ""}
21
+ "publish_fail_streak": 3, "notify_webhook": "", "notify_base_url": ""}
22
22
  # 并发上限 12:worker 只是拉起 CLI 子进程的调度位,跨任务无共享资源;
23
23
  # 同任务单飞守卫在 jobs 层。默认 6 对齐「多任务并行不排队」的使用预期。
24
24
  MIN_WORKERS, MAX_WORKERS = 1, 12
@@ -101,6 +101,8 @@ def save(patch):
101
101
  return cur, "publish_fail_streak 必须是 1-10 的整数"
102
102
  if "notify_webhook" in patch:
103
103
  cur["notify_webhook"] = str(patch.get("notify_webhook") or "").strip()[:300]
104
+ if "notify_base_url" in patch:
105
+ cur["notify_base_url"] = str(patch.get("notify_base_url") or "").strip()[:200]
104
106
  _FILE.parent.mkdir(parents=True, exist_ok=True)
105
107
  tmp = _FILE.with_suffix(".tmp")
106
108
  tmp.write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -0,0 +1,111 @@
1
+ # -*- coding: utf-8 -*-
2
+ """可分享报告页(借鉴 agency-orchestrator 的 ao report):把 run 成果渲染成
3
+ 自包含单文件 HTML——零 JS、内联样式、离线可看,直接发群/发人。
4
+
5
+ 纯函数无网络;数据全部来自调用方(store/paths 已读好的对象)。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import html as _html
10
+ import time
11
+
12
+
13
+ def _esc(s):
14
+ return _html.escape(str(s if s is not None else ""), quote=True)
15
+
16
+
17
+ def _md_ish(text):
18
+ """极简 Markdown 呈现:标题/列表/粗体/代码,够报告阅读即可,不做完整解析。"""
19
+ out = []
20
+ for ln in (text or "").splitlines():
21
+ e = _esc(ln)
22
+ if ln.startswith("# "):
23
+ out.append("<h1>%s</h1>" % e[2:])
24
+ elif ln.startswith("## "):
25
+ out.append("<h2>%s</h2>" % e[3:])
26
+ elif ln.startswith("### "):
27
+ out.append("<h3>%s</h3>" % e[4:])
28
+ elif ln.startswith("- "):
29
+ out.append("<li>%s</li>" % e[2:])
30
+ else:
31
+ out.append("<p>%s</p>" % e)
32
+ return "\n".join(out)
33
+
34
+
35
+ _SHARE_TPL = """<!DOCTYPE html>
36
+ <html lang="zh"><head><meta charset="utf-8">
37
+ <meta name="viewport" content="width=device-width, initial-scale=1">
38
+ <title>__TITLE__ · CodeBee 任务报告</title>
39
+ <style>
40
+ body{margin:0;background:#f5f6f8;color:#1c2330;font:15px/1.65 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
41
+ .wrap{max-width:760px;margin:0 auto;padding:28px 18px 60px}
42
+ .head{border-bottom:3px solid #2b6cb0;padding-bottom:14px;margin-bottom:22px}
43
+ h1{margin:0 0 6px;font-size:24px}
44
+ .meta{color:#5a6472;font-size:13px}
45
+ .badge{display:inline-block;border-radius:999px;padding:2px 12px;font-size:13px;font-weight:600}
46
+ .badge.ok{background:#e6f6ec;color:#177245}.badge.bad{background:#fdeaea;color:#b42318}
47
+ .badge.off{background:#eef0f3;color:#5a6472}
48
+ .card{background:#fff;border:1px solid #e3e6ea;border-radius:12px;padding:18px 20px;margin-bottom:18px}
49
+ .card h2{font-size:16px;margin:0 0 12px;color:#2b6cb0}
50
+ table{border-collapse:collapse;width:100%}
51
+ td{padding:6px 10px;border-bottom:1px solid #eef0f3}
52
+ tr.sum td{font-weight:700;border-top:2px solid #e3e6ea}
53
+ .step{display:flex;gap:10px;padding:7px 0;border-bottom:1px dashed #eef0f3;font-size:13.5px;align-items:baseline}
54
+ .step .n{color:#8a93a0;font-weight:700}.step .role{font-weight:600;min-width:88px}
55
+ .step .who{color:#5a6472;min-width:90px}.step .sum{flex:1}.st{font-size:12px}
56
+ .st.done{color:#177245}.st.failed{color:#b42318}
57
+ h1,h2,h3{margin:14px 0 8px}li{margin:4px 0}.trow{padding:2px 0;border-bottom:1px dotted #eee}
58
+ p{margin:8px 0}.mut{color:#8a93a0}code{background:#f0f2f5;padding:1px 6px;border-radius:4px}
59
+ pre{background:#f0f2f5;padding:12px;border-radius:8px;overflow-x:auto;white-space:pre-wrap}
60
+ </style></head><body><div class="wrap">
61
+ <div class="head"><h1>__TITLE__</h1>
62
+ <div class="meta">__META__ · __ST__ · CodeBee 生成于 __NOW__</div></div>
63
+ <div class="card"><h2>结果</h2><span class="badge __STCLS__">__ST__</span>__OVERALL__</div>
64
+ <div class="card"><h2>步骤</h2>__STEPS__</div>
65
+ <div class="card"><h2>报告</h2>__REPORT__</div>
66
+ <div class="meta">由 <b>CodeBee</b> 多智能体编排台生成</div>
67
+ </div></body></html>"""
68
+
69
+
70
+ def render_share_html(run, task, report_text):
71
+ """渲染自包含分享页。run/task/report_text 缺失时优雅降级。"""
72
+ run = run or {}
73
+ task = task or {}
74
+ status = str(run.get("status") or "")
75
+ st_label = {"done": "已完成", "failed": "失败", "cancelled": "已取消",
76
+ "running": "进行中"}.get(status, status)
77
+ st_cls = {"done": "ok", "failed": "bad", "cancelled": "off"}.get(status, "off")
78
+ verdict = run.get("verdict") or {}
79
+ means = verdict.get("scores") or {}
80
+
81
+ score_rows = ""
82
+ for d, v in means.items():
83
+ score_rows += "<tr><td>%s</td><td>%.1f</td></tr>" % (_esc(d), float(v))
84
+ if verdict:
85
+ score_rows += ('<tr class="sum"><td>综合</td>'
86
+ '<td>%.1f</td></tr>' % float(verdict.get("overall") or 0))
87
+
88
+ steps_rows = ""
89
+ for s in run.get("steps") or []:
90
+ steps_rows += (
91
+ '<div class="step"><span class="n">%02d</span>'
92
+ '<span class="role">%s</span><span class="who">%s</span>'
93
+ '<span class="sum">%s</span><span class="st %s">%s</span></div>'
94
+ % (s.get("n") or 0, _esc(s.get("role") or ""),
95
+ _esc(s.get("agent_label") or s.get("agent") or ""),
96
+ _esc((s.get("summary") or "")[:220]), _esc(s.get("status") or ""),
97
+ _esc(s.get("status") or "")))
98
+
99
+ report_html = _md_ish(report_text) if report_text else "<p class='mut'>(暂无报告)</p>"
100
+ overall_note = ("综合 %.1f 分" % float(verdict.get("overall"))
101
+ if verdict.get("overall") is not None else "")
102
+
103
+ return (_SHARE_TPL
104
+ .replace("__TITLE__", _esc(task.get("title") or run.get("title") or "任务报告"))
105
+ .replace("__META__", _esc(task.get("goal") or ""))
106
+ .replace("__ST__", _esc(st_label))
107
+ .replace("__STCLS__", st_cls)
108
+ .replace("__NOW__", _esc(time.strftime("%Y-%m-%d %H:%M")))
109
+ .replace("__OVERALL__", _esc(overall_note))
110
+ .replace("__STEPS__", steps_rows or "<p class='mut'>(无步骤)</p>")
111
+ .replace("__REPORT__", report_html))
@@ -29,7 +29,7 @@ import time
29
29
  import urllib.request
30
30
  import zipfile
31
31
 
32
- from . import errorlog, paths, settings
32
+ from . import errorlog, paths, settings, tlsctx
33
33
 
34
34
  # 端点:部署后把 CloudBase HTTP 函数 URL 填到这里(见 cloudfunctions/telemetry-collect/)
35
35
  ENDPOINT = os.environ.get("TUTTI_TELEMETRY_URL", "").strip()
@@ -107,7 +107,8 @@ def _post(url, payload):
107
107
  headers={"Content-Type": "application/json",
108
108
  "User-Agent": "CodeBee-Telemetry/1"},
109
109
  method="POST")
110
- with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
110
+ with urllib.request.urlopen(req, timeout=TIMEOUT_S,
111
+ context=tlsctx.context()) as resp:
111
112
  return int(resp.status or 0)
112
113
  except Exception:
113
114
  return 0
@@ -46,3 +46,19 @@ def context():
46
46
  if _CTX is None:
47
47
  _CTX = _build()
48
48
  return _CTX
49
+
50
+
51
+ def humanize(err_text):
52
+ """证书验证失败 → 可行动的人话提示(非证书类错误原样返回)。
53
+
54
+ 兜底 CA 仍验证失败只剩两类真实原因:本机代理对 HTTPS 做 TLS 拦截
55
+ (其根证书只在系统钥匙串/浏览器里,Python 的静态 CA 束不认),或极端
56
+ 环境连 /etc/ssl/cert.pem 都没有。SSL_CERT_FILE 是 OpenSSL 标准逃生门,
57
+ create_default_context() 本就吃它,无需额外代码。"""
58
+ text = str(err_text or "")
59
+ if "CERTIFICATE_VERIFY_FAILED" not in text:
60
+ return text
61
+ return (text + " —— 证书验证仍失败:① 若开着代理/安全软件且开启了 HTTPS 拦截,"
62
+ "关掉拦截,或把其根证书导出后设环境变量 SSL_CERT_FILE 指向它再启动;"
63
+ "② 确认系统存在 /etc/ssl/cert.pem;③ 刚升级过 CodeBee 的话,"
64
+ "旧服务进程可能还在跑——关掉终端窗口重开")