codebee 0.1.15 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +9 -8
- package/app/core/bookmeta.py +10 -3
- package/app/core/bookmeta_catalog.py +16 -0
- package/app/core/covergen.py +176 -56
- package/app/core/modelhub.py +23 -3
- package/app/core/notify.py +11 -1
- package/app/core/paihang.py +5 -2
- package/app/core/pipeline.py +80 -4
- package/app/core/publish/browser.py +21 -7
- package/app/core/publish/fanqie.py +16 -2
- package/app/core/publish/flow.py +25 -1
- package/app/core/publish/flows-fanqie-calibrated.json +324 -37
- package/app/core/publish/manager.py +25 -9
- package/app/core/publish/qimao.py +18 -3
- package/app/core/runner.py +19 -4
- package/app/core/settings.py +3 -1
- package/app/core/zentao.py +26 -26
- package/app/main.py +20 -0
- package/app/ui/app.js +411 -52
- package/app/ui/i18n.js +77 -7
- package/app/ui/index.html +25 -47
- package/app/ui/style.css +116 -24
- package/package.json +1 -1
|
@@ -139,14 +139,21 @@ class Browser:
|
|
|
139
139
|
except OSError as e:
|
|
140
140
|
raise BrowserError("浏览器启动失败:%s" % e)
|
|
141
141
|
if not self._wait_ready(15.0):
|
|
142
|
-
# Edge 对已有实例的 profile
|
|
143
|
-
#
|
|
144
|
-
#
|
|
142
|
+
# Edge 对已有实例的 profile:新进程转交参数后秒退(stdout 会打
|
|
143
|
+
# 「正在现有浏览器会话中打开」),调试端口是老实例自己的(甚至
|
|
144
|
+
# 自选的,真机实测指定 59852 实际跑 56394)。等自己指定的端口必然
|
|
145
|
+
# 超时——先扫进程命令行找该 profile 的真端口接管(含隐藏的后台
|
|
146
|
+
# 常驻实例:Edge「启动加速/后台运行」的进程锁着 profile 不可见)。
|
|
145
147
|
for p in _debug_ports_for_profile(self.user_data_dir):
|
|
146
148
|
if p != self.port and port_alive(p, timeout=2.0):
|
|
147
149
|
self.port = p
|
|
148
150
|
self.proc = None # 老实例不是本对象起的:close 不杀它
|
|
149
151
|
return
|
|
152
|
+
if self.proc.poll() == 0:
|
|
153
|
+
raise BrowserError(
|
|
154
|
+
"浏览器把启动请求转给了已在运行的会话(该平台浏览器有隐藏的"
|
|
155
|
+
"后台实例占着档案)——请在任务管理器结束所有 Edge 进程,"
|
|
156
|
+
"或重启电脑后重试;登录态不受影响")
|
|
150
157
|
raise BrowserError(
|
|
151
158
|
"浏览器调试端口 15 秒内没就绪:该平台可能已有浏览器实例在跑但"
|
|
152
159
|
"端口探测不到——关掉它的所有窗口后重试,或重启电脑后首次连接")
|
|
@@ -447,25 +454,32 @@ class Page:
|
|
|
447
454
|
raise BrowserError("点击失败:%s" % ((r or {}).get("err") or sel))
|
|
448
455
|
return True
|
|
449
456
|
|
|
450
|
-
def real_click_text(self, text, scope="", contains=True, exact_fallback=True
|
|
457
|
+
def real_click_text(self, text, scope="", contains=True, exact_fallback=True,
|
|
458
|
+
y_min=None, y_max=None):
|
|
451
459
|
"""按文本真实点击:JS 定位元素中心坐标 → CDP Input 派发鼠标事件序列。
|
|
452
460
|
|
|
453
461
|
qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
|
|
454
462
|
el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
|
|
463
|
+
y_min/y_max 限定元素纵向范围(区分同名 select、限制弹层内点击)。
|
|
455
464
|
返回 {ok, tag?, via};找不到元素返回 {ok: False, err}。"""
|
|
456
465
|
r = self.call(
|
|
457
|
-
"(t,scope,c)=>{"
|
|
458
|
-
"const
|
|
466
|
+
"(t,scope,c,y0,y1)=>{"
|
|
467
|
+
"const vis=e=>e.getBoundingClientRect().width>0;"
|
|
468
|
+
"const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li,[class*=btn]')].filter(vis);"
|
|
459
469
|
"let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
|
|
460
470
|
"return x&&(c?x.includes(t):x===t);});"
|
|
461
471
|
"if(!cands.length&&c){cands=els.filter(e=>(e.innerText||'').trim()===t);}"
|
|
472
|
+
"if(y0!==null)cands=cands.filter(e=>e.getBoundingClientRect().y>=y0);"
|
|
473
|
+
"if(y1!==null)cands=cands.filter(e=>e.getBoundingClientRect().y<=y1);"
|
|
462
474
|
"if(!cands.length)return{ok:false,err:'nf'};"
|
|
463
475
|
"cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
|
|
464
476
|
"const el=cands[0];el.scrollIntoView({block:'center'});"
|
|
465
477
|
"const rc=el.getBoundingClientRect();"
|
|
466
478
|
"return{ok:true,x:Math.round(rc.x+rc.width/2),y:Math.round(rc.y+rc.height/2),"
|
|
467
479
|
"tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
|
|
468
|
-
str(text), scope or "", bool(contains)
|
|
480
|
+
str(text), scope or "", bool(contains),
|
|
481
|
+
int(y_min) if y_min is not None else None,
|
|
482
|
+
int(y_max) if y_max is not None else None)
|
|
469
483
|
if not (r or {}).get("ok"):
|
|
470
484
|
return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素" % text}
|
|
471
485
|
x, y = r["x"], r["y"]
|
|
@@ -75,15 +75,29 @@ FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
|
|
|
75
75
|
"check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def _first(meta, key):
|
|
79
|
+
v = meta.get(key)
|
|
80
|
+
return str(v[0]).strip() if isinstance(v, list) and v else str(v or "").strip()
|
|
81
|
+
|
|
82
|
+
|
|
78
83
|
def values_create_book(meta):
|
|
79
|
-
"""bookmeta 字段 →
|
|
80
|
-
|
|
84
|
+
"""bookmeta 字段 → 建书表单值。标签弹层的分区点选用单值字段
|
|
85
|
+
(每组选 1 个最稳:番茄主题/角色/情节各≤2、内容组各有上限),
|
|
86
|
+
数组全量点选由 tag_groups 路径兼容。"""
|
|
81
87
|
return {
|
|
82
88
|
"title": (meta.get("book_name") or "").strip(),
|
|
83
89
|
"summary": (meta.get("summary") or "").strip(),
|
|
84
90
|
"protagonist": (meta.get("protagonist_1") or "").strip(),
|
|
91
|
+
"protagonist2": (meta.get("protagonist_2") or "").strip(),
|
|
85
92
|
"signing_mode": (meta.get("signing_mode") or "连载模式").strip(),
|
|
86
93
|
"category": (meta.get("category") or "").strip(),
|
|
94
|
+
"target_reader": (meta.get("target_reader") or "男频").strip(),
|
|
95
|
+
"tag_theme": _first(meta, "tags_theme"),
|
|
96
|
+
"tag_role": _first(meta, "tags_role"),
|
|
97
|
+
"tag_plot": _first(meta, "tags_plot"),
|
|
98
|
+
"tag_content_emotion": _first(meta, "content_emotion"),
|
|
99
|
+
"tag_content_character": _first(meta, "content_character"),
|
|
100
|
+
"tag_content_world": _first(meta, "content_world"),
|
|
87
101
|
}
|
|
88
102
|
|
|
89
103
|
|
package/app/core/publish/flow.py
CHANGED
|
@@ -146,6 +146,8 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
146
146
|
# 真实鼠标事件点击(CDP Input 派发):qm-btn 等自定义按钮
|
|
147
147
|
# 只认真实事件序列,el.click() 无效。发布确认链全靠它。
|
|
148
148
|
text = str(st.get("text") or "")
|
|
149
|
+
for k, v in (values or {}).items(): # {target_reader} 等动态值
|
|
150
|
+
text = text.replace("{%s}" % k, str(v))
|
|
149
151
|
for k, v in (values or {}).items():
|
|
150
152
|
text = text.replace("{%s}" % k, str(v))
|
|
151
153
|
if not text:
|
|
@@ -154,7 +156,9 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
154
156
|
r = None
|
|
155
157
|
for _try in range(int(st.get("tries") or 25)):
|
|
156
158
|
r = page.real_click_text(text, st.get("scope") or
|
|
157
|
-
"a,button,[class*=btn]"
|
|
159
|
+
"a,button,[class*=btn]",
|
|
160
|
+
y_min=st.get("y_min"),
|
|
161
|
+
y_max=st.get("y_max"))
|
|
158
162
|
if (r or {}).get("ok"):
|
|
159
163
|
break
|
|
160
164
|
time.sleep(0.4)
|
|
@@ -334,6 +338,26 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
334
338
|
note(i, "提交 %s" % st.get("sel") or "")
|
|
335
339
|
page.wait_for(st["sel"], timeout=8)
|
|
336
340
|
page.click(st["sel"])
|
|
341
|
+
elif act == "sleep":
|
|
342
|
+
# 固定等待(SPA 水合/动画缓冲):比 wait 更钝但最可靠
|
|
343
|
+
time.sleep(float(st.get("s") or 1.0))
|
|
344
|
+
note(i, "等待 %.1fs" % float(st.get("s") or 1.0))
|
|
345
|
+
elif act == "wait_gone":
|
|
346
|
+
# 等元素消失(弹层关闭动画对齐):连发点击被关闭动画的
|
|
347
|
+
# mask 吞掉是多层弹窗流程的经典竞态
|
|
348
|
+
sel = st["sel"]
|
|
349
|
+
note(i, "等待 %s 消失" % sel)
|
|
350
|
+
deadline = time.time() + float(st.get("timeout") or 10)
|
|
351
|
+
while time.time() < deadline:
|
|
352
|
+
try:
|
|
353
|
+
if not page.exists(sel, timeout=1.0):
|
|
354
|
+
break
|
|
355
|
+
except BrowserError:
|
|
356
|
+
break
|
|
357
|
+
time.sleep(0.4)
|
|
358
|
+
else:
|
|
359
|
+
raise FlowError("等待 %s 消失超时" % sel)
|
|
360
|
+
note(i, "已消失")
|
|
337
361
|
elif act == "verify":
|
|
338
362
|
# 上线验证:导航到验证页断言文本在场——防「流程完成但平台
|
|
339
363
|
# 静默未发布」的假成功(0 字草稿案)。url/any 支持 values 占位。
|
|
@@ -1,37 +1,324 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
1
|
+
{
|
|
2
|
+
"_note": "番茄真机校准(2026-09-19 定稿,建书+发章结构全部实测):编辑器=/main/writer/<book_id>/publish/ 直达(章节号 input.serial-input 纯数字;标题 div.serial-editor-input-hint input ≥5字/30上限;正文 .ProseMirror ≥1000字;「下一步」保存校验后按钮组右缘箭头展开下拉含「定时发布」)。建书=/main/writer/create 直达表单:radio 签约模式/目标读者(label 真实点击)→ 阅读标签 select(y<430)弹层 arco-anchor 切主分类/主题/角色/情节 + 标签云点击 + 确认 → 内容标签 select(y>430)弹层 情感/人设/世界观 同构 → 名称/主角1/主角2/简介 → 立即创建 → 跳 book-info/<id>。",
|
|
3
|
+
"check_login": [
|
|
4
|
+
{
|
|
5
|
+
"do": "navigate",
|
|
6
|
+
"url": "about:blank"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"do": "navigate",
|
|
10
|
+
"url": "{home}"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"do": "url_any",
|
|
14
|
+
"any": [
|
|
15
|
+
"fanqienovel.com"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"upload_chapter": [
|
|
20
|
+
{
|
|
21
|
+
"do": "navigate",
|
|
22
|
+
"url": "about:blank"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"do": "navigate",
|
|
26
|
+
"url": "{editor_url}"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"do": "url_any",
|
|
30
|
+
"any": [
|
|
31
|
+
"fanqienovel.com"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"do": "wait",
|
|
36
|
+
"sel": ".ProseMirror",
|
|
37
|
+
"timeout": 20
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"do": "fill",
|
|
41
|
+
"sel": "input.serial-input, input.byte-input",
|
|
42
|
+
"key": "chapter_no"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"do": "fill",
|
|
46
|
+
"sel": "div.serial-editor-input-hint input",
|
|
47
|
+
"key": "chapter_title"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"do": "fill",
|
|
51
|
+
"sel": ".ProseMirror",
|
|
52
|
+
"key": "chapter_body"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"do": "shot",
|
|
56
|
+
"name": "chapter-filled"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"do": "click_real",
|
|
60
|
+
"text": "下一步",
|
|
61
|
+
"scope": "button",
|
|
62
|
+
"tries": 15
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"do": "click_arrow",
|
|
66
|
+
"target": "下一步"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"do": "click_real",
|
|
70
|
+
"text": "定时发布",
|
|
71
|
+
"tries": 12,
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"do": "click_real",
|
|
76
|
+
"text": "发布",
|
|
77
|
+
"tries": 12,
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"do": "verify",
|
|
82
|
+
"url": "{chapter_manage_url}",
|
|
83
|
+
"any": [
|
|
84
|
+
"{chapter_title}"
|
|
85
|
+
],
|
|
86
|
+
"settle": 6
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
"probe_form": [
|
|
90
|
+
{
|
|
91
|
+
"do": "navigate",
|
|
92
|
+
"url": "about:blank"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"do": "navigate",
|
|
96
|
+
"url": "https://fanqienovel.com/main/writer/create"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"do": "url_any",
|
|
100
|
+
"any": [
|
|
101
|
+
"fanqienovel.com"
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"do": "wait",
|
|
106
|
+
"sel": "input[placeholder='请输入作品名称']",
|
|
107
|
+
"timeout": 20
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"do": "probe",
|
|
111
|
+
"note": "建书表单"
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
"create_book": [
|
|
115
|
+
{
|
|
116
|
+
"do": "navigate",
|
|
117
|
+
"url": "about:blank"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"do": "navigate",
|
|
121
|
+
"url": "https://fanqienovel.com/main/writer/create"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"do": "url_any",
|
|
125
|
+
"any": [
|
|
126
|
+
"fanqienovel.com"
|
|
127
|
+
]
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"do": "wait",
|
|
131
|
+
"sel": "input[placeholder='请输入作品名称']",
|
|
132
|
+
"timeout": 20
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"do": "sleep",
|
|
136
|
+
"s": 3
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"do": "click_real",
|
|
140
|
+
"text": "{signing_mode}",
|
|
141
|
+
"scope": "label.arco-radio,span.arco-radio,[class*=serial-radio]",
|
|
142
|
+
"tries": 8
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"do": "click_real",
|
|
146
|
+
"text": "{target_reader}",
|
|
147
|
+
"scope": "label.arco-radio,span.arco-radio,[class*=serial-radio]",
|
|
148
|
+
"tries": 8
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"do": "click_real",
|
|
152
|
+
"text": "知道了",
|
|
153
|
+
"scope": "button",
|
|
154
|
+
"tries": 4,
|
|
155
|
+
"optional": true
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"do": "click_real",
|
|
159
|
+
"text": "请选择作品标签",
|
|
160
|
+
"scope": "[class*=select-view],div,span",
|
|
161
|
+
"y_max": 430,
|
|
162
|
+
"tries": 10
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"do": "click_real",
|
|
166
|
+
"text": "{category}",
|
|
167
|
+
"scope": ".category-modal span,.category-modal div,.category-modal li",
|
|
168
|
+
"tries": 8
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"do": "click_real",
|
|
172
|
+
"text": "主题",
|
|
173
|
+
"scope": ".category-modal .arco-anchor-link",
|
|
174
|
+
"tries": 8
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"do": "click_real",
|
|
178
|
+
"text": "{tag_theme}",
|
|
179
|
+
"scope": ".category-modal span,.category-modal div,.category-modal li",
|
|
180
|
+
"y_min": 140,
|
|
181
|
+
"tries": 8,
|
|
182
|
+
"optional": true
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"do": "click_real",
|
|
186
|
+
"text": "角色",
|
|
187
|
+
"scope": ".category-modal .arco-anchor-link",
|
|
188
|
+
"tries": 8
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"do": "click_real",
|
|
192
|
+
"text": "{tag_role}",
|
|
193
|
+
"scope": ".category-modal span,.category-modal div,.category-modal li",
|
|
194
|
+
"y_min": 140,
|
|
195
|
+
"tries": 8,
|
|
196
|
+
"optional": true
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"do": "click_real",
|
|
200
|
+
"text": "情节",
|
|
201
|
+
"scope": ".category-modal .arco-anchor-link",
|
|
202
|
+
"tries": 8
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"do": "click_real",
|
|
206
|
+
"text": "{tag_plot}",
|
|
207
|
+
"scope": ".category-modal span,.category-modal div,.category-modal li",
|
|
208
|
+
"y_min": 140,
|
|
209
|
+
"tries": 8,
|
|
210
|
+
"optional": true
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"do": "click_real",
|
|
214
|
+
"text": "确认",
|
|
215
|
+
"scope": ".category-modal button",
|
|
216
|
+
"tries": 8
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
"do": "wait_gone",
|
|
220
|
+
"sel": ".category-modal",
|
|
221
|
+
"timeout": 25
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
"do": "click_real",
|
|
225
|
+
"text": "请选择作品标签",
|
|
226
|
+
"scope": "[class*=select-view],div,span",
|
|
227
|
+
"tries": 15
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"do": "click_real",
|
|
231
|
+
"text": "情感",
|
|
232
|
+
"scope": ".category-modal .arco-anchor-link",
|
|
233
|
+
"tries": 8
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"do": "click_real",
|
|
237
|
+
"text": "{tag_content_emotion}",
|
|
238
|
+
"scope": ".category-modal span,.category-modal div,.category-modal li",
|
|
239
|
+
"y_min": 140,
|
|
240
|
+
"tries": 8,
|
|
241
|
+
"optional": true
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
"do": "click_real",
|
|
245
|
+
"text": "人设",
|
|
246
|
+
"scope": ".category-modal .arco-anchor-link",
|
|
247
|
+
"tries": 8
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
"do": "click_real",
|
|
251
|
+
"text": "{tag_content_character}",
|
|
252
|
+
"scope": ".category-modal span,.category-modal div,.category-modal li",
|
|
253
|
+
"y_min": 140,
|
|
254
|
+
"tries": 8,
|
|
255
|
+
"optional": true
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
"do": "click_real",
|
|
259
|
+
"text": "世界观",
|
|
260
|
+
"scope": ".category-modal .arco-anchor-link",
|
|
261
|
+
"tries": 8
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"do": "click_real",
|
|
265
|
+
"text": "{tag_content_world}",
|
|
266
|
+
"scope": ".category-modal span,.category-modal div,.category-modal li",
|
|
267
|
+
"y_min": 140,
|
|
268
|
+
"tries": 8,
|
|
269
|
+
"optional": true
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
"do": "click_real",
|
|
273
|
+
"text": "确认",
|
|
274
|
+
"scope": ".category-modal button",
|
|
275
|
+
"tries": 8
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"do": "wait_gone",
|
|
279
|
+
"sel": ".category-modal",
|
|
280
|
+
"timeout": 25
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
"do": "fill",
|
|
284
|
+
"sel": "input[placeholder='请输入作品名称']",
|
|
285
|
+
"key": "title"
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"do": "fill",
|
|
289
|
+
"sel": "input[placeholder='请输入主角名1']",
|
|
290
|
+
"key": "protagonist"
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
"do": "fill",
|
|
294
|
+
"sel": "input[placeholder='请输入主角名2']",
|
|
295
|
+
"key": "protagonist2"
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
"do": "fill",
|
|
299
|
+
"sel": "textarea[placeholder*='请输入50-500字']",
|
|
300
|
+
"key": "summary"
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
"do": "shot",
|
|
304
|
+
"name": "create-book-filled"
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
"do": "click_real",
|
|
308
|
+
"text": "立即创建",
|
|
309
|
+
"scope": "button",
|
|
310
|
+
"tries": 10
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
"do": "wait",
|
|
314
|
+
"sel": "body",
|
|
315
|
+
"timeout": 10
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"do": "url_any",
|
|
319
|
+
"any": [
|
|
320
|
+
"book-info"
|
|
321
|
+
]
|
|
322
|
+
}
|
|
323
|
+
]
|
|
324
|
+
}
|
|
@@ -322,15 +322,27 @@ def create_book_async(task_id, plat, auto_submit=False):
|
|
|
322
322
|
b, page = _open_page(plat)
|
|
323
323
|
values = mod.values_create_book(data)
|
|
324
324
|
steps = _with_tag_steps(load_flow(plat, "create_book"),
|
|
325
|
-
mod.tag_groups(data), values)
|
|
325
|
+
mod.tag_groups(data), values, mod)
|
|
326
326
|
flow.run_flow(page, steps, values=values, config=mod.CONFIG,
|
|
327
327
|
auto_submit=auto_submit,
|
|
328
328
|
shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
|
|
329
329
|
log=logs.append)
|
|
330
|
+
# book_id:创建成功后平台跳书籍详情/编辑器,从 URL 提取
|
|
331
|
+
# (番茄 book-info/<id>;七猫 information?id=<id>)。
|
|
332
|
+
# 提取不到就先用书名登记,发章按书名找书。
|
|
333
|
+
book_id = ""
|
|
334
|
+
try:
|
|
335
|
+
m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
|
|
336
|
+
str(page.url() or ""))
|
|
337
|
+
if m_url:
|
|
338
|
+
book_id = m_url.group(1) or m_url.group(2)
|
|
339
|
+
except Exception:
|
|
340
|
+
pass
|
|
330
341
|
ledger.record(plat, "create_book", task_id=task_id, title=book_name,
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
342
|
+
book_id=book_id, ok=True,
|
|
343
|
+
error="\n".join(logs)[:2000],
|
|
344
|
+
shot=str(ledger.shot_path(plat, task_id, "")))
|
|
345
|
+
ledger.save_book(task_id, plat, {"book_id": book_id, "title": book_name})
|
|
334
346
|
_set(plat, status="connected", error="")
|
|
335
347
|
except Exception as e:
|
|
336
348
|
_set(plat, status="error", error="建书失败:%s" % e)
|
|
@@ -345,14 +357,16 @@ def create_book_async(task_id, plat, auto_submit=False):
|
|
|
345
357
|
return True, ""
|
|
346
358
|
|
|
347
359
|
|
|
348
|
-
def _with_tag_steps(steps, groups, values):
|
|
360
|
+
def _with_tag_steps(steps, groups, values, mod=None):
|
|
349
361
|
"""标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
|
|
350
|
-
"tags"
|
|
351
|
-
|
|
352
|
-
|
|
362
|
+
"tags" 步骤按组切换点选。组显示名映射来自**对应平台模块**的
|
|
363
|
+
TAG_GROUP_LABELS(曾硬编码 qimao——番茄任务 key 撞名被套上七猫组名,
|
|
364
|
+
插桩 text 变 list repr)。兼容旧流程表(无 tags 步骤时插桩);流程表
|
|
365
|
+
已用 {tag_x} 占位(番茄式 click_real)时不插桩——占位由 values 自答。"""
|
|
353
366
|
flat = []
|
|
367
|
+
labels = getattr(mod, "TAG_GROUP_LABELS", {}) if mod else {}
|
|
354
368
|
for key, tags in groups or []:
|
|
355
|
-
grp =
|
|
369
|
+
grp = labels.get(key, "")
|
|
356
370
|
flat.extend([grp, str(t)] if grp and str(t).strip() else str(t)
|
|
357
371
|
for t in tags if str(t).strip())
|
|
358
372
|
if not flat:
|
|
@@ -360,6 +374,8 @@ def _with_tag_steps(steps, groups, values):
|
|
|
360
374
|
if any(st.get("do") == "tags" for st in steps):
|
|
361
375
|
values["_tags"] = flat
|
|
362
376
|
return steps
|
|
377
|
+
if any("{tag_" in str(st.get("text") or "") for st in steps if st.get("text")):
|
|
378
|
+
return steps # 流程表自带标签占位(番茄式):values 已就绪
|
|
363
379
|
tag_steps = [{"do": "click_text", "text": str(t), "contains": False,
|
|
364
380
|
"scope": "[class*=tag] li,span,label,[class*=label]"}
|
|
365
381
|
for t in flat]
|
|
@@ -77,13 +77,28 @@ TAG_GROUP_LABELS = {"tags_style": "风格", "tags_role": "角色",
|
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def values_create_book(meta):
|
|
80
|
+
from .. import bookmeta_catalog as cat
|
|
81
|
+
target_reader = (meta.get("target_reader") or "").strip()
|
|
82
|
+
category_main = (meta.get("category_main") or "").strip()
|
|
83
|
+
category_sub = (meta.get("category_sub") or "").strip()
|
|
84
|
+
# 旧数据兜底:二级有值但一级空/与二级不自洽(如女生专属的「现实故事」
|
|
85
|
+
# 配了男生频道)会让一级 click_text 静默跳过、死在流程中段——按官方
|
|
86
|
+
# 目录跨频道反查对齐,频道与一级跟着二级走
|
|
87
|
+
if category_sub and not (category_main and
|
|
88
|
+
category_sub in (cat.QIMAO_CATS.get(target_reader)
|
|
89
|
+
or {}).get(category_main, [])):
|
|
90
|
+
loc = cat.qimao_locate_sub(category_sub)
|
|
91
|
+
if loc:
|
|
92
|
+
target_reader, category_main = loc
|
|
93
|
+
else:
|
|
94
|
+
category_sub = ""
|
|
80
95
|
return {
|
|
81
96
|
"title": (meta.get("book_name") or "").strip(),
|
|
82
97
|
"summary": (meta.get("summary") or "").strip(),
|
|
83
98
|
"protagonist": (meta.get("protagonist_1") or "").strip(),
|
|
84
|
-
"target_reader":
|
|
85
|
-
"category_main":
|
|
86
|
-
"category_sub":
|
|
99
|
+
"target_reader": target_reader,
|
|
100
|
+
"category_main": category_main,
|
|
101
|
+
"category_sub": category_sub,
|
|
87
102
|
"status": (meta.get("status") or "连载中").strip(),
|
|
88
103
|
}
|
|
89
104
|
|
package/app/core/runner.py
CHANGED
|
@@ -90,8 +90,12 @@ def _npm_shim_bypass(argv):
|
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
def _kill_tree(pid):
|
|
93
|
-
"""杀整棵进程树。Windows
|
|
94
|
-
|
|
93
|
+
"""杀整棵进程树。Windows:先 TerminateProcess 直接杀根进程(毫秒级,绝不
|
|
94
|
+
挂),再 taskkill /T 兜底扫子孙(短等待,卡死即放弃——真实案例 2026-09-19:
|
|
95
|
+
某些机器状态下 taskkill /F /T 对普通进程也挂死 30s+,等满 15s 超时会让每次
|
|
96
|
+
超时杀进程都卡 20s,看门狗/超时全被拖死;漏杀的孙进程由启动孤儿清扫兜底)。
|
|
97
|
+
POSIX 靠 spawn 时的 start_new_session(子进程自成一个进程组,pgid==pid)
|
|
98
|
+
用 killpg 连孙带杀。"""
|
|
95
99
|
if os.name != "nt":
|
|
96
100
|
try:
|
|
97
101
|
os.killpg(pid, signal.SIGKILL)
|
|
@@ -103,10 +107,21 @@ def _kill_tree(pid):
|
|
|
103
107
|
except Exception:
|
|
104
108
|
pass
|
|
105
109
|
return
|
|
110
|
+
# Windows 没有 signal.SIGKILL(直接引用会 AttributeError);os.kill 带任意
|
|
111
|
+
# 信号值都走 TerminateProcess,用 SIGTERM
|
|
106
112
|
try:
|
|
107
|
-
|
|
113
|
+
os.kill(pid, signal.SIGTERM) # Windows 语义 = TerminateProcess,只杀根进程
|
|
114
|
+
except OSError:
|
|
115
|
+
pass # 根进程可能已被 taskkill 抢先杀掉
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
try:
|
|
119
|
+
tk = subprocess.Popen(
|
|
108
120
|
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
109
|
-
|
|
121
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
122
|
+
stderr=subprocess.DEVNULL, close_fds=True, # 不继承被杀进程的管道句柄,否则 EOF 永不来、drain 烧满超时
|
|
123
|
+
creationflags=CREATE_NO_WINDOW)
|
|
124
|
+
tk.wait(timeout=1.2) # 健康机器 <1s;卡死就不再陪等(后台自行结束)
|
|
110
125
|
except Exception:
|
|
111
126
|
pass
|
|
112
127
|
|
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, "notify_webhook": ""}
|
|
21
|
+
"publish_fail_streak": 3, "notify_webhook": "", "notify_base_url": ""}
|
|
22
22
|
# 并发上限 12:worker 只是拉起 CLI 子进程的调度位,跨任务无共享资源;
|
|
23
23
|
# 同任务单飞守卫在 jobs 层。默认 6 对齐「多任务并行不排队」的使用预期。
|
|
24
24
|
MIN_WORKERS, MAX_WORKERS = 1, 12
|
|
@@ -101,6 +101,8 @@ def save(patch):
|
|
|
101
101
|
return cur, "publish_fail_streak 必须是 1-10 的整数"
|
|
102
102
|
if "notify_webhook" in patch:
|
|
103
103
|
cur["notify_webhook"] = str(patch.get("notify_webhook") or "").strip()[:300]
|
|
104
|
+
if "notify_base_url" in patch:
|
|
105
|
+
cur["notify_base_url"] = str(patch.get("notify_base_url") or "").strip()[:200]
|
|
104
106
|
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
105
107
|
tmp = _FILE.with_suffix(".tmp")
|
|
106
108
|
tmp.write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
|