codebee 0.1.10 → 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 +14 -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 +325 -26
- package/app/ui/i18n.js +4 -1
- package/app/ui/index.html +5 -3
- package/app/ui/style.css +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""发布管理:平台会话(attach-or-launch)、登录状态机、建书/发章后台线程。
|
|
3
|
+
|
|
4
|
+
状态机(data/publish/state.json 的 platforms.<id>.status):
|
|
5
|
+
none 从未连接
|
|
6
|
+
waiting_login 浏览器已开等用户扫码(connect 后,轮询 15 分钟)
|
|
7
|
+
connected 登录检测通过
|
|
8
|
+
busy 发布动作进行中(结束回 connected / error)
|
|
9
|
+
error 最后一次动作失败(error 字段带人话原因,可重试)
|
|
10
|
+
|
|
11
|
+
浏览器生命周期:每平台一个持久化 profile(data/publish/profiles/<id>),
|
|
12
|
+
登录态落在 profile 里。服务重启后按 state.json 记住的调试端口 attach
|
|
13
|
+
旧实例;实例已死才重新 launch——用户登录一次,之后无感。
|
|
14
|
+
|
|
15
|
+
与 bookmeta.generate_async 同款线程纪律:动作起后台线程即返回,前端靠
|
|
16
|
+
view()(SSE/轮询)看进度;线程内任何异常都落终态,绝不悬挂。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from .. import paths
|
|
25
|
+
from . import fanqie, flow, ledger, qimao
|
|
26
|
+
from .browser import Browser, BrowserError, Page
|
|
27
|
+
|
|
28
|
+
PLATFORMS = {"fanqie": fanqie, "qimao": qimao}
|
|
29
|
+
LOGIN_WAIT_S = 15 * 60 # 扫码等待窗口
|
|
30
|
+
STATUS_FILE = paths.PUBLISH_DIR / "state.json"
|
|
31
|
+
|
|
32
|
+
LOCK = threading.RLock()
|
|
33
|
+
_state = {"platforms": {}} # {plat: {status, port, at, error, last_login, last_action}}
|
|
34
|
+
_browsers = {} # plat → Browser(进程内缓存,alive 校验兜底)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------- 状态
|
|
38
|
+
def _load():
|
|
39
|
+
global _state
|
|
40
|
+
try:
|
|
41
|
+
d = json.loads(STATUS_FILE.read_text(encoding="utf-8"))
|
|
42
|
+
if isinstance(d, dict) and isinstance(d.get("platforms"), dict):
|
|
43
|
+
_state = d
|
|
44
|
+
return
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
_state = {"platforms": {}}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _save():
|
|
51
|
+
with LOCK:
|
|
52
|
+
try:
|
|
53
|
+
paths.PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
tmp = STATUS_FILE.with_suffix(".tmp")
|
|
55
|
+
tmp.write_text(json.dumps(_state, ensure_ascii=False, indent=1),
|
|
56
|
+
encoding="utf-8")
|
|
57
|
+
tmp.replace(STATUS_FILE)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _set(plat, **kv):
|
|
63
|
+
with LOCK:
|
|
64
|
+
ent = _state["platforms"].setdefault(plat, {})
|
|
65
|
+
ent.update(kv)
|
|
66
|
+
ent["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
67
|
+
_save()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _st(plat):
|
|
71
|
+
return (_state.get("platforms") or {}).get(plat) or {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def view():
|
|
75
|
+
"""前端状态视图:平台状态 + 是否找到浏览器 + 作品登记。"""
|
|
76
|
+
from .browser import find_browser
|
|
77
|
+
out = {}
|
|
78
|
+
for pid, mod in PLATFORMS.items():
|
|
79
|
+
s = _st(pid)
|
|
80
|
+
out[pid] = {"label": mod.CONFIG["label"], "status": s.get("status") or "none",
|
|
81
|
+
"at": s.get("at") or "", "error": s.get("error") or "",
|
|
82
|
+
"last_action": s.get("last_action") or "",
|
|
83
|
+
"profile": str(paths.PUBLISH_DIR / "profiles" / pid)}
|
|
84
|
+
return {"platforms": out, "browser_found": bool(find_browser())}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def recover_orphans():
|
|
88
|
+
"""启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。"""
|
|
89
|
+
_load()
|
|
90
|
+
n = 0
|
|
91
|
+
for plat in PLATFORMS:
|
|
92
|
+
if _st(plat).get("status") in ("waiting_login", "busy"):
|
|
93
|
+
_set(plat, status="error", error="上次操作随服务重启中断,请重试")
|
|
94
|
+
n += 1
|
|
95
|
+
return n
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------- 浏览器会话
|
|
99
|
+
def _profile_dir(plat):
|
|
100
|
+
return paths.PUBLISH_DIR / "profiles" / plat
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _ensure_browser(plat):
|
|
104
|
+
"""attach-or-launch:进程内缓存 → 记住的端口 → 全新 launch。"""
|
|
105
|
+
with LOCK:
|
|
106
|
+
b = _browsers.get(plat)
|
|
107
|
+
if b and b.alive():
|
|
108
|
+
return b
|
|
109
|
+
port = _st(plat).get("port") or 0
|
|
110
|
+
if port:
|
|
111
|
+
try:
|
|
112
|
+
b = Browser.attach(int(port))
|
|
113
|
+
_browsers[plat] = b
|
|
114
|
+
return b
|
|
115
|
+
except BrowserError:
|
|
116
|
+
pass # 旧实例已死:走 launch
|
|
117
|
+
b = Browser(_profile_dir(plat)) # 有窗口:用户要扫码/人工确认
|
|
118
|
+
_browsers[plat] = b
|
|
119
|
+
_set(plat, port=b.port)
|
|
120
|
+
return b
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _open_page(plat):
|
|
124
|
+
b = _ensure_browser(plat)
|
|
125
|
+
return b, b.first_page(create=True)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _check_login(plat, page):
|
|
129
|
+
"""打开后台首页看 URL 是否被踢到登录页。返回 (ok, 当前url)。
|
|
130
|
+
|
|
131
|
+
导航失败页(chrome-error://)不含登录标记,曾把「域名打不开」误判成
|
|
132
|
+
已登录——假 connected 的来源,先排除。"""
|
|
133
|
+
mod = PLATFORMS[plat]
|
|
134
|
+
try:
|
|
135
|
+
page.navigate(mod.CONFIG["home"], timeout=30)
|
|
136
|
+
except BrowserError as e:
|
|
137
|
+
return False, str(e)
|
|
138
|
+
url = str(page.url() or "")
|
|
139
|
+
if url.startswith("chrome-error://") or url.startswith("about:"):
|
|
140
|
+
return False, url # 页面没打开:网络/域名问题,不是登录态
|
|
141
|
+
if any(m in url for m in mod.CONFIG["login_url_marks"]):
|
|
142
|
+
return False, url
|
|
143
|
+
return True, url
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------- 流程加载
|
|
147
|
+
def load_flow(plat, action):
|
|
148
|
+
"""流程表:data/publish/flows-<plat>.json 覆盖内置默认(校准不改代码)。"""
|
|
149
|
+
fp = paths.PUBLISH_DIR / ("flows-%s.json" % plat)
|
|
150
|
+
try:
|
|
151
|
+
data = json.loads(fp.read_text(encoding="utf-8"))
|
|
152
|
+
if isinstance(data, dict) and isinstance(data.get(action), list):
|
|
153
|
+
return data[action]
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
return PLATFORMS[plat].FLOWS[action]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------- 动作:连接
|
|
160
|
+
def connect(plat):
|
|
161
|
+
"""开浏览器到平台首页,起后台线程轮询登录态(等扫码)。"""
|
|
162
|
+
if plat not in PLATFORMS:
|
|
163
|
+
return False, "未知平台"
|
|
164
|
+
try:
|
|
165
|
+
b, page = _open_page(plat)
|
|
166
|
+
except BrowserError as e:
|
|
167
|
+
_set(plat, status="error", error=str(e))
|
|
168
|
+
return False, str(e)
|
|
169
|
+
page.navigate(PLATFORMS[plat].CONFIG["home"])
|
|
170
|
+
_set(plat, status="waiting_login", error="")
|
|
171
|
+
|
|
172
|
+
def wait_login():
|
|
173
|
+
mod = PLATFORMS[plat]
|
|
174
|
+
deadline = time.time() + LOGIN_WAIT_S
|
|
175
|
+
while time.time() < deadline:
|
|
176
|
+
try:
|
|
177
|
+
url = str(page.url() or "")
|
|
178
|
+
if url and not any(m in url for m in mod.CONFIG["login_url_marks"]) \
|
|
179
|
+
and "about:blank" not in url and "chrome-error" not in url:
|
|
180
|
+
# 用户登录完成(离开登录页)。再主动开一次首页做复核,
|
|
181
|
+
# 复核被踢回登录页说明只是中间跳转,继续等。
|
|
182
|
+
ok, _u = _check_login(plat, page)
|
|
183
|
+
if ok:
|
|
184
|
+
_set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
|
|
185
|
+
ledger.record(plat, "connect", ok=True)
|
|
186
|
+
return
|
|
187
|
+
except (BrowserError, Exception):
|
|
188
|
+
pass # 页面被用户关掉等:继续等到超时
|
|
189
|
+
time.sleep(5)
|
|
190
|
+
_set(plat, status="error", error="等待登录超时(15 分钟),请重新点连接")
|
|
191
|
+
|
|
192
|
+
threading.Thread(target=wait_login, daemon=True,
|
|
193
|
+
name="pub-login-%s" % plat).start()
|
|
194
|
+
return True, ""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def disconnect(plat):
|
|
198
|
+
b = _browsers.pop(plat, None)
|
|
199
|
+
if b:
|
|
200
|
+
b.close()
|
|
201
|
+
_set(plat, status="none", error="")
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------- 动作:探测
|
|
206
|
+
def probe_form_async(plat):
|
|
207
|
+
"""「探测表单」:跑 probe 流程把页面真实可交互元素 dump 进台账 log。"""
|
|
208
|
+
if plat not in PLATFORMS:
|
|
209
|
+
return False, "未知平台"
|
|
210
|
+
if _st(plat).get("status") not in ("connected", "error"):
|
|
211
|
+
return False, "请先连接并登录平台"
|
|
212
|
+
_set(plat, status="busy", last_action="probe", error="")
|
|
213
|
+
|
|
214
|
+
logs = []
|
|
215
|
+
|
|
216
|
+
def run():
|
|
217
|
+
try:
|
|
218
|
+
b, page = _open_page(plat)
|
|
219
|
+
flow.run_flow(page, load_flow(plat, "probe_form"),
|
|
220
|
+
values={}, config=PLATFORMS[plat].CONFIG,
|
|
221
|
+
shot=lambda n: page.screenshot(ledger.shot_path(plat, "probe", n)),
|
|
222
|
+
log=logs.append)
|
|
223
|
+
_set(plat, status="connected")
|
|
224
|
+
ledger.record(plat, "probe", ok=True,
|
|
225
|
+
error="\n".join(logs)[:2000])
|
|
226
|
+
except Exception as e:
|
|
227
|
+
_set(plat, status="error", error="探测失败:%s" % e)
|
|
228
|
+
ledger.record(plat, "probe", ok=False, error=str(e)[:300])
|
|
229
|
+
finally:
|
|
230
|
+
_save()
|
|
231
|
+
|
|
232
|
+
threading.Thread(target=run, daemon=True, name="pub-probe-%s" % plat).start()
|
|
233
|
+
return True, ""
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------- 动作:建书
|
|
237
|
+
def create_book_async(task_id, plat, auto_submit=False):
|
|
238
|
+
"""按任务 book_meta 的资料在平台建书。返回 (ok, err)。"""
|
|
239
|
+
from .. import store
|
|
240
|
+
if plat not in PLATFORMS:
|
|
241
|
+
return False, "未知平台"
|
|
242
|
+
task = store.get_task(task_id)
|
|
243
|
+
if not task:
|
|
244
|
+
return False, "任务不存在"
|
|
245
|
+
meta = ((task.get("book_meta") or {}).get(plat) or {})
|
|
246
|
+
if meta.get("status") != "done" or not meta.get("data"):
|
|
247
|
+
return False, "请先生成该平台的作品信息"
|
|
248
|
+
if _st(plat).get("status") == "busy":
|
|
249
|
+
return False, "该平台有操作正在进行中"
|
|
250
|
+
if ledger.book_for(task_id, plat):
|
|
251
|
+
return False, "该任务已在此平台登记过作品,请直接发章"
|
|
252
|
+
ok_login, why = _login_guard(plat)
|
|
253
|
+
if not ok_login:
|
|
254
|
+
return False, why
|
|
255
|
+
_set(plat, status="busy", last_action="create_book", error="")
|
|
256
|
+
|
|
257
|
+
data = meta["data"]
|
|
258
|
+
mod = PLATFORMS[plat]
|
|
259
|
+
logs = []
|
|
260
|
+
|
|
261
|
+
def run():
|
|
262
|
+
book_name = (data.get("book_name") or "").strip()
|
|
263
|
+
try:
|
|
264
|
+
b, page = _open_page(plat)
|
|
265
|
+
values = mod.values_create_book(data)
|
|
266
|
+
steps = _with_tag_steps(load_flow(plat, "create_book"),
|
|
267
|
+
mod.tag_groups(data), values)
|
|
268
|
+
flow.run_flow(page, steps, values=values, config=mod.CONFIG,
|
|
269
|
+
auto_submit=auto_submit,
|
|
270
|
+
shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
|
|
271
|
+
log=logs.append)
|
|
272
|
+
ledger.record(plat, "create_book", task_id=task_id, title=book_name,
|
|
273
|
+
ok=True, shot=str(ledger.shot_path(plat, task_id, "")))
|
|
274
|
+
# book_id 拿不到(DOM 深链未知):先用书名登记,发章按书名找书
|
|
275
|
+
ledger.save_book(task_id, plat, {"book_id": "", "title": book_name})
|
|
276
|
+
_set(plat, status="connected", error="")
|
|
277
|
+
except Exception as e:
|
|
278
|
+
_set(plat, status="error", error="建书失败:%s" % e)
|
|
279
|
+
ledger.record(plat, "create_book", task_id=task_id, title=book_name,
|
|
280
|
+
ok=False, error=str(e)[:300],
|
|
281
|
+
shot=str(ledger.shot_path(plat, task_id, "")))
|
|
282
|
+
finally:
|
|
283
|
+
_save()
|
|
284
|
+
|
|
285
|
+
threading.Thread(target=run, daemon=True,
|
|
286
|
+
name="pub-book-%s" % plat).start()
|
|
287
|
+
return True, ""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _with_tag_steps(steps, groups, values):
|
|
291
|
+
"""标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
|
|
292
|
+
"tags" 步骤按组切换点选。组显示名映射来自平台模块 TAG_GROUP_LABELS
|
|
293
|
+
(缺省平台无映射时退化为纯标签)。兼容旧流程表(无 tags 步骤时插桩)。"""
|
|
294
|
+
from . import qimao as _qm
|
|
295
|
+
flat = []
|
|
296
|
+
for key, tags in groups or []:
|
|
297
|
+
grp = getattr(_qm, "TAG_GROUP_LABELS", {}).get(key, "")
|
|
298
|
+
flat.extend([grp, str(t)] if grp and str(t).strip() else str(t)
|
|
299
|
+
for t in tags if str(t).strip())
|
|
300
|
+
if not flat:
|
|
301
|
+
return steps
|
|
302
|
+
if any(st.get("do") == "tags" for st in steps):
|
|
303
|
+
values["_tags"] = flat
|
|
304
|
+
return steps
|
|
305
|
+
tag_steps = [{"do": "click_text", "text": str(t), "contains": False,
|
|
306
|
+
"scope": "[class*=tag] li,span,label,[class*=label]"}
|
|
307
|
+
for t in flat]
|
|
308
|
+
out, inserted = list(steps), False
|
|
309
|
+
for i, st in enumerate(out):
|
|
310
|
+
if st.get("do") in ("submit", "shot") and not inserted:
|
|
311
|
+
out[i:i] = tag_steps
|
|
312
|
+
inserted = True
|
|
313
|
+
if not inserted:
|
|
314
|
+
out += tag_steps
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _login_guard(plat):
|
|
319
|
+
"""动作前的登录前置:状态 connected 放行;否则现场复核一次。"""
|
|
320
|
+
s = _st(plat)
|
|
321
|
+
if s.get("status") == "connected":
|
|
322
|
+
return True, ""
|
|
323
|
+
try:
|
|
324
|
+
_b, page = _open_page(plat)
|
|
325
|
+
ok, url = _check_login(plat, page)
|
|
326
|
+
if ok:
|
|
327
|
+
_set(plat, status="connected")
|
|
328
|
+
return True, ""
|
|
329
|
+
return False, "平台未登录(当前页面 %s),请先点「连接平台」扫码" % url[:80]
|
|
330
|
+
except BrowserError as e:
|
|
331
|
+
return False, "浏览器不可用:%s" % e
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------- 动作:发章
|
|
335
|
+
def read_chapter(fp):
|
|
336
|
+
"""读章节文件 → (章号, 标题, 正文, 错误)。UTF-8→GBK 回退(章稿乱码教训)。
|
|
337
|
+
|
|
338
|
+
标题取首个「# 」标题行,没有则用文件名;章号从标题/文件名的
|
|
339
|
+
「第X章」解析,解析不出记 0(台账不按章号幂等,只按标题留痕)。"""
|
|
340
|
+
from pathlib import Path
|
|
341
|
+
from .. import runner
|
|
342
|
+
p = Path(fp)
|
|
343
|
+
if not p.is_file():
|
|
344
|
+
return 0, "", "", "章节文件不存在:%s" % p
|
|
345
|
+
try:
|
|
346
|
+
text = runner.read_text_any_enc(p)
|
|
347
|
+
except Exception as e:
|
|
348
|
+
return 0, "", "", "读章节文件失败:%s" % e
|
|
349
|
+
lines = text.strip().splitlines()
|
|
350
|
+
title = ""
|
|
351
|
+
body_start = 0
|
|
352
|
+
for i, ln in enumerate(lines[:5]):
|
|
353
|
+
s = ln.strip()
|
|
354
|
+
if s.startswith("#"): # 只认 markdown 标题行
|
|
355
|
+
s = s.lstrip("#").strip()
|
|
356
|
+
if s:
|
|
357
|
+
title, body_start = s, i + 1
|
|
358
|
+
break
|
|
359
|
+
if not title:
|
|
360
|
+
title = p.stem
|
|
361
|
+
body = "\n".join(lines[body_start:]).strip()
|
|
362
|
+
ch_no = ledger.parse_chapter_no(title) or ledger.parse_chapter_no(p.name)
|
|
363
|
+
return ch_no, title, body, ""
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
|
|
367
|
+
"""把一章发到平台(或填好待人工确认)。幂等:已成功发布的章号拒绝重发。"""
|
|
368
|
+
from .. import store
|
|
369
|
+
if plat not in PLATFORMS:
|
|
370
|
+
return False, "未知平台"
|
|
371
|
+
task = store.get_task(task_id)
|
|
372
|
+
if not task:
|
|
373
|
+
return False, "任务不存在"
|
|
374
|
+
book = ledger.book_for(task_id, plat)
|
|
375
|
+
if not book:
|
|
376
|
+
return False, "该任务尚未在此平台建书,请先「创建作品」"
|
|
377
|
+
if _st(plat).get("status") == "busy":
|
|
378
|
+
return False, "该平台有操作正在进行中"
|
|
379
|
+
ch_no, title, body, err = read_chapter(chapter_file)
|
|
380
|
+
if err:
|
|
381
|
+
return False, err
|
|
382
|
+
n_chars = len(body.replace("\n", "").replace(" ", ""))
|
|
383
|
+
if n_chars < 100:
|
|
384
|
+
return False, "正文过短(%d 字),疑似未完成章节" % n_chars
|
|
385
|
+
if n_chars > 30000:
|
|
386
|
+
return False, "正文超长(%d 字),平台单章上限一般 2 万字" % n_chars
|
|
387
|
+
done = ledger.published_chapters(task_id, plat)
|
|
388
|
+
if ch_no and ch_no in done:
|
|
389
|
+
return False, "第 %d 章已成功发布过(台账幂等拦截);确需重发请手工处理" % ch_no
|
|
390
|
+
ok_login, why = _login_guard(plat)
|
|
391
|
+
if not ok_login:
|
|
392
|
+
return False, why
|
|
393
|
+
_set(plat, status="busy", last_action="upload_chapter", error="")
|
|
394
|
+
|
|
395
|
+
mod = PLATFORMS[plat]
|
|
396
|
+
values = {"chapter_title": title, "chapter_body": body,
|
|
397
|
+
"book_name": book.get("title") or ""}
|
|
398
|
+
logs = []
|
|
399
|
+
|
|
400
|
+
def run():
|
|
401
|
+
try:
|
|
402
|
+
b, page = _open_page(plat)
|
|
403
|
+
flow.run_flow(page, load_flow(plat, "upload_chapter"), values=values,
|
|
404
|
+
config=mod.CONFIG, auto_submit=auto_submit,
|
|
405
|
+
shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
|
|
406
|
+
log=logs.append)
|
|
407
|
+
ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
|
|
408
|
+
book_id=book.get("book_id") or "", title=title, ok=True)
|
|
409
|
+
_set(plat, status="connected", error="")
|
|
410
|
+
except Exception as e:
|
|
411
|
+
_set(plat, status="error", error="发章失败:%s" % e)
|
|
412
|
+
ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
|
|
413
|
+
book_id=book.get("book_id") or "", title=title,
|
|
414
|
+
ok=False, error=str(e)[:300])
|
|
415
|
+
finally:
|
|
416
|
+
_save()
|
|
417
|
+
|
|
418
|
+
threading.Thread(target=run, daemon=True,
|
|
419
|
+
name="pub-ch-%s" % plat).start()
|
|
420
|
+
return True, ""
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def history(task_id=None, plat=None, limit=50):
|
|
424
|
+
return ledger.recent(task_id=task_id, platform=plat, limit=limit)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
_load()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""七猫作者后台的发布流程定义(默认表:选择器为合理推测,待实测校准)。
|
|
3
|
+
|
|
4
|
+
校准方式同番茄:data/publish/flows-qimao.json 覆盖 + 「探测表单」dump。
|
|
5
|
+
URL 依据:tools/qimao_bookmeta.json 的 source 记录 2026-09-17 登录态实抓
|
|
6
|
+
zuozhe.qimao.com/api/pc/v1/book/book-option——作者后台 PC 端在 zuozhe.qimao.com。
|
|
7
|
+
|
|
8
|
+
七猫建书表单结构(同次实抓):频道级联(男/女生 → 一级 → 二级)+ 四组标签
|
|
9
|
+
(风格/角色/情节/背景,每组 1-3 个)+ 文本字段。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
CONFIG = {
|
|
14
|
+
"id": "qimao",
|
|
15
|
+
"label": "七猫",
|
|
16
|
+
"home": "https://zuozhe.qimao.com/",
|
|
17
|
+
# 作品管理页:建书入口(「新建小说」按钮所在);建书向导第二步表单在其下
|
|
18
|
+
"book_manage": "https://zuozhe.qimao.com/front/book-manage",
|
|
19
|
+
# 编辑器顶栏明示「正文字数最少 1000 字」——不足时「立即发布」被静默拦截
|
|
20
|
+
"min_chapter_chars": 1000,
|
|
21
|
+
"login_url_marks": ["login", "signin", "passport", "sso"],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
CREATE_BOOK = [
|
|
25
|
+
{"do": "navigate", "url": "{home}"},
|
|
26
|
+
{"do": "url_any", "any": ["qimao.com"]},
|
|
27
|
+
{"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
|
|
28
|
+
{"do": "probe", "note": "建书表单"},
|
|
29
|
+
{"do": "wait", "sel": "input[placeholder*='作品'],input[placeholder*='书名'],input[maxlength]", "timeout": 10},
|
|
30
|
+
{"do": "fill", "sel": "input[placeholder*='作品'],input[placeholder*='书名']", "key": "title"},
|
|
31
|
+
{"do": "fill", "sel": "textarea[placeholder*='简介'],textarea", "key": "summary"},
|
|
32
|
+
{"do": "fill", "sel": "input[placeholder*='主角']", "key": "protagonist"},
|
|
33
|
+
# 频道级联:目标读者 → 一级分类 → 二级分类(值来自 bookmeta 的级联字段)
|
|
34
|
+
{"do": "click_text", "text": "{target_reader}", "contains": False,
|
|
35
|
+
"scope": "[class*=channel] label,label,span"},
|
|
36
|
+
{"do": "click_text", "text": "{category_main}", "contains": False,
|
|
37
|
+
"scope": "[class*=categor] li,option,span"},
|
|
38
|
+
{"do": "click_text", "text": "{category_sub}", "contains": False,
|
|
39
|
+
"scope": "[class*=categor] li,option,span"},
|
|
40
|
+
{"do": "shot", "name": "create-book-filled"},
|
|
41
|
+
{"do": "submit", "sel": "button[class*=submit],button[class*=primary],button[class*=confirm]"},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
UPLOAD_CHAPTER = [
|
|
45
|
+
{"do": "navigate", "url": "{home}"},
|
|
46
|
+
{"do": "url_any", "any": ["qimao.com"]},
|
|
47
|
+
{"do": "click_text", "text": "{book_name}", "contains": True,
|
|
48
|
+
"scope": "a,span,div[class*=title],div[class*=book]"},
|
|
49
|
+
{"do": "click_text", "text": "新建章节", "contains": True, "scope": "button,a,[role=button],span"},
|
|
50
|
+
{"do": "probe", "note": "章节编辑器"},
|
|
51
|
+
{"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题'],input[placeholder*='章名']", "key": "chapter_title"},
|
|
52
|
+
{"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],iframe", "timeout": 10},
|
|
53
|
+
{"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
|
|
54
|
+
{"do": "shot", "name": "chapter-filled"},
|
|
55
|
+
{"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
CHECK_LOGIN = [
|
|
59
|
+
{"do": "navigate", "url": "{home}"},
|
|
60
|
+
{"do": "url_any", "any": ["qimao.com"]},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
PROBE_FORM = [
|
|
64
|
+
{"do": "navigate", "url": "{home}"},
|
|
65
|
+
{"do": "url_any", "any": ["qimao.com"]},
|
|
66
|
+
{"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
|
|
67
|
+
{"do": "probe", "note": "建书表单"},
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
|
|
71
|
+
"check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# 标签字段名 → 弹层左侧组显示名(tags 步骤按组切换后点选;每组必选 1-3 个)
|
|
75
|
+
TAG_GROUP_LABELS = {"tags_style": "风格", "tags_role": "角色",
|
|
76
|
+
"tags_plot": "情节", "tags_bg": "背景"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def values_create_book(meta):
|
|
80
|
+
return {
|
|
81
|
+
"title": (meta.get("book_name") or "").strip(),
|
|
82
|
+
"summary": (meta.get("summary") or "").strip(),
|
|
83
|
+
"protagonist": (meta.get("protagonist_1") or "").strip(),
|
|
84
|
+
"target_reader": (meta.get("target_reader") or "").strip(),
|
|
85
|
+
"category_main": (meta.get("category_main") or "").strip(),
|
|
86
|
+
"category_sub": (meta.get("category_sub") or "").strip(),
|
|
87
|
+
"status": (meta.get("status") or "连载中").strip(),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def tag_groups(meta):
|
|
92
|
+
out = []
|
|
93
|
+
for key in ("tags_style", "tags_role", "tags_plot", "tags_bg"):
|
|
94
|
+
v = meta.get(key)
|
|
95
|
+
if isinstance(v, list) and v:
|
|
96
|
+
out.append((key, [str(x) for x in v]))
|
|
97
|
+
return out
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""最小 WebSocket 客户端(RFC 6455 子集)——CDP 驱动专用。
|
|
3
|
+
|
|
4
|
+
只实现客户端角色:HTTP Upgrade 握手 → 文本帧收发。CDP 的用法非常受限,
|
|
5
|
+
所以刻意不做完整协议:
|
|
6
|
+
- 客户端只发文本帧(JSON 命令),服务端回文本帧(响应/事件)+ 偶尔 ping;
|
|
7
|
+
- 截图等大响应可能分片(continuation 帧),必须重组;
|
|
8
|
+
- 不协商 permessage-deflate 等扩展,服务端帧按规范不带掩码(仍兼容读取)。
|
|
9
|
+
|
|
10
|
+
不引第三方 websocket 库:32 位 Python + npm 分发是零依赖策略,
|
|
11
|
+
标准库 socket 手写两百行即可覆盖上述用法,行为对着真 Edge 验证。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
import struct
|
|
19
|
+
|
|
20
|
+
OP_CONT, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG = 0x0, 0x1, 0x2, 0x8, 0x9, 0xA
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WebSocketError(Exception):
|
|
24
|
+
"""连接层错误(握手失败/对端关闭/帧损坏)。业务超时抛 socket.timeout。"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MiniWS:
|
|
28
|
+
"""一次性连接:不做重连(CDP 页面会话由上层 browser.py 决定重开)。"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, host, port, path, timeout=30.0):
|
|
31
|
+
self._timeout = timeout
|
|
32
|
+
self._sock = socket.create_connection((host, int(port)), timeout=timeout)
|
|
33
|
+
self._buf = b""
|
|
34
|
+
self._handshake(host, int(port), path)
|
|
35
|
+
|
|
36
|
+
# ------------------------------------------------------------ 握手
|
|
37
|
+
def _handshake(self, host, port, path):
|
|
38
|
+
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
39
|
+
req = ("GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\n"
|
|
40
|
+
"Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
|
|
41
|
+
"Sec-WebSocket-Version: 13\r\n\r\n") % (path, host, port, key)
|
|
42
|
+
self._sock.sendall(req.encode("ascii"))
|
|
43
|
+
while b"\r\n\r\n" not in self._buf:
|
|
44
|
+
chunk = self._sock.recv(4096)
|
|
45
|
+
if not chunk:
|
|
46
|
+
raise WebSocketError("握手失败:连接被关闭")
|
|
47
|
+
self._buf += chunk
|
|
48
|
+
head, _, self._buf = self._buf.partition(b"\r\n\r\n")
|
|
49
|
+
status = head.split(b"\r\n", 1)[0].decode("latin-1", "replace")
|
|
50
|
+
if " 101 " not in status and not status.endswith(" 101"):
|
|
51
|
+
raise WebSocketError("握手失败:%s" % status[:120])
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------ 发送
|
|
54
|
+
def _send_frame(self, opcode, payload):
|
|
55
|
+
mask = os.urandom(4) # 客户端→服务端必须掩码
|
|
56
|
+
n = len(payload)
|
|
57
|
+
header = bytearray([0x80 | opcode]) # FIN + opcode
|
|
58
|
+
if n < 126:
|
|
59
|
+
header.append(0x80 | n)
|
|
60
|
+
elif n < 65536:
|
|
61
|
+
header.append(0x80 | 126)
|
|
62
|
+
header += struct.pack(">H", n)
|
|
63
|
+
else:
|
|
64
|
+
header.append(0x80 | 127)
|
|
65
|
+
header += struct.pack(">Q", n)
|
|
66
|
+
header += mask
|
|
67
|
+
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
68
|
+
self._sock.sendall(bytes(header) + masked)
|
|
69
|
+
|
|
70
|
+
def send_text(self, text):
|
|
71
|
+
self._send_frame(OP_TEXT, text.encode("utf-8"))
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------ 接收
|
|
74
|
+
def _recv_exact(self, n):
|
|
75
|
+
while len(self._buf) < n:
|
|
76
|
+
chunk = self._sock.recv(max(4096, n - len(self._buf)))
|
|
77
|
+
if not chunk:
|
|
78
|
+
raise WebSocketError("连接被关闭")
|
|
79
|
+
self._buf += chunk
|
|
80
|
+
out, self._buf = self._buf[:n], self._buf[n:]
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
def recv_message(self, timeout=None):
|
|
84
|
+
"""收一条完整消息:自动应答 ping、重组分片。
|
|
85
|
+
|
|
86
|
+
返回 str(文本帧)或 bytes(二进制帧)。超时抛 socket.timeout,
|
|
87
|
+
对端关闭抛 WebSocketError。"""
|
|
88
|
+
if timeout is not None:
|
|
89
|
+
self._sock.settimeout(timeout)
|
|
90
|
+
elif self._timeout:
|
|
91
|
+
self._sock.settimeout(self._timeout)
|
|
92
|
+
opcode_msg = None
|
|
93
|
+
payload = bytearray()
|
|
94
|
+
while True:
|
|
95
|
+
b1, b2 = self._recv_exact(2)
|
|
96
|
+
fin, opcode, masked = b1 & 0x80, b1 & 0x0F, b2 & 0x80
|
|
97
|
+
n = b2 & 0x7F
|
|
98
|
+
if n == 126:
|
|
99
|
+
(n,) = struct.unpack(">H", self._recv_exact(2))
|
|
100
|
+
elif n == 127:
|
|
101
|
+
(n,) = struct.unpack(">Q", self._recv_exact(8))
|
|
102
|
+
data = self._recv_exact(n)
|
|
103
|
+
if masked:
|
|
104
|
+
mkey = self._recv_exact(4)
|
|
105
|
+
data = bytes(b ^ mkey[i % 4] for i, b in enumerate(data))
|
|
106
|
+
if opcode == OP_CLOSE:
|
|
107
|
+
raise WebSocketError("对端发送关闭帧")
|
|
108
|
+
if opcode == OP_PING:
|
|
109
|
+
self._send_frame(OP_PONG, data)
|
|
110
|
+
continue
|
|
111
|
+
if opcode == OP_PONG:
|
|
112
|
+
continue
|
|
113
|
+
if opcode in (OP_TEXT, OP_BINARY):
|
|
114
|
+
opcode_msg, payload = opcode, bytearray(data)
|
|
115
|
+
elif opcode == OP_CONT:
|
|
116
|
+
if opcode_msg is None:
|
|
117
|
+
raise WebSocketError("收到无起始帧的续帧")
|
|
118
|
+
payload += data
|
|
119
|
+
else:
|
|
120
|
+
raise WebSocketError("未知 opcode:%d" % opcode)
|
|
121
|
+
if fin:
|
|
122
|
+
return bytes(payload) if opcode_msg == OP_BINARY else \
|
|
123
|
+
payload.decode("utf-8", "replace")
|
|
124
|
+
|
|
125
|
+
def close(self):
|
|
126
|
+
try:
|
|
127
|
+
self._send_frame(OP_CLOSE, b"")
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
try:
|
|
131
|
+
self._sock.close()
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def __enter__(self):
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def __exit__(self, *exc):
|
|
139
|
+
self.close()
|