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.
- package/CHANGELOG.md +15 -0
- package/README.md +42 -8
- package/app/core/automation.py +7 -0
- package/app/core/catalog.py +9 -1
- package/app/core/env_scrub.py +5 -3
- package/app/core/flows.py +3 -0
- package/app/core/goal_service.py +11 -2
- package/app/core/jobs.py +38 -11
- package/app/core/market_remote.py +6 -3
- package/app/core/modelhub.py +45 -18
- package/app/core/notify.py +96 -0
- package/app/core/paihang.py +106 -0
- package/app/core/pipeline.py +26 -15
- package/app/core/publish/browser.py +63 -3
- package/app/core/publish/fanqie.py +16 -0
- package/app/core/publish/flow.py +25 -1
- package/app/core/publish/flows-fanqie-calibrated.json +37 -0
- package/app/core/publish/flows-qimao-calibrated.json +233 -0
- package/app/core/publish/manager.py +73 -13
- package/app/core/publish/qimao.py +13 -0
- package/app/core/settings.py +3 -1
- package/app/core/settings_schema.py +15 -2
- package/app/core/share_page.py +111 -0
- package/app/core/step_runner.py +23 -1
- package/app/core/telemetry.py +3 -2
- package/app/core/tlsctx.py +64 -0
- package/app/core/zentao.py +1302 -0
- package/app/main.py +89 -4
- package/app/ui/app.js +212 -1
- package/app/ui/i18n.js +54 -0
- package/app/ui/index.html +50 -0
- package/app/ui/style.css +27 -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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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"
|
package/app/core/publish/flow.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
|
@@ -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"]
|
package/app/core/settings.py
CHANGED
|
@@ -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")
|