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 +13 -0
- package/README.md +21 -0
- package/app/core/covergen.py +200 -0
- package/app/core/flows.py +3 -0
- package/app/core/goal_service.py +11 -2
- package/app/core/jobs.py +5 -0
- package/app/core/market_remote.py +6 -3
- package/app/core/modelhub.py +42 -15
- package/app/core/notify.py +96 -0
- package/app/core/paihang.py +65 -0
- package/app/core/pipeline.py +26 -12
- package/app/core/publish/browser.py +72 -10
- package/app/core/publish/flow.py +81 -2
- package/app/core/publish/flows-qimao-calibrated.json +233 -0
- package/app/core/publish/manager.py +83 -13
- package/app/core/publish/qimao.py +29 -0
- package/app/core/settings.py +3 -1
- package/app/core/settings_schema.py +15 -2
- package/app/core/step_runner.py +23 -1
- package/app/core/store.py +16 -0
- package/app/core/tlsctx.py +48 -0
- package/app/main.py +27 -0
- package/app/ui/app.js +101 -0
- package/app/ui/i18n.js +10 -0
- package/app/ui/index.html +1 -0
- package/app/ui/style.css +10 -0
- package/package.json +1 -1
package/app/core/pipeline.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
-
|
|
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
|
|
314
|
-
|
|
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,
|
|
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
|
package/app/core/publish/flow.py
CHANGED
|
@@ -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]
|
|
43
|
-
"
|
|
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
|
|
12
|
-
|
|
13
|
-
旧实例;实例已死才重新
|
|
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(
|
|
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
|
|
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
|
-
"""
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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()
|