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.
@@ -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:
@@ -276,15 +276,32 @@ class Page:
276
276
  return self.evaluate("location.href")
277
277
 
278
278
  def navigate(self, url, timeout=30.0):
279
- """导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。"""
279
+ """导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。
280
+
281
+ about:blank 起跳时 readyState 本就 complete——必须同时等 location
282
+ 真正到达目标域,否则后续步骤打在空白页上(url_any 误报)。"""
283
+ from urllib.parse import urlparse as _up
284
+ if url == "about:blank": # 中转页:无需等加载(页面忙时会假超时)
285
+ try:
286
+ self.send("Page.navigate", {"url": url}, timeout=5.0)
287
+ except BrowserError:
288
+ pass
289
+ time.sleep(0.4)
290
+ return
280
291
  try:
281
292
  self.send("Page.navigate", {"url": url}, timeout=timeout)
282
293
  except BrowserError:
283
294
  pass # 老页面销毁时连接报错属正常,轮询兜底
295
+ host = _up(url).netloc
284
296
  deadline = time.time() + timeout
297
+ seen_url = False
285
298
  while time.time() < deadline:
286
299
  try:
287
- if self.evaluate("document.readyState", timeout=3.0) == "complete":
300
+ href = self.evaluate("location.href", timeout=3.0) or ""
301
+ if not host or host in href:
302
+ seen_url = True
303
+ if seen_url and self.evaluate("document.readyState", timeout=3.0) == "complete":
304
+ time.sleep(0.5) # SPA 首帧渲染余量
288
305
  return
289
306
  except BrowserError:
290
307
  pass # 导航间隙 evaluate 会短暂失败
