codebee 0.1.13 → 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.
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import aiflavor, catalog, history, jobs, manager, modelhub, mocks, planner, registry, router, runner, skills, store, usage
22
+ from . import aiflavor, catalog, history, jobs, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, usage
23
23
  from . import builtin_agent
24
24
  from . import diagnostics
25
25
  from . import paths as paths_mod
@@ -168,8 +168,16 @@ def _resume_workdir(resume_ctx, fallback):
168
168
 
169
169
 
170
170
  def _compaction_enabled():
171
- """Phase 2 灰度开关:环境变量 TUTTI_COMPACTION=1 启用上下文压缩(默认关)。"""
172
- return os.environ.get("TUTTI_COMPACTION") == "1"
171
+ """Phase 2 灰度开关:环境变量 TUTTI_COMPACTION=1 或设置页
172
+ settings_v2 orchestrator.compaction.enabled 任一开启即生效(默认关)。"""
173
+ if os.environ.get("TUTTI_COMPACTION") == "1":
174
+ return True
175
+ try:
176
+ from .settings_schema import get as ss_get, register_default_namespaces
177
+ register_default_namespaces()
178
+ return bool(ss_get("orchestrator", "compaction.enabled"))
179
+ except Exception:
180
+ return False
173
181
 
174
182
 
175
183
  _sessions_cache = {}
@@ -1284,15 +1292,21 @@ def _run_direct(run, task, agents, ev, stats, mode):
1284
1292
  while True:
1285
1293
  _wait_gate(run_id, ev)
1286
1294
  if first:
1287
- if bi is not None:
1288
- prompt = (BUILTIN_DIRECT_PROMPT
1289
- .replace("__GOAL__", task["goal"])
1290
- .replace("__CONTEXT__", task.get("context") or "(无)")
1291
- .replace("__FOLLOWUPS__", FOLLOWUPS_PROTOCOL))
1292
- else:
1293
- prompt = (DIRECT_PROMPT
1294
- .replace("__GOAL__", task["goal"])
1295
- .replace("__CONTEXT__", task.get("context") or "(无)"))
1295
+ prompt = ""
1296
+ if task.get("type") == "rank_scan" and bi is not None:
1297
+ # 扫榜选材(借鉴 oh-story 扫榜):抓七猫排行榜公开数据注入,
1298
+ # AI 做选题洞察;抓取失败回落普通直连提示词
1299
+ prompt = paihang.rank_scan_prompt(task.get("goal") or "") or ""
1300
+ if not prompt:
1301
+ if bi is not None:
1302
+ prompt = (BUILTIN_DIRECT_PROMPT
1303
+ .replace("__GOAL__", task["goal"])
1304
+ .replace("__CONTEXT__", task.get("context") or "(无)")
1305
+ .replace("__FOLLOWUPS__", FOLLOWUPS_PROTOCOL))
1306
+ else:
1307
+ prompt = (DIRECT_PROMPT
1308
+ .replace("__GOAL__", task["goal"])
1309
+ .replace("__CONTEXT__", task.get("context") or "(无)"))
1296
1310
  note = route.get("implementer", "")
1297
1311
  images = _task_images(task, workdir)
1298
1312
  else:
@@ -1458,9 +1472,6 @@ def _plot_modules(workdir):
1458
1472
  """剧情模块库(oh-story 拆文沉淀式):工作目录里的 plot-modules.md
1459
1473
  (可复用的桥段/冲突/爽点/名场面素材模块),作者手工维护,每章起草与
1460
1474
  评审前自动注入。不存在/为空返回 ""——约定式功能,零配置零噪音。"""
1461
- p = os.path.abspath(os.path.join(str(workdir or ""), MODULES_FILE))
1462
- if not _inside(workdir, p) or not os.path.isfile(p):
1463
- return ""
1464
1475
  try:
1465
1476
  txt = _read_text_any_enc(p)[:_MODULES_MAX_CHARS].strip()
1466
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):
@@ -276,15 +319,32 @@ class Page:
276
319
  return self.evaluate("location.href")
277
320
 
278
321
  def navigate(self, url, timeout=30.0):
