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,410 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""CDP 浏览器驱动:定位浏览器 → 带 profile 启动 → WebSocket 控制页面。
|
|
3
|
+
|
|
4
|
+
设计取向:
|
|
5
|
+
- 「操作」全部走 Runtime.evaluate 注入 JS(querySelector 匹配交给浏览器自己),
|
|
6
|
+
Python 侧不解析 DOM——平台改版时只需改流程选择器表,驱动层不动;
|
|
7
|
+
- 每平台一个持久化 user-data-dir(profile):登录态落在 profile 里,
|
|
8
|
+
服务重启后重新 launch 即恢复,代码不经手 cookie;
|
|
9
|
+
- 浏览器窗口对用户可见(扫码登录、人工确认提交都靠它),不默认 headless;
|
|
10
|
+
- CDP 调试端口只绑 127.0.0.1,HTTP 探测禁用系统代理(用户挂代理时不误伤)。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import urllib.request
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .ws import MiniWS, WebSocketError
|
|
27
|
+
|
|
28
|
+
# 本机回环的 CDP HTTP 端点绝不能走系统代理(代理环境下 urlopen 会黑洞)
|
|
29
|
+
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
30
|
+
|
|
31
|
+
_CANDS = {
|
|
32
|
+
"win32": [r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
|
33
|
+
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
|
34
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
35
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"],
|
|
36
|
+
"darwin": ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
37
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
|
|
38
|
+
"linux": [],
|
|
39
|
+
}
|
|
40
|
+
_WHICH = ("msedge", "microsoft-edge", "google-chrome", "chromium", "chrome")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BrowserError(Exception):
|
|
44
|
+
"""驱动层失败(找不到浏览器/起不来/JS 执行异常),message 面向用户。"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_browser():
|
|
48
|
+
"""按 Edge → Chrome 顺序找本机浏览器可执行文件;找不到返回 None。"""
|
|
49
|
+
for p in _CANDS.get(sys.platform, []):
|
|
50
|
+
if Path(p).exists():
|
|
51
|
+
return p
|
|
52
|
+
for name in _WHICH:
|
|
53
|
+
w = shutil.which(name)
|
|
54
|
+
if w:
|
|
55
|
+
return w
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _http_json(url, timeout=5.0, method="GET"):
|
|
60
|
+
req = urllib.request.Request(url, method=method)
|
|
61
|
+
with _OPENER.open(req, timeout=timeout) as r:
|
|
62
|
+
return json.loads(r.read().decode("utf-8", "replace"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def port_alive(port, timeout=2.0):
|
|
66
|
+
"""探测 127.0.0.1:<port> 上有没有活的 CDP 服务(重启后重连用)。"""
|
|
67
|
+
try:
|
|
68
|
+
return bool(_http_json("http://127.0.0.1:%d/json/version" % port, timeout=timeout))
|
|
69
|
+
except Exception:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _free_port():
|
|
74
|
+
s = socket.socket()
|
|
75
|
+
s.bind(("127.0.0.1", 0))
|
|
76
|
+
port = s.getsockname()[1]
|
|
77
|
+
s.close()
|
|
78
|
+
return port
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Browser:
|
|
82
|
+
"""一个浏览器子进程(一个 profile 一个实例)+ 它的调试端口。"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, user_data_dir, headless=False, exe=None, extra_args=None):
|
|
85
|
+
self.exe = exe or find_browser()
|
|
86
|
+
if not self.exe:
|
|
87
|
+
raise BrowserError("没找到 Edge/Chrome 浏览器,请先安装 Microsoft Edge")
|
|
88
|
+
self.user_data_dir = str(Path(user_data_dir))
|
|
89
|
+
self.port = _free_port()
|
|
90
|
+
args = [self.exe,
|
|
91
|
+
"--remote-debugging-port=%d" % self.port,
|
|
92
|
+
"--user-data-dir=%s" % self.user_data_dir,
|
|
93
|
+
"--no-first-run", "--no-default-browser-check",
|
|
94
|
+
"--hide-crash-restore-bubble"] # 崩溃恢复气泡压掉即够;
|
|
95
|
+
# --restore-last-session 是无值开关,
|
|
96
|
+
# 带 =false 反而激活会话恢复,勿加
|
|
97
|
+
if extra_args:
|
|
98
|
+
args += list(extra_args)
|
|
99
|
+
if headless:
|
|
100
|
+
args.append("--headless=new")
|
|
101
|
+
try:
|
|
102
|
+
self.proc = subprocess.Popen(
|
|
103
|
+
args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
104
|
+
start_new_session=(os.name != "nt")) # POSIX 独立进程组供 killpg
|
|
105
|
+
# 杀树(runner 同款);Windows 忽略
|
|
106
|
+
except OSError as e:
|
|
107
|
+
raise BrowserError("浏览器启动失败:%s" % e)
|
|
108
|
+
if not self._wait_ready(15.0):
|
|
109
|
+
raise BrowserError("浏览器调试端口 15 秒内没就绪(可能被安全软件拦截)")
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------ 生命周期
|
|
112
|
+
def _wait_ready(self, timeout):
|
|
113
|
+
deadline = time.time() + timeout
|
|
114
|
+
while time.time() < deadline:
|
|
115
|
+
if self.proc.poll() is not None:
|
|
116
|
+
return False # 二开 profile 时新进程会把参数转交老实例后退出
|
|
117
|
+
if port_alive(self.port, timeout=1.0):
|
|
118
|
+
return True
|
|
119
|
+
time.sleep(0.4)
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def alive(self):
|
|
123
|
+
return port_alive(self.port, timeout=1.5)
|
|
124
|
+
|
|
125
|
+
def close(self):
|
|
126
|
+
"""杀掉浏览器进程树。登录态在 profile 里,杀掉不丢。"""
|
|
127
|
+
pid = getattr(self, "proc", None) and self.proc.pid
|
|
128
|
+
try:
|
|
129
|
+
if sys.platform == "win32" and pid:
|
|
130
|
+
subprocess.call(["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
131
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
132
|
+
elif pid:
|
|
133
|
+
# spawn 时 start_new_session,pgid==pid,连渲染进程带杀(runner 同款);
|
|
134
|
+
# SIGTERM 单杀主进程在浏览器挂死时留残余子进程锁 profile
|
|
135
|
+
os.killpg(pid, signal.SIGKILL)
|
|
136
|
+
elif self.proc:
|
|
137
|
+
self.proc.terminate()
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------ 页面
|
|
142
|
+
def pages(self):
|
|
143
|
+
return [t for t in _http_json("http://127.0.0.1:%d/json/list" % self.port)
|
|
144
|
+
if t.get("type") == "page"]
|
|
145
|
+
|
|
146
|
+
def new_page(self, url="about:blank"):
|
|
147
|
+
t = _http_json("http://127.0.0.1:%d/json/new?%s" % (self.port, url),
|
|
148
|
+
method="PUT") # Chromium 111+ 要求 PUT
|
|
149
|
+
return Page(t["webSocketDebuggerUrl"])
|
|
150
|
+
|
|
151
|
+
def first_page(self, create=True):
|
|
152
|
+
ps = self.pages()
|
|
153
|
+
if ps:
|
|
154
|
+
return Page(ps[0]["webSocketDebuggerUrl"])
|
|
155
|
+
return self.new_page() if create else None
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def attach(port):
|
|
159
|
+
"""不启动进程、直接接管已存活浏览器(服务重启后重连同 profile 的实例)。"""
|
|
160
|
+
if not port_alive(port):
|
|
161
|
+
raise BrowserError("端口 %s 上没有活着的浏览器" % port)
|
|
162
|
+
b = Browser.__new__(Browser)
|
|
163
|
+
b.port = int(port)
|
|
164
|
+
b.proc = None
|
|
165
|
+
b.user_data_dir = ""
|
|
166
|
+
b.exe = find_browser()
|
|
167
|
+
return b
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Page:
|
|
171
|
+
"""一个标签页的 CDP 会话:命令收发 + 注入式高层操作。"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, ws_url, connect_timeout=15.0):
|
|
174
|
+
m = re.match(r"ws://([^/:]+):(\d+)(/.+)", ws_url)
|
|
175
|
+
if not m:
|
|
176
|
+
raise BrowserError("无法解析调试地址:%s" % ws_url[:80])
|
|
177
|
+
try:
|
|
178
|
+
self.ws = MiniWS(m.group(1), int(m.group(2)), m.group(3),
|
|
179
|
+
timeout=connect_timeout)
|
|
180
|
+
except (WebSocketError, OSError) as e:
|
|
181
|
+
raise BrowserError("连接页面失败:%s" % e)
|
|
182
|
+
self._id = 0
|
|
183
|
+
self._events = []
|
|
184
|
+
try:
|
|
185
|
+
self.send("Runtime.enable")
|
|
186
|
+
self.send("Page.enable")
|
|
187
|
+
self._ua_override()
|
|
188
|
+
except BrowserError:
|
|
189
|
+
self.ws.close()
|
|
190
|
+
raise
|
|
191
|
+
|
|
192
|
+
def _ua_override(self):
|
|
193
|
+
"""UA 伪装成标准 Chrome:Edge 尾巴(Edg/x.y)会被部分站点(如七猫
|
|
194
|
+
建书页)的浏览器检测判「版本过低」整页替换。用 CDP Browser.getVersion
|
|
195
|
+
的真实内核版本拼标准 Chrome UA,各站点兼容性等同 Chrome。失败不拦
|
|
196
|
+
(个别上下文可能禁用该域)。"""
|
|
197
|
+
try:
|
|
198
|
+
r = self.send("Browser.getVersion", timeout=5.0)
|
|
199
|
+
ver = str((r.get("product") or "")).split("/")[-1] or "130.0.0.0"
|
|
200
|
+
ua = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
201
|
+
"(KHTML, like Gecko) Chrome/%s Safari/537.36" % ver)
|
|
202
|
+
self.send("Network.setUserAgentOverride",
|
|
203
|
+
{"userAgent": ua}, timeout=5.0)
|
|
204
|
+
except BrowserError:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------ CDP 协议
|
|
208
|
+
def send(self, method, params=None, timeout=30.0):
|
|
209
|
+
"""发一条命令并等它的响应;期间到达的事件进缓存(wait_event 消费)。"""
|
|
210
|
+
self._id += 1
|
|
211
|
+
mid = self._id
|
|
212
|
+
self.ws.send_text(json.dumps({"id": mid, "method": method,
|
|
213
|
+
"params": params or {}}))
|
|
214
|
+
deadline = time.time() + timeout
|
|
215
|
+
while True:
|
|
216
|
+
remain = deadline - time.time()
|
|
217
|
+
if remain <= 0:
|
|
218
|
+
raise BrowserError("命令 %s 等响应超时(%.0fs)" % (method, timeout))
|
|
219
|
+
try:
|
|
220
|
+
msg = json.loads(self.ws.recv_message(timeout=remain))
|
|
221
|
+
except socket.timeout:
|
|
222
|
+
continue # 到点抛 BrowserError(上面的分支)
|
|
223
|
+
except (WebSocketError, OSError) as e:
|
|
224
|
+
raise BrowserError("连接断开:%s" % e)
|
|
225
|
+
if msg.get("id") == mid:
|
|
226
|
+
if "error" in msg:
|
|
227
|
+
raise BrowserError("CDP 拒绝 %s:%s" %
|
|
228
|
+
(method, str(msg["error"].get("message"))[:160]))
|
|
229
|
+
return msg.get("result") or {}
|
|
230
|
+
if "id" not in msg:
|
|
231
|
+
self._events.append(msg)
|
|
232
|
+
|
|
233
|
+
def wait_event(self, method, timeout=30.0, drain=True):
|
|
234
|
+
"""等一个指定事件(如 Page.loadEventFired);drain 时清空旧事件。"""
|
|
235
|
+
if drain:
|
|
236
|
+
self._events = [e for e in self._events if e.get("method") != method]
|
|
237
|
+
deadline = time.time() + timeout
|
|
238
|
+
while time.time() < deadline:
|
|
239
|
+
for i, e in enumerate(self._events):
|
|
240
|
+
if e.get("method") == method:
|
|
241
|
+
del self._events[i]
|
|
242
|
+
return e.get("params") or {}
|
|
243
|
+
try:
|
|
244
|
+
msg = json.loads(self.ws.recv_message(
|
|
245
|
+
timeout=max(0.2, deadline - time.time())))
|
|
246
|
+
except socket.timeout:
|
|
247
|
+
continue
|
|
248
|
+
except (WebSocketError, OSError) as e:
|
|
249
|
+
raise BrowserError("连接断开:%s" % e)
|
|
250
|
+
if "id" not in msg:
|
|
251
|
+
self._events.append(msg)
|
|
252
|
+
raise BrowserError("等事件 %s 超时(%.0fs)" % (method, timeout))
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------ 求值
|
|
255
|
+
def evaluate(self, expr, await_promise=False, timeout=60.0):
|
|
256
|
+
"""求值 JS 表达式返回其值;JS 抛错时带出人话信息。"""
|
|
257
|
+
r = self.send("Runtime.evaluate", {
|
|
258
|
+
"expression": expr, "awaitPromise": await_promise,
|
|
259
|
+
"returnByValue": True, "userGesture": True,
|
|
260
|
+
}, timeout=timeout)
|
|
261
|
+
det = r.get("exceptionDetails")
|
|
262
|
+
if det:
|
|
263
|
+
raise BrowserError("JS 异常:%s" % json.dumps(det, ensure_ascii=False)[:220])
|
|
264
|
+
res = r.get("result") or {} # send 已剥掉外层 {id,result} 的 result
|
|
265
|
+
if res.get("subtype") == "error":
|
|
266
|
+
raise BrowserError("JS 抛错:%s" % str(res.get("description"))[:200])
|
|
267
|
+
return res.get("value")
|
|
268
|
+
|
|
269
|
+
def call(self, fn, *args, **kw):
|
|
270
|
+
"""注入一个 JS 函数并调用:call('(sel,t)=>{...}', sel, text)。"""
|
|
271
|
+
payload = ", ".join(json.dumps(a, ensure_ascii=False) for a in args)
|
|
272
|
+
return self.evaluate("(%s)(%s)" % (fn, payload), **kw)
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------------ 高层操作
|
|
275
|
+
def url(self):
|
|
276
|
+
return self.evaluate("location.href")
|
|
277
|
+
|
|
278
|
+
def navigate(self, url, timeout=30.0):
|
|
279
|
+
"""导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。"""
|
|
280
|
+
try:
|
|
281
|
+
self.send("Page.navigate", {"url": url}, timeout=timeout)
|
|
282
|
+
except BrowserError:
|
|
283
|
+
pass # 老页面销毁时连接报错属正常,轮询兜底
|
|
284
|
+
deadline = time.time() + timeout
|
|
285
|
+
while time.time() < deadline:
|
|
286
|
+
try:
|
|
287
|
+
if self.evaluate("document.readyState", timeout=3.0) == "complete":
|
|
288
|
+
return
|
|
289
|
+
except BrowserError:
|
|
290
|
+
pass # 导航间隙 evaluate 会短暂失败
|
|
291
|
+
time.sleep(0.3)
|
|
292
|
+
raise BrowserError("页面 %.0f 秒未完成加载:%s" % (timeout, url))
|
|
293
|
+
|
|
294
|
+
def exists(self, sel, timeout=1.0):
|
|
295
|
+
return bool(self.call("(s)=>!!document.querySelector(s)", sel, timeout=timeout))
|
|
296
|
+
|
|
297
|
+
def wait_for(self, sel, timeout=15.0):
|
|
298
|
+
deadline = time.time() + timeout
|
|
299
|
+
while time.time() < deadline:
|
|
300
|
+
if self.exists(sel, timeout=1.0):
|
|
301
|
+
return
|
|
302
|
+
time.sleep(0.4)
|
|
303
|
+
raise BrowserError("等待元素超时(%.0fs):%s" % (timeout, sel))
|
|
304
|
+
|
|
305
|
+
def value(self, sel):
|
|
306
|
+
return self.call("(s)=>{const e=document.querySelector(s);"
|
|
307
|
+
"return e?(e.value!==undefined?e.value:e.textContent):null}", sel)
|
|
308
|
+
|
|
309
|
+
def fill(self, sel, text):
|
|
310
|
+
"""填输入框/文本域/富文本。
|
|
311
|
+
|
|
312
|
+
React/Vue 受控组件直接赋 value 不触发框架状态,必须走原型链 native
|
|
313
|
+
setter 再补 input/change 事件;网文编辑器正文多为 contenteditable,
|
|
314
|
+
走 execCommand('insertText') 以触发其内部输入管道(先清空再插入)。"""
|
|
315
|
+
r = self.call(
|
|
316
|
+
"(s,t)=>{const el=document.querySelector(s);if(!el)"
|
|
317
|
+
"return{ok:false,err:'找不到输入框 '+s};"
|
|
318
|
+
"el.scrollIntoView({block:'center'});el.focus();"
|
|
319
|
+
"if(el.isContentEditable){"
|
|
320
|
+
"const r=document.createRange();r.selectNodeContents(el);"
|
|
321
|
+
"const g=getSelection();g.removeAllRanges();g.addRange(r);"
|
|
322
|
+
"document.execCommand('insertText',false,t);"
|
|
323
|
+
"return{ok:el.textContent.length>=Math.min(t.length,10)};}"
|
|
324
|
+
"const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype"
|
|
325
|
+
":HTMLInputElement.prototype;"
|
|
326
|
+
"const d=Object.getOwnPropertyDescriptor(proto,'value');"
|
|
327
|
+
"(d&&d.set?d.set:function(v){el.value=v}).call(el,t);"
|
|
328
|
+
"el.dispatchEvent(new Event('input',{bubbles:true}));"
|
|
329
|
+
"el.dispatchEvent(new Event('change',{bubbles:true}));"
|
|
330
|
+
"return{ok:true};}", sel, str(text))
|
|
331
|
+
if not (r or {}).get("ok"):
|
|
332
|
+
raise BrowserError("填入失败:%s" % ((r or {}).get("err") or "目标不是可输入元素"))
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
def click(self, sel):
|
|
336
|
+
r = self.call(
|
|
337
|
+
"(s)=>{const el=document.querySelector(s);if(!el)"
|
|
338
|
+
"return{ok:false,err:'找不到 '+s};"
|
|
339
|
+
"if(el.disabled)return{ok:false,err:'按钮处于禁用态(表单可能有未通过校验的字段)'};"
|
|
340
|
+
"el.scrollIntoView({block:'center'});el.click();return{ok:true};}", sel)
|
|
341
|
+
if not (r or {}).get("ok"):
|
|
342
|
+
raise BrowserError("点击失败:%s" % ((r or {}).get("err") or sel))
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
def real_click_text(self, text, scope="", contains=True, exact_fallback=True):
|
|
346
|
+
"""按文本真实点击:JS 定位元素中心坐标 → CDP Input 派发鼠标事件序列。
|
|
347
|
+
|
|
348
|
+
qm-btn 一类自定义按钮只认真实事件序列(mousedown/mouseup/focus),
|
|
349
|
+
el.click() 对它们无效——建书「确认创建」/发章「立即发布」都栽在这。
|
|
350
|
+
返回 {ok, tag?, via};找不到元素返回 {ok: False, err}。"""
|
|
351
|
+
r = self.call(
|
|
352
|
+
"(t,scope,c)=>{"
|
|
353
|
+
"const els=[...document.querySelectorAll(scope||'button,a,[role=button],span,li,[class*=btn]')];"
|
|
354
|
+
"let cands=els.filter(e=>{const x=(e.innerText||'').trim();"
|
|
355
|
+
"return x&&(c?x.includes(t):x===t);});"
|
|
356
|
+
"if(!cands.length&&c){cands=els.filter(e=>(e.innerText||'').trim()===t);}"
|
|
357
|
+
"if(!cands.length)return{ok:false,err:'nf'};"
|
|
358
|
+
"cands.sort((a,b)=>((a.innerText||'').trim().length)-((b.innerText||'').trim().length));"
|
|
359
|
+
"const el=cands[0];el.scrollIntoView({block:'center'});"
|
|
360
|
+
"const rc=el.getBoundingClientRect();"
|
|
361
|
+
"return{ok:true,x:Math.round(rc.x+rc.width/2),y:Math.round(rc.y+rc.height/2),"
|
|
362
|
+
"tag:el.tagName,cls:(el.className||'').toString().slice(0,30)};}",
|
|
363
|
+
str(text), scope or "", bool(contains))
|
|
364
|
+
if not (r or {}).get("ok"):
|
|
365
|
+
return {"ok": False, "err": "页面上找不到文本为「%s」的可点元素" % text}
|
|
366
|
+
x, y = r["x"], r["y"]
|
|
367
|
+
self.send("Input.dispatchMouseEvent",
|
|
368
|
+
{"type": "mousePressed", "x": x, "y": y,
|
|
369
|
+
"button": "left", "clickCount": 1}, timeout=8.0)
|
|
370
|
+
self.send("Input.dispatchMouseEvent",
|
|
371
|
+
{"type": "mouseReleased", "x": x, "y": y,
|
|
372
|
+
"button": "left", "clickCount": 1}, timeout=8.0)
|
|
373
|
+
return {"ok": True, "tag": r.get("tag"), "via": "input"}
|
|
374
|
+
|
|
375
|
+
def screenshot(self, fp):
|
|
376
|
+
"""整页截图存证:发布每步之后落一张,出错可回看卡在哪一步。"""
|
|
377
|
+
r = self.send("Page.captureScreenshot", {"format": "png", "fromSurface": True})
|
|
378
|
+
import base64
|
|
379
|
+
fp = Path(fp)
|
|
380
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
fp.write_bytes(base64.b64decode(r.get("data") or ""))
|
|
382
|
+
return str(fp)
|
|
383
|
+
|
|
384
|
+
def close(self):
|
|
385
|
+
try:
|
|
386
|
+
self.send("Page.close", timeout=3.0)
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
self.ws.close()
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------ 表单探测
|
|
392
|
+
def probe(self):
|
|
393
|
+
"""dump 页面上可交互元素的概要——校准流程选择器表的辅助工具。
|
|
394
|
+
|
|
395
|
+
返回 [{tag, type, sel, text, value, placeholder}],sel 是尽量稳定的
|
|
396
|
+
定位串(id/#id 优先,退而求其次 name/placeholder/文本/序号)。"""
|
|
397
|
+
return self.call(
|
|
398
|
+
"()=>{const out=[];"
|
|
399
|
+
"document.querySelectorAll('input,textarea,button,select,[contenteditable=true]').forEach(el=>{"
|
|
400
|
+
"let s='';"
|
|
401
|
+
"if(el.id)s='#'+el.id;"
|
|
402
|
+
"else if(el.name)s=el.tagName.toLowerCase()+'[name=\"'+el.name+'\"]';"
|
|
403
|
+
"else if(el.placeholder)s=el.tagName.toLowerCase()+'[placeholder=\"'+el.placeholder.slice(0,20)+'\"]';"
|
|
404
|
+
"else{const t=(el.innerText||el.value||'').trim().slice(0,12);"
|
|
405
|
+
"s=t?el.tagName.toLowerCase()+':contains('+t+')':el.tagName.toLowerCase();}"
|
|
406
|
+
"out.push({tag:el.tagName.toLowerCase(),type:el.type||'',sel:s,"
|
|
407
|
+
"text:(el.innerText||'').trim().slice(0,16),"
|
|
408
|
+
"value:(el.value||el.textContent||'').trim().slice(0,24),"
|
|
409
|
+
"placeholder:(el.placeholder||'').slice(0,20)});});"
|
|
410
|
+
"return out.slice(0,80);}", timeout=15.0) or []
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""番茄作家后台的发布流程定义(默认表:选择器为合理推测,待实测校准)。
|
|
3
|
+
|
|
4
|
+
校准方式(不改代码):把真实步骤写进 data/publish/flows-fanqie.json:
|
|
5
|
+
{"create_book": [ {"do":"navigate","url":"..."}, {"do":"fill","sel":"...","key":"title"} ... ],
|
|
6
|
+
"upload_chapter": [ ... ]}
|
|
7
|
+
存在即整体覆盖本文件的 FLOWS;用「探测表单」按钮(probe 流程)dump 出
|
|
8
|
+
页面真实元素清单后照着写即可。每次失败的截图也会指出卡在哪一步。
|
|
9
|
+
|
|
10
|
+
URL 依据:tools/fanqie_bookmeta.json 的 source 记录了 2026-09-17 实抓
|
|
11
|
+
fanqienovel.com/main/writer/create 建书弹层;入口走作家后台首页点
|
|
12
|
+
「创建作品」,不硬编码深链(深链随改版漂移,入口按钮最稳)。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
CONFIG = {
|
|
17
|
+
"id": "fanqie",
|
|
18
|
+
"label": "番茄",
|
|
19
|
+
"home": "https://fanqienovel.com/main/writer/", # 实测 writer. 子域不存在(DNS 000);快照实抓为主站路径
|
|
20
|
+
# 导航后 URL 含任一标记 → 未登录(跳到了登录/通行证页)
|
|
21
|
+
"login_url_marks": ["login", "passport", "sso", "account/signin"],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# 建书:入口 → 弹层/页面 → 文本字段直填;分类/签约模式/标签走文本点击。
|
|
25
|
+
# 文本输入类的选择器优先用 placeholder 模糊匹配(改版后 id/class 易变,
|
|
26
|
+
# 「书名」「简介」这类 placeholder 语料最稳定)。
|
|
27
|
+
CREATE_BOOK = [
|
|
28
|
+
{"do": "navigate", "url": "{home}"},
|
|
29
|
+
{"do": "url_any", "any": ["fanqienovel.com"]},
|
|
30
|
+
{"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
|
|
31
|
+
{"do": "probe", "note": "建书表单"},
|
|
32
|
+
{"do": "wait", "sel": "input[placeholder*='书名'],input[placeholder*='作品名'],input[maxlength]", "timeout": 10},
|
|
33
|
+
{"do": "fill", "sel": "input[placeholder*='书名'],input[placeholder*='作品名']", "key": "title"},
|
|
34
|
+
{"do": "fill", "sel": "textarea[placeholder*='简介'],textarea", "key": "summary"},
|
|
35
|
+
{"do": "fill", "sel": "input[placeholder*='主角']", "key": "protagonist"},
|
|
36
|
+
{"do": "click_text", "text": "{signing_mode}", "contains": False,
|
|
37
|
+
"scope": "[class*=mode] label,label,span,div"},
|
|
38
|
+
{"do": "click_text", "text": "{category}", "contains": False,
|
|
39
|
+
"scope": "[class*=categor] li,span,div"},
|
|
40
|
+
{"do": "shot", "name": "create-book-filled"},
|
|
41
|
+
{"do": "submit", "sel": "button[class*=submit],button[class*=primary]"},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# 发章:后台首页 → 按书名点进作品 → 新建章节 → 填标题与正文。
|
|
45
|
+
# book_name 由 manager 从作品登记(books.json)带入 values,click_text 复用。
|
|
46
|
+
UPLOAD_CHAPTER = [
|
|
47
|
+
{"do": "navigate", "url": "{home}"},
|
|
48
|
+
{"do": "url_any", "any": ["fanqienovel.com"]},
|
|
49
|
+
{"do": "click_text", "text": "{book_name}", "contains": True,
|
|
50
|
+
"scope": "a,span,div[class*=title],div[class*=book]"},
|
|
51
|
+
{"do": "click_text", "text": "新建章节", "contains": True, "scope": "button,a,[role=button],span"},
|
|
52
|
+
{"do": "probe", "note": "章节编辑器"},
|
|
53
|
+
{"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题']", "key": "chapter_title"},
|
|
54
|
+
{"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
|
|
55
|
+
{"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
|
|
56
|
+
{"do": "shot", "name": "chapter-filled"},
|
|
57
|
+
{"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
# 登录态探测:打开后台首页,URL 被踢到登录页 → 未登录
|
|
61
|
+
CHECK_LOGIN = [
|
|
62
|
+
{"do": "navigate", "url": "{home}"},
|
|
63
|
+
{"do": "url_any", "any": ["fanqienovel.com"]},
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# 表单探测(校准辅助):开建书入口后 dump 全部可交互元素
|
|
67
|
+
PROBE_FORM = [
|
|
68
|
+
{"do": "navigate", "url": "{home}"},
|
|
69
|
+
{"do": "url_any", "any": ["fanqienovel.com"]},
|
|
70
|
+
{"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
|
|
71
|
+
{"do": "probe", "note": "建书表单"},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
|
|
75
|
+
"check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def values_create_book(meta):
|
|
79
|
+
"""bookmeta 字段 → 建书表单值。标签/分类逐项点击由流程按 values.tags 展开
|
|
80
|
+
(manager 组装时把 tags 列表拍平成 click_text 步骤追加)。"""
|
|
81
|
+
return {
|
|
82
|
+
"title": (meta.get("book_name") or "").strip(),
|
|
83
|
+
"summary": (meta.get("summary") or "").strip(),
|
|
84
|
+
"protagonist": (meta.get("protagonist_1") or "").strip(),
|
|
85
|
+
"signing_mode": (meta.get("signing_mode") or "连载模式").strip(),
|
|
86
|
+
"category": (meta.get("category") or "").strip(),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def tag_groups(meta):
|
|
91
|
+
"""标签字段名 → 值列表(manager 用来生成逐个 click_text 步骤)。"""
|
|
92
|
+
out = []
|
|
93
|
+
for key in ("tags_theme", "tags_role", "tags_plot",
|
|
94
|
+
"content_plot", "content_emotion", "content_character", "content_world"):
|
|
95
|
+
v = meta.get(key)
|
|
96
|
+
if isinstance(v, list) and v:
|
|
97
|
+
out.append((key, [str(x) for x in v]))
|
|
98
|
+
return out
|