@@ -310,24 +327,69 @@ class Page:
310
327
  """填输入框/文本域/富文本。
311
328
 
312
329
  React/Vue 受控组件直接赋 value 不触发框架状态,必须走原型链 native
313
- setter 再补 input/change 事件;网文编辑器正文多为 contenteditable,
314
- execCommand('insertText') 以触发其内部输入管道(先清空再插入)。"""
330
+ setter 再补 input/change 事件;富文本(章节正文)走 CDP
331
+ Input.insertText——execCommand('insertText') 对千字长文会截断
332
+ (真机实测 1020 字只进 615),insertText 走完整输入管道无此限。"""
333
+ text = str(text)
334
+ ce = None
335
+ for _try in range(24): # 编辑器慢加载 + class 动态 + edit-mask
336
+ ce = self.call( # 遮罩需真实点击激活后才可编辑
337
+ "(s)=>{const els=[...document.querySelectorAll(s)];"
338
+ "let best=null;for(const e of els){"
339
+ "if(!e.isContentEditable)continue;"
340
+ "const r=e.getBoundingClientRect();"
341
+ "if(r.width<=0)continue;"
342
+ "const a=r.width*r.height;"
343
+ "if(!best||a>best.a)best={e,a};}"
344
+ "if(best){"
345
+ "const el=best.e;"
346
+ "el.scrollIntoView({block:'center'});el.focus();"
347
+ "const r=document.createRange();r.selectNodeContents(el);"
348
+ "const g=getSelection();g.removeAllRanges();g.addRange(r);return {hit:true};}"
349
+ "const vis=els.filter(e=>e.getBoundingClientRect().width>0);"
350
+ "if(!vis.length)return null;"
351
+ "let big=vis[0];for(const e of vis){"
352
+ "const r=e.getBoundingClientRect();"
353
+ "if(r.width*r.height>big.getBoundingClientRect().width*big.getBoundingClientRect().height)big=e;}"
354
+ "const r=big.getBoundingClientRect();"
355
+ "return {hit:false,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}", sel)
356
+ if ce is None:
357
+ break
358
+ if isinstance(ce, dict) and ce.get("hit"):
359
+ ce = True
360
+ break
361
+ if isinstance(ce, dict) and "x" in ce: # 遮罩未揭:真实点击激活
362
+ self.send("Input.dispatchMouseEvent",
363
+ {"type": "mousePressed", "x": ce["x"], "y": ce["y"],
364
+ "button": "left", "clickCount": 1}, timeout=8.0)
365
+ self.send("Input.dispatchMouseEvent",
366
+ {"type": "mouseReleased", "x": ce["x"], "y": ce["y"],
367
+ "button": "left", "clickCount": 1}, timeout=8.0)
368
+ ce = False
369
+ time.sleep(0.8)
370
+ continue
371
+ ce = False
372
+ time.sleep(0.5)
373
+ if ce is True: # 富文本:焦点就位后键盘级插入
374
+ self.send("Input.insertText", {"text": text}, timeout=30.0)
375
+ n = self.call("(s)=>{const els=[...document.querySelectorAll(s)].filter(e=>e.isContentEditable);"
376
+ "let n=0;for(const e of els)n=Math.max(n,(e.innerText||'').length);return n;}", sel)
377
+ if int(n or 0) < min(len(text), 20):
378
+ raise BrowserError("富文本插入不完整(%s/%d 字)" % (n, len(text)))
379
+ return True
380
+ if ce is None:
381
+ raise BrowserError("找不到输入框 %s" % sel)
315
382
  r = self.call(
316
383
  "(s,t)=>{const el=document.querySelector(s);if(!el)"
317
384
  "return{ok:false,err:'找不到输入框 '+s};"
318
385
  "el.scrollIntoView({block:'center'});el.focus();"
319
- "if(el.isContentEditable){"
320
- "const r=document.createRange();r.selectNodeContents(el);"
321
- "const g=getSelection();g.removeAllRanges();g.addRange(r);"
322
- "document.execCommand('insertText',false,t);"
323
- "return{ok:el.textContent.length>=Math.min(t.length,10)};}"
324
386
  "const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype"
325
387
  ":HTMLInputElement.prototype;"
326
388
  "const d=Object.getOwnPropertyDescriptor(proto,'value');"
327
389
  "(d&&d.set?d.set:function(v){el.value=v}).call(el,t);"
328
390
  "el.dispatchEvent(new Event('input',{bubbles:true}));"
329
391
  "el.dispatchEvent(new Event('change',{bubbles:true}));"
330
- "return{ok:true};}", sel, str(text))
392
+ "return{ok:true};}", sel, text)
331
393
  if not (r or {}).get("ok"):
332
394
  raise BrowserError("填入失败:%s" % ((r or {}).get("err") or "目标不是可输入元素"))
333
395
  return True
@@ -39,8 +39,10 @@ def _click_match_js():
39
39
  "return x && x.length<=maxLen && keys.every(k=>x.includes(k));});"
40
40
  "if(!hit.length)return{ok:false,err:'找不到同时含 '+keys.join('+')+' 的元素'};"
41
41
  "hit.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
42
- "hit[0].scrollIntoView({block:'center'});hit[0].click();"
43
- "return{ok:true,tag:hit[0].tagName};}")
42
+ "let el=hit[0];"
43
+ "const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
44
+ "act.scrollIntoView({block:'center'});act.click();"
45
+ "return{ok:true,tag:act.tagName};}")
44
46
 
45
47
 
46
48
  def _fill_label_js():
@@ -104,6 +106,8 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
104
106
  url = st["url"]
105
107
  for k, v in config.items():
106
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))
107
111
  note(i, "打开 %s" % url)
108
112
  page.navigate(url)
109
113
  elif act == "wait":
@@ -137,9 +141,64 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
137
141
  time.sleep(0.9)
138
142
  if not (r or {}).get("ok"):
139
143
  raise FlowError((r or {}).get("err") or text)
144
+ elif act == "click_real":
145
+ # 真实鼠标事件点击(CDP Input 派发):qm-btn 等自定义按钮
146
+ # 只认真实事件序列,el.click() 无效。发布确认链全靠它。
147
+ text = str(st.get("text") or "")
148
+ for k, v in (values or {}).items():
149
+ text = text.replace("{%s}" % k, str(v))
150
+ if not text:
151
+ continue
152
+ note(i, "真实点击「%s」" % text)
153
+ r = None
154
+ for _try in range(int(st.get("tries") or 25)):
155
+ r = page.real_click_text(text, st.get("scope") or
156
+ "a,button,[class*=btn]")
157
+ if (r or {}).get("ok"):
158
+ break
159
+ time.sleep(0.4)
160
+ if not (r or {}).get("ok"):
161
+ if st.get("optional"):
162
+ note(i, "「%s」未出现,跳过(optional)" % text)
163
+ continue
164
+ raise FlowError((r or {}).get("err") or text)
165
+ elif act == "click_in":
166
+ # 先按 scope_text 定位容器(如书卡),再在容器内点 text 按钮。
167
+ # 解决「按钮与书名同卡片但不在彼此祖先链」的组合定位。
168
+ scope_t = str(st.get("scope_text") or "")
169
+ text = str(st.get("text") or "")
170
+ for k, v in (values or {}).items():
171
+ scope_t = scope_t.replace("{%s}" % k, str(v))
172
+ text = text.replace("{%s}" % k, str(v))
173
+ note(i, "在含「%s」的卡片内点「%s」" % (scope_t, text))
174
+ r = None
175
+ for _try in range(10): # 表格异步渲染:重试窗口加长
176
+ r = page.call(
177
+ "(sc,t)=>{"
178
+ "const cards=[...document.querySelectorAll('div,li,section,tr')].filter(e=>{"
179
+ "const x=(e.innerText||'').trim();"
180
+ "return x.includes(sc)&&x.length<600&&e.getBoundingClientRect().width>0;});"
181
+ "if(!cards.length)return{ok:false,err:'找不到含'+sc+'的卡片'};"
182
+ "cards.sort((a,b)=>(b.innerText||'').length-(a.innerText||'').length);"
183
+ "const card=cards[0];"
184
+ "const btns=[...card.querySelectorAll('a,button,[role=button],[class*=btn],span')].filter(e=>{"
185
+ "const x=(e.innerText||'').trim();return x===t||x.includes(t)&&x.length<=t.length+6;});"
186
+ "if(!btns.length)return{ok:false,err:'卡片内没有'+t};"
187
+ "btns.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
188
+ "let el=btns[0];"
189
+ "const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
190
+ "act.scrollIntoView({block:'center'});act.click();"
191
+ "return{ok:true};}", scope_t, text)
192
+ if (r or {}).get("ok"):
193
+ break
194
+ time.sleep(0.9)
195
+ if not (r or {}).get("ok"):
196
+ raise FlowError((r or {}).get("err") or "click_in 失败")
140
197
  elif act == "click_match":
141
198
  # 关键词选块(站点卡片等):点同时包含所有关键词的最小元素
142
199
  keys = [str(x) for x in (st.get("any") or []) if str(x).strip()]
200
+ for k2, v2 in (values or {}).items(): # {book_name} 等动态值
201
+ keys = [x.replace("{%s}" % k2, str(v2)) for x in keys]
143
202
  note(i, "点击含 %s 的卡片" % keys)
144
203
  r = None
145
204
  for _try in range(4): # 弹层渲染慢:找不到先等再试
@@ -253,6 +312,26 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
253
312
  note(i, "提交 %s" % st.get("sel") or "")
254
313
  page.wait_for(st["sel"], timeout=8)
255
314
  page.click(st["sel"])
315
+ elif act == "verify":
316
+ # 上线验证:导航到验证页断言文本在场——防「流程完成但平台
317
+ # 静默未发布」的假成功(0 字草稿案)。url/any 支持 values 占位。
318
+ url = str(st.get("url") or "")
319
+ for k, v in (values or {}).items():
320
+ url = url.replace("{%s}" % k, str(v))
321
+ note(i, "验证 %s" % url[:60])
322
+ page.navigate(url, timeout=30)
323
+ time.sleep(float(st.get("settle") or 4))
324
+ marks = [str(m) for m in (st.get("any") or [])]
325
+ body_txt = str(page.call(
326
+ "()=>(document.body.innerText||'')", timeout=10) or "")
327
+ for m in marks:
328
+ mk = m
329
+ for k, v in (values or {}).items():
330
+ mk = mk.replace("{%s}" % k, str(v))
331
+ if mk not in body_txt:
332
+ raise FlowError("上线验证失败:%s 页面上没有「%s」(章节未真正发布)"
333
+ % (url[:60], mk[:40]))
334
+ note(i, "验证通过")
256
335
  elif act == "url_any":
257
336
  u = str(page.url() or "")
258
337
  marks = st.get("any") or []
@@ -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
 
@@ -382,6 +440,11 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
382
440
  n_chars = len(body.replace("\n", "").replace(" ", ""))
383
441
  if n_chars < 100:
384
442
  return False, "正文过短(%d 字),疑似未完成章节" % n_chars
443
+ # 平台级下限(如七猫编辑器明示「最少 1000 字」:不足时发布被静默拦截)
444
+ min_chars = int(PLATFORMS[plat].CONFIG.get("min_chapter_chars") or 100)
445
+ if n_chars < min_chars:
446
+ return False, ("正文 %d 字未达该平台下限(%d 字),补足后再发"
447
+ % (n_chars, min_chars))
385
448
  if n_chars > 30000:
386
449
  return False, "正文超长(%d 字),平台单章上限一般 2 万字" % n_chars
387
450
  done = ledger.published_chapters(task_id, plat)
@@ -395,6 +458,12 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
395
458
  mod = PLATFORMS[plat]
396
459
  values = {"chapter_title": title, "chapter_body": body,
397
460
  "book_name": book.get("title") or ""}
461
+ try: # verify/draft 页 URL(流程占位)
462
+ values["chapter_manage_url"] = mod.chapter_manage_url(book)
463
+ values["draft_url"] = mod.draft_url(book)
464
+ values["editor_url"] = mod.editor_url(book)
465
+ except AttributeError:
466
+ pass
398
467
  logs = []
399
468
 
400
469
  def run():
@@ -425,3 +494,4 @@ def history(task_id=None, plat=None, limit=50):
425
494
 
426
495
 
427
496
  _load()
497
+ _migrate_legacy_profiles()