279
- """导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。"""
322
+ """导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。
323
+
324
+ about:blank 起跳时 readyState 本就 complete——必须同时等 location
325
+ 真正到达目标域,否则后续步骤打在空白页上(url_any 误报)。"""
326
+ from urllib.parse import urlparse as _up
327
+ if url == "about:blank": # 中转页:无需等加载(页面忙时会假超时)
328
+ try:
329
+ self.send("Page.navigate", {"url": url}, timeout=5.0)
330
+ except BrowserError:
331
+ pass
332
+ time.sleep(0.4)
333
+ return
280
334
  try:
281
335
  self.send("Page.navigate", {"url": url}, timeout=timeout)
282
336
  except BrowserError:
283
337
  pass # 老页面销毁时连接报错属正常,轮询兜底
338
+ host = _up(url).netloc
284
339
  deadline = time.time() + timeout
340
+ seen_url = False
285
341
  while time.time() < deadline:
286
342
  try:
287
- if self.evaluate("document.readyState", timeout=3.0) == "complete":
343
+ href = self.evaluate("location.href", timeout=3.0) or ""
344
+ if not host or host in href:
345
+ seen_url = True
346
+ if seen_url and self.evaluate("document.readyState", timeout=3.0) == "complete":
347
+ time.sleep(0.5) # SPA 首帧渲染余量
288
348
  return
289
349
  except BrowserError:
290
350
  pass # 导航间隙 evaluate 会短暂失败
@@ -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"
@@ -106,8 +106,11 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
106
106
  url = st["url"]
107
107
  for k, v in config.items():
108
108
  url = url.replace("{%s}" % k, str(v))
109
+ for k, v in (values or {}).items(): # editor_url/draft_url 等任务级占位
110
+ url = url.replace("{%s}" % k, str(v))
109
111
  note(i, "打开 %s" % url)
110
- page.navigate(url)
112
+ # 重页(编辑器/管理列表)可按步放宽;缺省 45s(30s 对慢网偏紧)
113
+ page.navigate(url, timeout=float(st.get("timeout") or 45))
111
114
  elif act == "wait":
112
115
  note(i, "等待 %s" % st["sel"])
113
116
  page.wait_for(st["sel"], timeout=float(st.get("timeout") or 12))
@@ -192,6 +195,27 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
192
195
  time.sleep(0.9)
193
196
  if not (r or {}).get("ok"):
194
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))
195
219
  elif act == "click_match":
196
220
  # 关键词选块(站点卡片等):点同时包含所有关键词的最小元素
