codebee 0.1.9 → 0.1.11
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 +20 -0
- package/README.md +8 -6
- package/app/core/aiflavor.py +51 -0
- package/app/core/automation.py +9 -0
- package/app/core/gitmod.py +4 -0
- package/app/core/jobs.py +115 -13
- package/app/core/manager.py +49 -0
- package/app/core/modelhub.py +10 -1
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +282 -1
- package/app/core/publish/__init__.py +7 -0
- package/app/core/publish/auto.py +367 -0
- package/app/core/publish/browser.py +410 -0
- package/app/core/publish/fanqie.py +98 -0
- package/app/core/publish/flow.py +281 -0
- package/app/core/publish/ledger.py +198 -0
- package/app/core/publish/manager.py +427 -0
- package/app/core/publish/qimao.py +97 -0
- package/app/core/publish/ws.py +139 -0
- package/app/core/settings.py +18 -3
- package/app/core/store.py +24 -2
- package/app/main.py +174 -0
- package/app/ui/app.js +332 -28
- package/app/ui/i18n.js +5 -1
- package/app/ui/index.html +5 -3
- package/app/ui/style.css +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""数据驱动的发布流程解释器:按步骤表操作一个 CDP 页面。
|
|
3
|
+
|
|
4
|
+
平台流程(建书/发章)描述为步骤数组,与代码分离:
|
|
5
|
+
- 内置默认表在各平台模块(fanqie/qimao),是「待校准」的推测选择器;
|
|
6
|
+
- data/publish/flows-<platform>.json 存在时整体覆盖内置表——平台改版/
|
|
7
|
+
首次校准只改数据文件,不用动代码;
|
|
8
|
+
- 每步失败先落一张 fail-*.png 再抛人话错误,发布中断可回看卡在哪一步。
|
|
9
|
+
|
|
10
|
+
步骤类型:
|
|
11
|
+
navigate {url} 打开地址(支持 {home} 等占位符,来自平台配置)
|
|
12
|
+
wait {sel, timeout} 等元素出现
|
|
13
|
+
fill {sel, key} 把 values[key] 填进输入框(React 安全 + 富文本)
|
|
14
|
+
click {sel} 点选择器命中的元素
|
|
15
|
+
click_text {text, scope, contains} 按「可见文本」点按钮/标签(无稳定 id 的弹层项)
|
|
16
|
+
shot {name} 截图存证
|
|
17
|
+
probe {note} dump 表单元素清单(校准选择器用,结果进 log)
|
|
18
|
+
submit {sel} 终步:auto_submit=false 时跳过(留给人工确认),
|
|
19
|
+
跳过时补一张 ready-*.png,用户在浏览器窗口里自查提交
|
|
20
|
+
url_any {any: [..]} 断言当前 URL 含任一标记,不含则报错(登录跳转检测)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
from .browser import BrowserError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FlowError(Exception):
|
|
31
|
+
"""流程失败:message 面向用户(含步骤序号与截图路径线索)。"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _click_match_js():
|
|
35
|
+
# 点同时包含所有关键词的最小可见元素(长度最小者=最内层卡片)
|
|
36
|
+
return ("(keys,maxLen)=>{"
|
|
37
|
+
"const hit=[...document.querySelectorAll('div,li,section,label,span,a')].filter(e=>{"
|
|
38
|
+
"const x=(e.innerText||'').trim();"
|
|
39
|
+
"return x && x.length<=maxLen && keys.every(k=>x.includes(k));});"
|
|
40
|
+
"if(!hit.length)return{ok:false,err:'找不到同时含 '+keys.join('+')+' 的元素'};"
|
|
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};}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fill_label_js():
|
|
47
|
+
# Element UI 表单:在 .el-form-item(或 class 含 form-item 的容器)里按
|
|
48
|
+
# label 文本定位控件,走与 browser.fill 相同的 native setter 管道
|
|
49
|
+
return ("(labelText,text)=>{"
|
|
50
|
+
"const items=[...document.querySelectorAll('.el-form-item,[class*=form-item]')];"
|
|
51
|
+
"let target=null;"
|
|
52
|
+
"for(const it of items){"
|
|
53
|
+
"const lb=it.querySelector('[class*=label],[class*=label]');"
|
|
54
|
+
"const ltxt=((lb&&lb.innerText)||'').trim();"
|
|
55
|
+
"if(ltxt&<xt.includes(labelText)){target=it;break;}}"
|
|
56
|
+
"if(!target)return{ok:false,err:'找不到字段「'+labelText+'」'};"
|
|
57
|
+
"const el=target.querySelector('textarea,[contenteditable=true],input[type=text]')||"
|
|
58
|
+
"target.querySelector('input,textarea');"
|
|
59
|
+
"if(!el)return{ok:false,err:'字段「'+labelText+'」下没有输入控件'};"
|
|
60
|
+
"el.scrollIntoView({block:'center'});el.focus();"
|
|
61
|
+
"if(el.isContentEditable){"
|
|
62
|
+
"const r=document.createRange();r.selectNodeContents(el);"
|
|
63
|
+
"const g=getSelection();g.removeAllRanges();g.addRange(r);"
|
|
64
|
+
"document.execCommand('insertText',false,text);"
|
|
65
|
+
"return{ok:true};}"
|
|
66
|
+
"const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;"
|
|
67
|
+
"const d=Object.getOwnPropertyDescriptor(proto,'value');"
|
|
68
|
+
"(d&&d.set?d.set:function(v){el.value=v}).call(el,text);"
|
|
69
|
+
"el.dispatchEvent(new Event('input',{bubbles:true}));"
|
|
70
|
+
"el.dispatchEvent(new Event('change',{bubbles:true}));"
|
|
71
|
+
"return{ok:true};}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _click_text_js():
|
|
75
|
+
# contains 模式下外层容器(整页文本)也会 includes 命中——候选按文本长度
|
|
76
|
+
# 升序取最短者=最内层最精确元素(「新建小说」按钮赢过包它的大 DIV)
|
|
77
|
+
return ("(t,scope,contains)=>{"
|
|
78
|
+
"const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li')];"
|
|
79
|
+
"const cands=els.filter(e=>{const x=(e.innerText||'').trim();"
|
|
80
|
+
"return x&&(contains?x.includes(t):x===t);});"
|
|
81
|
+
"if(!cands.length)return{ok:false,err:'页面上找不到文本为「'+t+'」的可点元素'};"
|
|
82
|
+
"cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
|
|
83
|
+
"const hit=cands[0];"
|
|
84
|
+
"hit.scrollIntoView({block:'center'});hit.click();return{ok:true};}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
88
|
+
shot=None, log=None):
|
|
89
|
+
"""跑一个流程。values:fill 取值字典;config:平台 URL 等占位符来源。
|
|
90
|
+
|
|
91
|
+
shot(name) → 截图落盘函数(manager 注入,路径含任务/平台维度);
|
|
92
|
+
log(line) → 步骤日志函数(进发布记录的 log 字段)。返回执行到的步数。"""
|
|
93
|
+
values = values or {}
|
|
94
|
+
config = config or {}
|
|
95
|
+
log = log or (lambda s: None)
|
|
96
|
+
|
|
97
|
+
def note(i, s):
|
|
98
|
+
log("步骤%d %s" % (i, s))
|
|
99
|
+
|
|
100
|
+
for i, st in enumerate(steps):
|
|
101
|
+
act = (st.get("do") or "").strip()
|
|
102
|
+
try:
|
|
103
|
+
if act == "navigate":
|
|
104
|
+
url = st["url"]
|
|
105
|
+
for k, v in config.items():
|
|
106
|
+
url = url.replace("{%s}" % k, str(v))
|
|
107
|
+
note(i, "打开 %s" % url)
|
|
108
|
+
page.navigate(url)
|
|
109
|
+
elif act == "wait":
|
|
110
|
+
note(i, "等待 %s" % st["sel"])
|
|
111
|
+
page.wait_for(st["sel"], timeout=float(st.get("timeout") or 12))
|
|
112
|
+
elif act == "fill":
|
|
113
|
+
key = st.get("key") or ""
|
|
114
|
+
text = values.get(key, "")
|
|
115
|
+
if text in ("", None):
|
|
116
|
+
continue # 空值字段跳过(如无第二主角)
|
|
117
|
+
note(i, "填入 %s(%d 字)" % (key, len(str(text))))
|
|
118
|
+
page.wait_for(st["sel"], timeout=8)
|
|
119
|
+
page.fill(st["sel"], str(text))
|
|
120
|
+
elif act == "click":
|
|
121
|
+
note(i, "点击 %s" % st["sel"])
|
|
122
|
+
page.wait_for(st["sel"], timeout=8)
|
|
123
|
+
page.click(st["sel"])
|
|
124
|
+
elif act == "click_text":
|
|
125
|
+
text = str(st.get("text") or "")
|
|
126
|
+
for k, v in (values or {}).items(): # {signing_mode} 等动态值
|
|
127
|
+
text = text.replace("{%s}" % k, str(v))
|
|
128
|
+
if not text:
|
|
129
|
+
continue
|
|
130
|
+
note(i, "点击「%s」" % text)
|
|
131
|
+
r = None
|
|
132
|
+
for _try in range(3): # SPA 渲染慢:找不到先等再试
|
|
133
|
+
r = page.call(_click_text_js(), text, st.get("scope") or "",
|
|
134
|
+
bool(st.get("contains")))
|
|
135
|
+
if (r or {}).get("ok"):
|
|
136
|
+
break
|
|
137
|
+
time.sleep(0.9)
|
|
138
|
+
if not (r or {}).get("ok"):
|
|
139
|
+
raise FlowError((r or {}).get("err") or text)
|
|
140
|
+
elif act == "click_match":
|
|
141
|
+
# 关键词选块(站点卡片等):点同时包含所有关键词的最小元素
|
|
142
|
+
keys = [str(x) for x in (st.get("any") or []) if str(x).strip()]
|
|
143
|
+
note(i, "点击含 %s 的卡片" % keys)
|
|
144
|
+
r = None
|
|
145
|
+
for _try in range(4): # 弹层渲染慢:找不到先等再试
|
|
146
|
+
r = page.call(_click_match_js(), keys,
|
|
147
|
+
int(st.get("max_len") or 400))
|
|
148
|
+
if (r or {}).get("ok"):
|
|
149
|
+
break
|
|
150
|
+
time.sleep(1.0)
|
|
151
|
+
if not (r or {}).get("ok"):
|
|
152
|
+
raise FlowError((r or {}).get("err") or "卡片未找到")
|
|
153
|
+
elif act == "fill_label":
|
|
154
|
+
# 按字段标签填(Element UI form-item:label 与控件同容器)
|
|
155
|
+
key = st.get("key") or ""
|
|
156
|
+
label = str(st.get("label") or "")
|
|
157
|
+
text = str(values.get(key, "") or "")
|
|
158
|
+
if not text:
|
|
159
|
+
continue
|
|
160
|
+
note(i, "填字段「%s」(%d 字)" % (label, len(text)))
|
|
161
|
+
page.wait_for("[class*=form]", timeout=8)
|
|
162
|
+
r = None
|
|
163
|
+
for _try in range(3):
|
|
164
|
+
r = page.call(_fill_label_js(), label, text)
|
|
165
|
+
if (r or {}).get("ok"):
|
|
166
|
+
break
|
|
167
|
+
time.sleep(0.9)
|
|
168
|
+
if not (r or {}).get("ok"):
|
|
169
|
+
raise FlowError((r or {}).get("err") or "填字段失败")
|
|
170
|
+
elif act == "radio":
|
|
171
|
+
# 单选组:values[map[key]] 映射到 radio value 后点它。
|
|
172
|
+
# 隐藏 input(Element UI)点不到——点它的最近可见 label 祖先。
|
|
173
|
+
key = st.get("key") or ""
|
|
174
|
+
group = st.get("map") or {}
|
|
175
|
+
want = str(values.get(key, "")).strip()
|
|
176
|
+
val = group.get(want)
|
|
177
|
+
if val is None:
|
|
178
|
+
if st.get("optional"):
|
|
179
|
+
note(i, "跳过单选 %s(无值)" % key)
|
|
180
|
+
continue
|
|
181
|
+
raise FlowError("单选 %s:值「%s」不在映射 %s 里" % (key, want, list(group)))
|
|
182
|
+
note(i, "单选 %s=%s(value=%s)" % (key, want, val))
|
|
183
|
+
r = page.call(
|
|
184
|
+
"(v)=>{const r=[...document.querySelectorAll('input[type=radio]')]"
|
|
185
|
+
".find(x=>x.value===v);if(!r)return{ok:false,err:'radio v='+v};"
|
|
186
|
+
"const lab=r.closest('label')||(r.closest('.el-radio')||{}).firstElementChild||r;"
|
|
187
|
+
"const t=lab.matches('label,.el-radio')?lab:(r.parentElement||r);"
|
|
188
|
+
"t.scrollIntoView({block:'center'});t.click();return{ok:true};}", str(val))
|
|
189
|
+
if not (r or {}).get("ok"):
|
|
190
|
+
raise FlowError((r or {}).get("err") or "单选点击失败")
|
|
191
|
+
elif act == "tags":
|
|
192
|
+
# 标签弹层逐个点选:manager 把标签清单放 values["_tags"]。
|
|
193
|
+
# 项为 [组名, 标签] 时先点左侧组名切换(组标签懒渲染)再点标签;
|
|
194
|
+
# 纯字符串直接点。弹层打开由前置 click_text「添加标签」负责。
|
|
195
|
+
tags = values.get("_tags") or []
|
|
196
|
+
if not tags:
|
|
197
|
+
note(i, "无标签可点,跳过")
|
|
198
|
+
continue
|
|
199
|
+
scope = st.get("scope") or "span,li,label,[class*=dialog] *,[class*=popper] *"
|
|
200
|
+
note(i, "点选标签 %d 项" % len(tags))
|
|
201
|
+
for item in tags:
|
|
202
|
+
grp, tg = ("", str(item)) if isinstance(item, (str, int)) \
|
|
203
|
+
else (str(item[0] or ""), str(item[1] or ""))
|
|
204
|
+
if not tg:
|
|
205
|
+
continue
|
|
206
|
+
if grp: # 切到目标组
|
|
207
|
+
r0 = None
|
|
208
|
+
for _try in range(2):
|
|
209
|
+
r0 = page.call(_click_text_js(), grp, scope, True)
|
|
210
|
+
if (r0 or {}).get("ok"):
|
|
211
|
+
break
|
|
212
|
+
time.sleep(0.6)
|
|
213
|
+
if not (r0 or {}).get("ok"):
|
|
214
|
+
raise FlowError("标签组「%s」切换失败" % grp)
|
|
215
|
+
time.sleep(0.3)
|
|
216
|
+
r = None
|
|
217
|
+
for _try in range(3):
|
|
218
|
+
r = page.call(_click_text_js(), tg, scope, True)
|
|
219
|
+
if (r or {}).get("ok"):
|
|
220
|
+
break
|
|
221
|
+
time.sleep(0.7)
|
|
222
|
+
if not (r or {}).get("ok"):
|
|
223
|
+
raise FlowError("标签「%s」点选失败:%s" % (tg, (r or {}).get("err")))
|
|
224
|
+
elif act == "shot":
|
|
225
|
+
note(i, "截图 %s" % st.get("name"))
|
|
226
|
+
if shot:
|
|
227
|
+
shot(st.get("name") or "step")
|
|
228
|
+
elif act == "probe":
|
|
229
|
+
info = page.probe()
|
|
230
|
+
note(i, "表单探测 %s:%d 个可交互元素" % (st.get("note") or "", len(info)))
|
|
231
|
+
if log:
|
|
232
|
+
log("PROBE " + json.dumps(info, ensure_ascii=False)[:4000])
|
|
233
|
+
elif act == "submit":
|
|
234
|
+
if not auto_submit:
|
|
235
|
+
note(i, "已填好未提交——请人工检查后提交(auto_submit=false)")
|
|
236
|
+
if shot:
|
|
237
|
+
shot("ready-manual-submit")
|
|
238
|
+
return i + 1
|
|
239
|
+
if st.get("text"): # 按按钮文本提交
|
|
240
|
+
text = str(st["text"])
|
|
241
|
+
note(i, "提交「%s」(真实鼠标事件)" % text)
|
|
242
|
+
r = None
|
|
243
|
+
for _try in range(3):
|
|
244
|
+
r = page.real_click_text(
|
|
245
|
+
text, st.get("scope") or "button,a,[class*=btn]",
|
|
246
|
+
contains=True)
|
|
247
|
+
if (r or {}).get("ok"):
|
|
248
|
+
break
|
|
249
|
+
time.sleep(0.9)
|
|
250
|
+
if not (r or {}).get("ok"):
|
|
251
|
+
raise FlowError((r or {}).get("err") or "提交按钮未找到")
|
|
252
|
+
else:
|
|
253
|
+
note(i, "提交 %s" % st.get("sel") or "")
|
|
254
|
+
page.wait_for(st["sel"], timeout=8)
|
|
255
|
+
page.click(st["sel"])
|
|
256
|
+
elif act == "url_any":
|
|
257
|
+
u = str(page.url() or "")
|
|
258
|
+
marks = st.get("any") or []
|
|
259
|
+
if not any(m in u for m in marks):
|
|
260
|
+
raise FlowError("当前页面 %s 不含预期标记 %s(可能未登录或改版)"
|
|
261
|
+
% (u[:90], marks))
|
|
262
|
+
note(i, "页面标记校验通过")
|
|
263
|
+
else:
|
|
264
|
+
raise FlowError("未知步骤类型:%s" % act)
|
|
265
|
+
except FlowError:
|
|
266
|
+
_fail_shot(shot, "step%d-%s" % (i, act))
|
|
267
|
+
raise
|
|
268
|
+
except BrowserError as e:
|
|
269
|
+
_fail_shot(shot, "step%d-%s" % (i, act))
|
|
270
|
+
raise FlowError("步骤%d(%s)失败:%s" % (i, act, e))
|
|
271
|
+
except KeyError as e:
|
|
272
|
+
raise FlowError("步骤%d 缺少字段 %s" % (i, e))
|
|
273
|
+
return len(steps)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _fail_shot(shot, name):
|
|
277
|
+
try:
|
|
278
|
+
if shot:
|
|
279
|
+
shot(name)
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""发布台账:每次发布动作(连接/建书/传章节)追加一条 JSONL,append-only。
|
|
3
|
+
|
|
4
|
+
落盘 data/publish/publish-YYYYMM.jsonl(按月分文件,与用量台账同范式)。
|
|
5
|
+
幂等键 (task_id, platform, chapter_no):章节发布成功后重发即跳过——
|
|
6
|
+
断点续发(发布到一半挂了,重启后接着发没发完的)靠这一条。
|
|
7
|
+
|
|
8
|
+
作品登记 books.json:task ↔ 平台作品的绑定(建书成功后写入 book_id/标题),
|
|
9
|
+
原子写(tmp + os.replace)。与台账分工:台账只追加不改(审计/历史),
|
|
10
|
+
可变状态(当前绑定的作品)进 books.json。
|
|
11
|
+
|
|
12
|
+
截图存证:data/publish/shots/<platform>/<task_id>/<时间戳>-<步骤名>.png,
|
|
13
|
+
每步动作后落一张,出错可回看卡在哪一步;旧截图按任务清理不自动做(量小)。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from .. import paths
|
|
23
|
+
|
|
24
|
+
LOCK = threading.RLock()
|
|
25
|
+
|
|
26
|
+
FIELDS = ("ts", "day", "platform", "action", "task_id", "chapter_no",
|
|
27
|
+
"book_id", "title", "ok", "error", "shot")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _month_file(day):
|
|
31
|
+
return paths.PUBLISH_DIR / ("publish-%s.jsonl" % day[:7].replace("-", ""))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def record(platform, action, task_id="", chapter_no=0, book_id="",
|
|
35
|
+
title="", ok=True, error="", shot=""):
|
|
36
|
+
"""追加一条发布记录。异常全吞——记账失败绝不能影响发布主流程。"""
|
|
37
|
+
try:
|
|
38
|
+
rec = {
|
|
39
|
+
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
40
|
+
"day": time.strftime("%Y-%m-%d"),
|
|
41
|
+
"platform": str(platform)[:16],
|
|
42
|
+
"action": str(action)[:24],
|
|
43
|
+
"task_id": str(task_id)[:64],
|
|
44
|
+
"chapter_no": int(chapter_no or 0),
|
|
45
|
+
"book_id": str(book_id)[:80],
|
|
46
|
+
"title": str(title)[:120],
|
|
47
|
+
"ok": bool(ok),
|
|
48
|
+
"error": str(error or "")[:300],
|
|
49
|
+
"shot": str(shot)[:200],
|
|
50
|
+
}
|
|
51
|
+
with LOCK:
|
|
52
|
+
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
|
|
54
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _iter_records(days=90):
|
|
60
|
+
"""近 N 天的记录(月份文件粒度粗滤,行内再按 day 过滤)。"""
|
|
61
|
+
import datetime
|
|
62
|
+
cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
|
|
63
|
+
for fp in sorted(paths.PUBLISH_DIR.glob("publish-*.jsonl")):
|
|
64
|
+
try:
|
|
65
|
+
lines = fp.read_text(encoding="utf-8").splitlines()
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
for ln in lines:
|
|
69
|
+
try:
|
|
70
|
+
r = json.loads(ln)
|
|
71
|
+
except ValueError:
|
|
72
|
+
continue
|
|
73
|
+
if str(r.get("day") or "") >= cutoff:
|
|
74
|
+
yield r
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def published_chapters(task_id, platform):
|
|
78
|
+
"""该任务在该平台已成功发布的章节号集合(幂等跳过依据)。"""
|
|
79
|
+
out = set()
|
|
80
|
+
for r in _iter_records(3650):
|
|
81
|
+
if (r.get("action") == "upload_chapter" and r.get("ok")
|
|
82
|
+
and r.get("task_id") == task_id and r.get("platform") == platform):
|
|
83
|
+
n = r.get("chapter_no") or 0
|
|
84
|
+
if n > 0:
|
|
85
|
+
out.add(int(n))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def recent(task_id=None, platform=None, limit=50):
|
|
90
|
+
"""最近记录(新在前),详情页发布历史用。"""
|
|
91
|
+
out = []
|
|
92
|
+
for r in _iter_records(90):
|
|
93
|
+
if task_id and r.get("task_id") != task_id:
|
|
94
|
+
continue
|
|
95
|
+
if platform and r.get("platform") != platform:
|
|
96
|
+
continue
|
|
97
|
+
out.append(r)
|
|
98
|
+
out.reverse()
|
|
99
|
+
return out[:limit]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------- 作品登记
|
|
103
|
+
_BOOKS_FILE = paths.PUBLISH_DIR / "books.json"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_books():
|
|
107
|
+
try:
|
|
108
|
+
d = json.loads(_BOOKS_FILE.read_text(encoding="utf-8"))
|
|
109
|
+
return d if isinstance(d, dict) else {}
|
|
110
|
+
except Exception:
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_book(task_id, platform, info):
|
|
115
|
+
"""登记/更新任务在某平台的作品绑定。info: {book_id, title, url?}。"""
|
|
116
|
+
with LOCK:
|
|
117
|
+
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
books = load_books()
|
|
119
|
+
books.setdefault(str(task_id), {})[str(platform)] = {
|
|
120
|
+
"book_id": str(info.get("book_id") or ""),
|
|
121
|
+
"title": str(info.get("title") or "")[:120],
|
|
122
|
+
"url": str(info.get("url") or "")[:300],
|
|
123
|
+
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
124
|
+
}
|
|
125
|
+
tmp = _BOOKS_FILE.with_suffix(".tmp")
|
|
126
|
+
tmp.write_text(json.dumps(books, ensure_ascii=False, indent=1),
|
|
127
|
+
encoding="utf-8")
|
|
128
|
+
tmp.replace(_BOOKS_FILE)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def book_for(task_id, platform):
|
|
132
|
+
return ((load_books().get(str(task_id)) or {}).get(str(platform))) or None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def shot_path(platform, task_id, step):
|
|
136
|
+
"""截图存证路径(只算路径不落盘,由 browser.Page.screenshot 写)。"""
|
|
137
|
+
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
138
|
+
return paths.PUBLISH_DIR / "shots" / str(platform) / str(task_id) / \
|
|
139
|
+
("%s-%s.png" % (ts, step))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_CHAPTER_RE = re.compile(r"第\s*([0-90-9一二三四五六七八九十百千零两]+)\s*[章节回]")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_chapter_no(name):
|
|
146
|
+
"""从章节标题/文件名解析章号(第12章/第十二章),失败返回 0。"""
|
|
147
|
+
m = _CHAPTER_RE.search(str(name or ""))
|
|
148
|
+
if not m:
|
|
149
|
+
return 0
|
|
150
|
+
s = m.group(1)
|
|
151
|
+
if s.isdigit():
|
|
152
|
+
return int(s)
|
|
153
|
+
cn = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
|
|
154
|
+
"六": 6, "七": 7, "八": 8, "九": 9}
|
|
155
|
+
# 正序解析(十二=12、二十三=23、一百零五=105):数字暂存 num,
|
|
156
|
+
# 遇单位(十/百/千)把它乘上去并清零;万字内够用(章号没有更大的)
|
|
157
|
+
section, num = 0, 0
|
|
158
|
+
for ch in s:
|
|
159
|
+
if ch in cn:
|
|
160
|
+
num = cn[ch]
|
|
161
|
+
elif ch == "十":
|
|
162
|
+
section += (num or 1) * 10
|
|
163
|
+
num = 0
|
|
164
|
+
elif ch == "百":
|
|
165
|
+
section += (num or 1) * 100
|
|
166
|
+
num = 0
|
|
167
|
+
elif ch == "千":
|
|
168
|
+
section += (num or 1) * 1000
|
|
169
|
+
num = 0
|
|
170
|
+
return section + num
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------- 护栏统计
|
|
174
|
+
def today_count(task_id, platform):
|
|
175
|
+
"""该任务在该平台今日已成功发布的章数(每日上限护栏的计数口径)。"""
|
|
176
|
+
day = time.strftime("%Y-%m-%d")
|
|
177
|
+
return sum(1 for r in _iter_records(2)
|
|
178
|
+
if r.get("action") == "upload_chapter" and r.get("ok")
|
|
179
|
+
and r.get("task_id") == task_id and r.get("platform") == platform
|
|
180
|
+
and r.get("day") == day)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def consecutive_failures(platform):
|
|
184
|
+
"""该平台最近连续失败的发布动作数(连败退避护栏,跨任务口径)。
|
|
185
|
+
|
|
186
|
+
记录时间序旧→新,倒着数到第一条成功为止;连败大概率是风控或改版,
|
|
187
|
+
此时继续自动重试只会火上浇油,应转人工检查。
|
|
188
|
+
"""
|
|
189
|
+
n = 0
|
|
190
|
+
for r in reversed(list(_iter_records(7))):
|
|
191
|
+
if r.get("platform") != platform:
|
|
192
|
+
continue
|
|
193
|
+
if r.get("action") not in ("upload_chapter", "create_book"):
|
|
194
|
+
continue
|
|
195
|
+
if r.get("ok"):
|
|
196
|
+
break
|
|
197
|
+
n += 1
|
|
198
|
+
return n
|