197
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
+ }
@@ -0,0 +1,233 @@
1
+ {
2
+ "_note": "七猫真机校准(2026-09-19 凌晨定稿):建书=book-manage「新建小说」→站点弹层(click_match+确认)→information 表单(fill_label/radio/级联分类/tags 四组各1-3个+确定/主角名平铺/简介);书名仅允许,:!?中文标点(·被拦)。发章=编辑器填好后「立即发布」落草稿箱(未签约书限制)→草稿行「立即发布」→「更正序号去发布」→「确认发布」三层弹窗→章节待审核。正文下限 1000 字(min_chapter_chars)。book-upload?id=<书id> 直达编辑器。",
3
+ "probe_form": [
4
+ {
5
+ "do": "navigate",
6
+ "url": "{book_manage}"
7
+ },
8
+ {
9
+ "do": "url_any",
10
+ "any": [
11
+ "qimao.com"
12
+ ]
13
+ },
14
+ {
15
+ "do": "click_text",
16
+ "text": "新建小说",
17
+ "contains": true
18
+ },
19
+ {
20
+ "do": "click_match",
21
+ "any": [
22
+ "七猫中文网",
23
+ "网站特色"
24
+ ],
25
+ "max_len": 160
26
+ },
27
+ {
28
+ "do": "probe",
29
+ "note": "建书表单"
30
+ }
31
+ ],
32
+ "create_book": [
33
+ {
34
+ "do": "navigate",
35
+ "url": "{book_manage}"
36
+ },
37
+ {
38
+ "do": "url_any",
39
+ "any": [
40
+ "qimao.com"
41
+ ]
42
+ },
43
+ {
44
+ "do": "click_text",
45
+ "text": "新建小说",
46
+ "contains": true
47
+ },
48
+ {
49
+ "do": "click_match",
50
+ "any": [
51
+ "七猫中文网",
52
+ "网站特色"
53
+ ],
54
+ "max_len": 400
55
+ },
56
+ {
57
+ "do": "click_text",
58
+ "text": "确认",
59
+ "scope": "button,[class*=btn]"
60
+ },
61
+ {
62
+ "do": "wait",
63
+ "sel": "textarea[placeholder*='作品名称']",
64
+ "timeout": 15
65
+ },
66
+ {
67
+ "do": "fill_label",
68
+ "label": "作品名称",
69
+ "key": "title"
70
+ },
71
+ {
72
+ "do": "radio",
73
+ "key": "target_reader",
74
+ "map": {
75
+ "男生": "0",
76
+ "女生": "1"
77
+ }
78
+ },
79
+ {
80
+ "do": "click",
81
+ "sel": "input[placeholder='请选择一级分类']"
82
+ },
83
+ {
84
+ "do": "click_text",
85
+ "text": "{category_main}",
86
+ "contains": true,
87
+ "scope": "li,span,[class*=dropdown] *,[class*=popper] *"
88
+ },
89
+ {
90
+ "do": "click",
91
+ "sel": "input[placeholder='请选择二级分类']"
92
+ },
93
+ {
94
+ "do": "click_text",
95
+ "text": "{category_sub}",
96
+ "contains": true,
97
+ "scope": "li,span,[class*=dropdown] *,[class*=popper] *"
98
+ },
99
+ {
100
+ "do": "click_text",
101
+ "text": "添加标签"
102
+ },
103
+ {
104
+ "do": "tags"
105
+ },
106
+ {
107
+ "do": "click_text",
108
+ "text": "确定",
109
+ "scope": "button,[class*=btn]"
110
+ },
111
+ {
112
+ "do": "fill_label",
113
+ "label": "主角名",
114
+ "key": "protagonist"
115
+ },
116
+ {
117
+ "do": "radio",
118
+ "key": "status",
119
+ "map": {
120
+ "连载中": "0",
121
+ "已完结": "1"
122
+ }
123
+ },
124
+ {
125
+ "do": "fill_label",
126
+ "label": "作品简介",
127
+ "key": "summary"
128
+ },
129
+ {
130
+ "do": "shot",
131
+ "name": "create-book-filled"
132
+ },
133
+ {
134
+ "do": "submit",
135
+ "text": "确认创建"
136
+ }
137
+ ],
138
+ "upload_chapter": [
139
+ {
140
+ "do": "navigate",
141
+ "url": "about:blank"
142
+ },
143
+ {
144
+ "do": "navigate",
145
+ "url": "{editor_url}"
146
+ },
147
+ {
148
+ "do": "url_any",
149
+ "any": [
150
+ "qimao.com"
151
+ ]
152
+ },
153
+ {
154
+ "do": "wait",
155
+ "sel": "textarea[placeholder*='章节名称']",
156
+ "timeout": 20
157
+ },
158
+ {
159
+ "do": "fill",
160
+ "sel": "textarea[placeholder*='章节名称']",
161
+ "key": "chapter_title"
162
+ },
163
+ {
164
+ "do": "wait",
165
+ "sel": ".q-contenteditable",
166
+ "timeout": 12
167
+ },
168
+ {
169
+ "do": "fill",
170
+ "sel": ".q-contenteditable",
171
+ "key": "chapter_body"
172
+ },
173
+ {
174
+ "do": "shot",
175
+ "name": "chapter-filled"
176
+ },
177
+ {
178
+ "do": "click_real",
179
+ "text": "立即发布",
180
+ "tries": 25
181
+ },
182
+ {
183
+ "do": "navigate",
184
+ "url": "about:blank"
185
+ },
186
+ {
187
+ "do": "navigate",
188
+ "url": "{draft_url}"
189
+ },
190
+ {
191
+ "do": "wait",
192
+ "sel": ".el-table__row, tr",
193
+ "timeout": 25
194
+ },
195
+ {
196
+ "do": "click_in",
197
+ "scope_text": "{chapter_title}",
198
+ "text": "立即发布"
199
+ },
200
+ {
201
+ "do": "click_real",
202
+ "text": "更正序号去发布",
203
+ "tries": 20,
204
+ "optional": true
205
+ },
206
+ {
207
+ "do": "click_real",
208
+ "text": "确认发布",
209
+ "tries": 20,
210
+ "optional": true
211
+ },
212
+ {
213
+ "do": "verify",
214
+ "url": "{chapter_manage_url}",
215
+ "any": [
216
+ "{chapter_title}"
217
+ ],
218
+ "settle": 6
219
+ }
220
+ ],
221
+ "check_login": [
222
+ {
223
+ "do": "navigate",
224
+ "url": "{home}"
225
+ },
226
+ {
227
+ "do": "url_any",
228
+ "any": [
229
+ "qimao.com"
230
+ ]
231
+ }
232
+ ]
233
+ }
@@ -8,9 +8,10 @@
8
8
  busy 发布动作进行中(结束回 connected / error)
9
9
  error 最后一次动作失败(error 字段带人话原因,可重试)
10
10
 
11
- 浏览器生命周期:每平台一个持久化 profile(data/publish/profiles/<id>),
12
- 登录态落在 profile 里。服务重启后按 state.json 记住的调试端口 attach
13
- 旧实例;实例已死才重新 launch——用户登录一次,之后无感。
11
+ 浏览器生命周期:每平台一个持久化 profile(~/.codebee/publish_profiles/<id>,
12
+ 仓库外——GB 级浏览器运行时数据不进仓库目录),登录态落在 profile 里。
13
+ 服务重启后按 state.json 记住的调试端口 attach 旧实例;实例已死才重新
14
+ launch——用户登录一次,之后无感。
14
15
 
15
16
  与 bookmeta.generate_async 同款线程纪律:动作起后台线程即返回,前端靠
16
17
  view()(SSE/轮询)看进度;线程内任何异常都落终态,绝不悬挂。
@@ -80,7 +81,7 @@ def view():
80
81
  out[pid] = {"label": mod.CONFIG["label"], "status": s.get("status") or "none",
81
82
  "at": s.get("at") or "", "error": s.get("error") or "",
82
83
  "last_action": s.get("last_action") or "",
83
- "profile": str(paths.PUBLISH_DIR / "profiles" / pid)}
84
+ "profile": str(_profile_dir(pid))}
84
85
  return {"platforms": out, "browser_found": bool(find_browser())}
85
86
 
86
87
 
@@ -96,8 +97,49 @@ def recover_orphans():
96
97
 
97
98
 
98
99
  # ---------------------------------------------------------------- 浏览器会话
100
+ def _profiles_base():
101
+ """profile 存放根:用户主目录 ~/.codebee/publish_profiles(仓库外)。
102
+
103
+ 浏览器 profile 是 GB 级运行时数据(缓存/扩展/字体),放仓库 data/ 里
104
+ 会拖垮静态扫描/备份(639M→986M 实测淹没整仓安全扫描),且登录态
105
+ cookie 没必要进任何仓库周边流程。"""
106
+ from pathlib import Path as _P
107
+ return _P.home() / ".codebee" / "publish_profiles"
108
+
109
+
110
+ def _migrate_legacy_profiles():
111
+ """一次性迁移:老位置 data/publish/profiles/<plat> 整体搬到新根(保登录态)。
112
+
113
+ 新位置已有同名平台目录时跳过(老数据视为已废弃);搬家失败静默——
114
+ 大不了用户重扫一次码。"""
115
+ from pathlib import Path as _P
116
+ legacy = _P(paths.PUBLISH_DIR) / "profiles"
117
+ if not legacy.is_dir():
118
+ return
119
+ base = _profiles_base()
120
+ try:
121
+ base.mkdir(parents=True, exist_ok=True)
122
+ except OSError:
123
+ return
124
+ for plat_dir in legacy.iterdir():
125
+ if not plat_dir.is_dir():
126
+ continue
127
+ dst = base / plat_dir.name
128
+ if dst.exists():
129
+ continue
130
+ try:
131
+ import shutil
132
+ shutil.move(str(plat_dir), str(dst)) # 跨盘(仓库盘→系统盘)也能搬
133
+ except OSError:
134
+ continue
135
+ try:
136
+ legacy.rmdir() # 空了才删得掉;还有残留就留给下次
137
+ except OSError:
138
+ pass
139
+
140
+
99
141
  def _profile_dir(plat):
100
- return paths.PUBLISH_DIR / "profiles" / plat
142
+ return _profiles_base() / plat
101
143
 
102
144
 
103
145
  def _ensure_browser(plat):
@@ -122,6 +164,18 @@ def _ensure_browser(plat):
122
164
 
123
165
  def _open_page(plat):
124
166
  b = _ensure_browser(plat)
167
+ # 单标签纪律:平台点击(如「上传章节」)会开新 tab,流程引擎的 page
168
+ # 对象可能留在旧 tab 上填错页面(0 字草稿案的乱源)。每次动作前收拢
169
+ # 到一个 tab——发布是串行作业,多 tab 只会串台。
170
+ try:
171
+ for t in b.pages()[1:]:
172
+ try:
173
+ from .browser import _http_json
174
+ _http_json("http://127.0.0.1:%d/json/close/%s" % (b.port, t["id"]))
175
+ except Exception:
176
+ pass
177
+ except Exception:
178
+ pass
125
179
  return b, b.first_page(create=True)
126
180
 
127
181
 
@@ -145,14 +199,18 @@ def _check_login(plat, page):
145
199
 
146
200
  # ---------------------------------------------------------------- 流程加载
147
201
  def load_flow(plat, action):
148
- """流程表:data/publish/flows-<plat>.json 覆盖内置默认(校准不改代码)。"""
149
- fp = paths.PUBLISH_DIR / ("flows-%s.json" % plat)
150
- try:
151
- data = json.loads(fp.read_text(encoding="utf-8"))
152
- if isinstance(data, dict) and isinstance(data.get(action), list):
153
- return data[action]
154
- except Exception:
155
- pass
202
+ """流程表三层:data/publish/flows-<plat>.json(用户校准)→ 仓库校准模板
203
+ flows-<plat>-calibrated.json(真机验证过的基线,随代码分发)→ 平台模块
204
+ 内置默认(待校准推测)。"""
205
+ from pathlib import Path
206
+ for fp in (paths.PUBLISH_DIR / ("flows-%s.json" % plat),
207
+ Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
208
+ try:
209
+ data = json.loads(fp.read_text(encoding="utf-8"))
210
+ if isinstance(data, dict) and isinstance(data.get(action), list):
211
+ return data[action]
212
+ except Exception:
213
+ pass
156
214
  return PLATFORMS[plat].FLOWS[action]
157
215
 
158
216
 
@@ -403,6 +461,7 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
403
461
  try: # verify/draft 页 URL(流程占位)
404
462
  values["chapter_manage_url"] = mod.chapter_manage_url(book)
405
463
  values["draft_url"] = mod.draft_url(book)
464
+ values["editor_url"] = mod.editor_url(book)
406
465
  except AttributeError:
407
466
  pass
408
467
  logs = []
@@ -435,3 +494,4 @@ def history(task_id=None, plat=None, limit=50):
435
494
 
436
495
 
437
496
  _load()
497
+ _migrate_legacy_profiles()
@@ -111,3 +111,16 @@ def draft_url(book):
111
111
  if bid:
112
112
  return CONFIG["book_manage"] + "/draft?id=" + bid
113
113
  return CONFIG["book_manage"]
114
+
115
+
116
+ def editor_url(book):
117
+ """章节编辑器直达(绕开会开新 tab 的「上传章节」点击)。
118
+
119
+ 缺 title 参数会被平台重定向回首页(真机实测),必须带上。"""
120
+ bid = str((book or {}).get("book_id") or "")
121
+ title = str((book or {}).get("title") or "")
122
+ if bid:
123
+ from urllib.parse import quote
124
+ return (CONFIG["book_manage"].rsplit("/", 1)[0]
125
+ + "/book-upload?id=" + bid + "&title=" + quote(title))
126
+ return CONFIG["book_manage"]
@@ -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}
21
+ "publish_fail_streak": 3, "notify_webhook": ""}
22
22
  # 并发上限 12:worker 只是拉起 CLI 子进程的调度位,跨任务无共享资源;
23
23
  # 同任务单飞守卫在 jobs 层。默认 6 对齐「多任务并行不排队」的使用预期。
24
24
  MIN_WORKERS, MAX_WORKERS = 1, 12
@@ -99,6 +99,8 @@ def save(patch):
99
99
  cur["publish_fail_streak"] = max(1, min(10, int(patch.get("publish_fail_streak"))))
100
100
  except (TypeError, ValueError):
101
101
  return cur, "publish_fail_streak 必须是 1-10 的整数"
102
+ if "notify_webhook" in patch:
103
+ cur["notify_webhook"] = str(patch.get("notify_webhook") or "").strip()[:300]
102
104
  _FILE.parent.mkdir(parents=True, exist_ok=True)
103
105
  tmp = _FILE.with_suffix(".tmp")
104
106
  tmp.write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")