research-assistant 0.1.0
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/LICENSE +21 -0
- package/README-ZH.md +115 -0
- package/README.md +124 -0
- package/npm/bin/research-assistant.js +70 -0
- package/npm/scripts/postinstall.js +112 -0
- package/package.json +38 -0
- package/pyproject.toml +84 -0
- package/src/research_assistant/__init__.py +7 -0
- package/src/research_assistant/__main__.py +6 -0
- package/src/research_assistant/assets/researcher_persona.md +94 -0
- package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
- package/src/research_assistant/cli.py +155 -0
- package/src/research_assistant/commands/__init__.py +7 -0
- package/src/research_assistant/commands/ask.py +54 -0
- package/src/research_assistant/commands/doctor.py +201 -0
- package/src/research_assistant/commands/fetch.py +99 -0
- package/src/research_assistant/commands/locate.py +71 -0
- package/src/research_assistant/commands/search.py +161 -0
- package/src/research_assistant/commands/setup.py +249 -0
- package/src/research_assistant/commands/skills.py +60 -0
- package/src/research_assistant/config.py +258 -0
- package/src/research_assistant/errors.py +106 -0
- package/src/research_assistant/fetch/__init__.py +20 -0
- package/src/research_assistant/fetch/browser.py +613 -0
- package/src/research_assistant/fetch/cfbypass.py +318 -0
- package/src/research_assistant/fetch/html2md.py +44 -0
- package/src/research_assistant/fetch/normal.py +69 -0
- package/src/research_assistant/fetch/search_engine.py +324 -0
- package/src/research_assistant/fetch/snapshot.py +50 -0
- package/src/research_assistant/http.py +118 -0
- package/src/research_assistant/installer.py +270 -0
- package/src/research_assistant/locate/__init__.py +5 -0
- package/src/research_assistant/locate/engine.py +289 -0
- package/src/research_assistant/loginstate/__init__.py +10 -0
- package/src/research_assistant/loginstate/daemon.py +145 -0
- package/src/research_assistant/loginstate/daemon_cli.py +21 -0
- package/src/research_assistant/loginstate/daemonctl.py +116 -0
- package/src/research_assistant/loginstate/extension/background.js +167 -0
- package/src/research_assistant/loginstate/extension/manifest.json +15 -0
- package/src/research_assistant/loginstate/extension/popup.html +31 -0
- package/src/research_assistant/loginstate/extension/popup.js +33 -0
- package/src/research_assistant/output.py +140 -0
- package/src/research_assistant/providers/__init__.py +10 -0
- package/src/research_assistant/providers/base.py +93 -0
- package/src/research_assistant/providers/browser.py +91 -0
- package/src/research_assistant/providers/context7.py +171 -0
- package/src/research_assistant/providers/exa.py +353 -0
- package/src/research_assistant/providers/firecrawl.py +644 -0
- package/src/research_assistant/providers/openai_compat.py +293 -0
- package/src/research_assistant/providers/registry.py +62 -0
- package/src/research_assistant/providers/tavily.py +346 -0
- package/src/research_assistant/proxy.py +58 -0
- package/src/research_assistant/targets.py +66 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""浏览器抓取:1 浏览器多 tab + headed + CF auto-detect + 网络静默等待。
|
|
2
|
+
|
|
3
|
+
fetch 浏览器层与 browser 平台共用。1 个 ChromiumPage(browser)+ N tab(每 url 一个),共享
|
|
4
|
+
cookie(注一次),ThreadPoolExecutor 并发各 tab:导航 → cfbypass.solve(无 CF 秒过,有 CF 解题)
|
|
5
|
+
→ _wait_network_idle(网络稳定:idle 静默 / 稳态 polling / timeout 兜底)→ html2md。
|
|
6
|
+
启动前清孤儿 profile + browser 锁(崩溃自愈)。浏览器进程数受 config.browser.max_browser_instances
|
|
7
|
+
跨进程限流(.browser-locks/ PID 标记)。
|
|
8
|
+
|
|
9
|
+
演进:去 headless(2026-07,统一 headed+CF auto-detect);多 tab(2026-07,原每 url 独立浏览器
|
|
10
|
+
改 1 browser 多 tab 省内存);网络静默等待(2026-07,替代 html 长度轮询,JS/API 加载更稳)。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
import logging
|
|
23
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .. import config as config_mod
|
|
28
|
+
from ..config import Config
|
|
29
|
+
from ..errors import AntibotError, ConfigError, ResearchAssistantError
|
|
30
|
+
from ..proxy import resolve_proxy
|
|
31
|
+
from .html2md import html_to_md
|
|
32
|
+
from . import cfbypass
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("research_assistant.fetch.browser")
|
|
35
|
+
|
|
36
|
+
# 孤儿判定:profile/锁 超过该秒数且属主进程已死 → 视为孤儿
|
|
37
|
+
ORPHAN_AGE_SECONDS = 600
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# channel 可执行探测
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _channel_executable(channel: str) -> str | None:
|
|
46
|
+
"""返回本地浏览器可执行路径(用于探测),找不到返回 None。"""
|
|
47
|
+
if sys.platform.startswith("win"):
|
|
48
|
+
candidates = {
|
|
49
|
+
"msedge": [
|
|
50
|
+
os.path.join(os.environ.get("PROGRAMFILES(X86)", ""), "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
51
|
+
os.path.join(os.environ.get("PROGRAMFILES", ""), "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
52
|
+
],
|
|
53
|
+
"chrome": [
|
|
54
|
+
os.path.join(os.environ.get("PROGRAMFILES", ""), "Google", "Chrome", "Application", "chrome.exe"),
|
|
55
|
+
os.path.join(os.environ.get("PROGRAMFILES(X86)", ""), "Google", "Chrome", "Application", "chrome.exe"),
|
|
56
|
+
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Google", "Chrome", "Application", "chrome.exe"),
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
elif sys.platform == "darwin":
|
|
60
|
+
candidates = {
|
|
61
|
+
"msedge": ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
|
|
62
|
+
"chrome": ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
|
|
63
|
+
}
|
|
64
|
+
else:
|
|
65
|
+
candidates = {
|
|
66
|
+
"msedge": ["/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable"],
|
|
67
|
+
"chrome": ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium"],
|
|
68
|
+
}
|
|
69
|
+
for path in candidates.get(channel, []):
|
|
70
|
+
if path and os.path.exists(path):
|
|
71
|
+
return path
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def browser_probe(channel: str) -> str:
|
|
76
|
+
exe = _channel_executable(channel)
|
|
77
|
+
if exe:
|
|
78
|
+
return f"channel={channel} 可用"
|
|
79
|
+
return f"channel={channel} 未找到本地浏览器可执行(fetch 回退将不可用)"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# per-call profile + 原子 id(ADR-0005)
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _new_profile_dir() -> Path:
|
|
88
|
+
"""生成一个全新的 per-call profile 目录(原子 id,防并发碰撞)。"""
|
|
89
|
+
base = config_mod.profiles_dir()
|
|
90
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
for _ in range(50):
|
|
92
|
+
pid = os.getpid()
|
|
93
|
+
ident = f"{int(time.time())}-{pid}-{uuid.uuid4().hex[:6]}"
|
|
94
|
+
d = base / f"fetch-{ident}"
|
|
95
|
+
# 原子创建:不存在才建(os.makedirs exist_ok=False + 捕获)
|
|
96
|
+
try:
|
|
97
|
+
d.mkdir(parents=True, exist_ok=False)
|
|
98
|
+
except FileExistsError:
|
|
99
|
+
continue
|
|
100
|
+
# 写属主标记
|
|
101
|
+
(d / ".ra-meta").write_text(
|
|
102
|
+
f"pid={pid}\nstarted_at={int(time.time())}\n", encoding="utf-8"
|
|
103
|
+
)
|
|
104
|
+
return d
|
|
105
|
+
raise ResearchAssistantError("无法分配独立 profile 目录(重试耗尽)")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_meta(d: Path) -> tuple[int, int] | None:
|
|
109
|
+
meta = d / ".ra-meta"
|
|
110
|
+
if not meta.exists():
|
|
111
|
+
return None
|
|
112
|
+
pid, started = 0, 0
|
|
113
|
+
for line in meta.read_text(encoding="utf-8").splitlines():
|
|
114
|
+
if line.startswith("pid="):
|
|
115
|
+
pid = int(line.split("=", 1)[1].strip() or "0")
|
|
116
|
+
elif line.startswith("started_at="):
|
|
117
|
+
started = int(line.split("=", 1)[1].strip() or "0")
|
|
118
|
+
return pid, started
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _pid_alive(pid: int) -> bool:
|
|
122
|
+
if pid <= 0:
|
|
123
|
+
return False
|
|
124
|
+
try:
|
|
125
|
+
if sys.platform.startswith("win"):
|
|
126
|
+
import ctypes
|
|
127
|
+
|
|
128
|
+
kernel32 = ctypes.windll.kernel32
|
|
129
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
130
|
+
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
131
|
+
if not handle:
|
|
132
|
+
return False
|
|
133
|
+
kernel32.CloseHandle(handle)
|
|
134
|
+
return True
|
|
135
|
+
os.kill(pid, 0)
|
|
136
|
+
return True
|
|
137
|
+
except (OSError, ProcessLookupError, PermissionError):
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _kill_pid(pid: int) -> None:
|
|
142
|
+
try:
|
|
143
|
+
if sys.platform.startswith("win"):
|
|
144
|
+
os.system(f"taskkill /PID {pid} /T /F >nul 2>&1")
|
|
145
|
+
else:
|
|
146
|
+
try:
|
|
147
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
148
|
+
except Exception:
|
|
149
|
+
os.kill(pid, signal.SIGTERM)
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cleanup_orphans() -> int:
|
|
155
|
+
"""扫描 profile 目录,清理孤儿(属主进程已死 且 超过 ORPHAN_AGE_SECONDS)。
|
|
156
|
+
|
|
157
|
+
搬 cdt 三件套之"启动前孤儿清理"——崩溃后下次 fetch 自愈。返回清理数量。
|
|
158
|
+
"""
|
|
159
|
+
base = config_mod.profiles_dir()
|
|
160
|
+
if not base.exists():
|
|
161
|
+
return 0
|
|
162
|
+
now = int(time.time())
|
|
163
|
+
reaped = 0
|
|
164
|
+
for d in base.iterdir():
|
|
165
|
+
if not d.is_dir() or not d.name.startswith("fetch-"):
|
|
166
|
+
continue
|
|
167
|
+
meta = _parse_meta(d)
|
|
168
|
+
if meta is None:
|
|
169
|
+
# 无标记且足够旧 → 清掉
|
|
170
|
+
try:
|
|
171
|
+
age = now - int(d.stat().st_mtime)
|
|
172
|
+
except OSError:
|
|
173
|
+
continue
|
|
174
|
+
if age > ORPHAN_AGE_SECONDS:
|
|
175
|
+
_safe_rmtree(d)
|
|
176
|
+
reaped += 1
|
|
177
|
+
continue
|
|
178
|
+
pid, started = meta
|
|
179
|
+
# 属主进程还活着 → 不动(正在用的)
|
|
180
|
+
if _pid_alive(pid):
|
|
181
|
+
continue
|
|
182
|
+
# 属主已死:若已超期则清理(刚崩的给一点宽限,避免误伤正在退出的)
|
|
183
|
+
if (now - started) > ORPHAN_AGE_SECONDS:
|
|
184
|
+
_kill_pid(pid)
|
|
185
|
+
_safe_rmtree(d)
|
|
186
|
+
reaped += 1
|
|
187
|
+
return reaped
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _safe_rmtree(d: Path) -> None:
|
|
191
|
+
try:
|
|
192
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# 浏览器进程数限流(跨 CLI,config.browser.max_browser_instances)
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _browser_locks_dir() -> Path:
|
|
203
|
+
"""browser 进程锁目录(config_dir/.browser-locks/)。每个活跃 browser 一个 lock-*/ 标记。"""
|
|
204
|
+
d = config_mod.config_dir() / ".browser-locks"
|
|
205
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
return d
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cleanup_browser_locks() -> int:
|
|
210
|
+
"""清 .browser-locks/ 下死 PID 锁(崩溃自愈,复用 _pid_alive/_kill_pid)。返回清理数。"""
|
|
211
|
+
base = _browser_locks_dir()
|
|
212
|
+
now = int(time.time())
|
|
213
|
+
reaped = 0
|
|
214
|
+
for d in base.iterdir():
|
|
215
|
+
if not d.is_dir() or not d.name.startswith("lock-"):
|
|
216
|
+
continue
|
|
217
|
+
meta = _parse_meta(d)
|
|
218
|
+
if meta is None:
|
|
219
|
+
continue
|
|
220
|
+
pid, started = meta
|
|
221
|
+
if _pid_alive(pid):
|
|
222
|
+
continue
|
|
223
|
+
if (now - started) > ORPHAN_AGE_SECONDS:
|
|
224
|
+
_kill_pid(pid)
|
|
225
|
+
_safe_rmtree(d)
|
|
226
|
+
reaped += 1
|
|
227
|
+
return reaped
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _acquire_browser_slot(config: Config) -> Path:
|
|
231
|
+
"""获取一个 browser 进程槽(跨 CLI 限流)。返回锁标记目录。
|
|
232
|
+
|
|
233
|
+
先 cleanup_browser_locks 清死锁,再数活 PID 锁;>= max_browser_instances 抛 ConfigError。
|
|
234
|
+
"""
|
|
235
|
+
cleanup_browser_locks()
|
|
236
|
+
base = _browser_locks_dir()
|
|
237
|
+
alive = 0
|
|
238
|
+
for d in base.iterdir():
|
|
239
|
+
if not d.is_dir() or not d.name.startswith("lock-"):
|
|
240
|
+
continue
|
|
241
|
+
meta = _parse_meta(d)
|
|
242
|
+
if meta is None:
|
|
243
|
+
continue
|
|
244
|
+
if _pid_alive(meta[0]):
|
|
245
|
+
alive += 1
|
|
246
|
+
max_inst = config.browser.max_browser_instances
|
|
247
|
+
if alive >= max_inst:
|
|
248
|
+
raise ConfigError(
|
|
249
|
+
f"浏览器进程数已达上限 {max_inst}(有并发 fetch/search 在跑)",
|
|
250
|
+
details={"hint": "调高 config [browser] max_browser_instances,或等现有任务结束"},
|
|
251
|
+
)
|
|
252
|
+
pid = os.getpid()
|
|
253
|
+
for _ in range(50):
|
|
254
|
+
ident = f"{int(time.time())}-{pid}-{uuid.uuid4().hex[:6]}"
|
|
255
|
+
d = base / f"lock-{ident}"
|
|
256
|
+
try:
|
|
257
|
+
d.mkdir(parents=True, exist_ok=False)
|
|
258
|
+
except FileExistsError:
|
|
259
|
+
continue
|
|
260
|
+
(d / ".ra-meta").write_text(
|
|
261
|
+
f"pid={pid}\nstarted_at={int(time.time())}\n", encoding="utf-8"
|
|
262
|
+
)
|
|
263
|
+
return d
|
|
264
|
+
raise ResearchAssistantError("无法分配 browser 锁目录(重试耗尽)")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _release_browser_slot(lock_dir: Path | None) -> None:
|
|
268
|
+
if lock_dir is not None:
|
|
269
|
+
_safe_rmtree(lock_dir)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# 登录 cookie / DrissionPage 选项 / 防弹窗
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def _get_login_cookies(config: Config) -> list[dict[str, Any]]:
|
|
278
|
+
if config.browser.extension_status != "installed":
|
|
279
|
+
return []
|
|
280
|
+
try:
|
|
281
|
+
from ..loginstate import get_cookies, to_playwright_cookies
|
|
282
|
+
|
|
283
|
+
raw = await get_cookies(config)
|
|
284
|
+
return to_playwright_cookies(raw)
|
|
285
|
+
except Exception:
|
|
286
|
+
# 登录态不可用不阻塞 fetch(按公开页处理)
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _build_dp_options(config: Config, profile_dir: Path, *, headless: bool = False) -> Any:
|
|
291
|
+
"""构造 DrissionPage ChromiumOptions:用户 Edge + 隔离 profile + 反检测/防弹窗 args。
|
|
292
|
+
|
|
293
|
+
headless=False(默认,fetch/CF 路径):CF 识别 headless,必须 headed。
|
|
294
|
+
headless=True(search_engine 用):结果页无 CF 挑战,headless 更快且不打扰用户。
|
|
295
|
+
DrissionPage 直接 CDP 驱动用户 Edge,不经自动化框架运行时,无 playwright 注入痕迹。
|
|
296
|
+
防弹窗照 cdt 配方(launch args + 后续 _write_suppress_prefs 的 Preferences 双保险)。
|
|
297
|
+
"""
|
|
298
|
+
from DrissionPage import ChromiumOptions
|
|
299
|
+
|
|
300
|
+
exe = _channel_executable(config.browser.channel)
|
|
301
|
+
co = ChromiumOptions()
|
|
302
|
+
co.set_browser_path(exe)
|
|
303
|
+
co.set_user_data_path(str(profile_dir))
|
|
304
|
+
co.headless(headless)
|
|
305
|
+
co.auto_port(True)
|
|
306
|
+
w, h = cfbypass.REAL_VIEWPORT["width"], cfbypass.REAL_VIEWPORT["height"]
|
|
307
|
+
co.set_argument(f"--window-size={w},{h}")
|
|
308
|
+
# 翻译弹窗根治:DrissionPage 默认带 --disable-features=PrivacySandboxSettings4,与 Translate
|
|
309
|
+
# 冲突(Chrome 只认最后一个 --disable-features),先移除默认再设合并值,一次杀翻译+隐私沙盒+Edge 欢迎页。
|
|
310
|
+
co.remove_argument("--disable-features=PrivacySandboxSettings4")
|
|
311
|
+
co.set_argument("--disable-features=Translate,TranslateUI,PrivacySandboxSettings4,msEdgeWelcomeFLX")
|
|
312
|
+
co.set_argument("--disable-blink-features=AutomationControlled") # 反 webdriver 指纹
|
|
313
|
+
co.set_argument("--disable-extensions") # 禁扩展,消除 Edge 扩展安装/引导页
|
|
314
|
+
co.set_argument("--no-experiments")
|
|
315
|
+
co.set_argument("--disable-sync") # 禁同步引擎,配合 Preferences 抑制同步弹窗
|
|
316
|
+
proxy_url = resolve_proxy(config.proxy.url)
|
|
317
|
+
if proxy_url:
|
|
318
|
+
co.set_argument(f"--proxy-server={proxy_url}")
|
|
319
|
+
return co
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _inject_cookies_dp(page: Any, cookies: list[dict[str, Any]]) -> None:
|
|
323
|
+
"""用 CDP Network.setCookie 注入 cookie。playwright cookie 字段名与 CDP setCookie 参数
|
|
324
|
+
一致,直接透传(绕过 DrissionPage set.cookies 的格式差异)。失败不阻塞 CF 解题。"""
|
|
325
|
+
if not cookies:
|
|
326
|
+
return
|
|
327
|
+
ok = 0
|
|
328
|
+
for c in cookies:
|
|
329
|
+
try:
|
|
330
|
+
params: dict[str, Any] = {
|
|
331
|
+
"name": c["name"],
|
|
332
|
+
"value": c["value"],
|
|
333
|
+
"domain": c["domain"],
|
|
334
|
+
"path": c.get("path") or "/",
|
|
335
|
+
}
|
|
336
|
+
if c.get("secure"):
|
|
337
|
+
params["secure"] = True
|
|
338
|
+
if c.get("httpOnly"):
|
|
339
|
+
params["httpOnly"] = True
|
|
340
|
+
if c.get("expires", -1) >= 0:
|
|
341
|
+
params["expires"] = c["expires"]
|
|
342
|
+
ss = (c.get("sameSite") or "").lower()
|
|
343
|
+
if ss in ("strict", "lax", "none"):
|
|
344
|
+
params["sameSite"] = ss.capitalize()
|
|
345
|
+
page.run_cdp("Network.setCookie", **params)
|
|
346
|
+
ok += 1
|
|
347
|
+
except Exception:
|
|
348
|
+
pass
|
|
349
|
+
logger.info("DrissionPage 注入 cookie %d/%d", ok, len(cookies))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _write_suppress_prefs(user_data_dir: Path) -> None:
|
|
353
|
+
"""写隔离 profile 的 Default\\Preferences,抑制翻译/下载框/权限/同步弹窗。
|
|
354
|
+
|
|
355
|
+
两层防弹窗之一(另一层是 launch args 的 --disable-features=Translate,...)。
|
|
356
|
+
- translate.enabled=false + intl.accept_languages=en-US,en:Edge 只在页面语言 != 用户语言
|
|
357
|
+
时弹翻译框,CF/nopecha 挑战页都是英文,把接受语言设成 en 后不再提示(最可靠的翻译弹窗根治)。
|
|
358
|
+
- signin.allowed_on_next_launch=false + sync.first_setup_complete=true:抑制 Edge 新 profile
|
|
359
|
+
从 Windows 系统账户读 MSA 自动登录 + 同步确认弹窗(CF 路径不能加 --enable-automation 暴露
|
|
360
|
+
webdriver,故走 Preferences)。
|
|
361
|
+
读-合并-无 BOM 写回,避免破坏 profile 既有的其他偏好。
|
|
362
|
+
"""
|
|
363
|
+
import json
|
|
364
|
+
|
|
365
|
+
prefs_file = user_data_dir / "Default" / "Preferences"
|
|
366
|
+
prefs_file.parent.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
prefs: dict[str, Any] = {}
|
|
368
|
+
if prefs_file.exists():
|
|
369
|
+
try:
|
|
370
|
+
loaded = json.loads(prefs_file.read_text(encoding="utf-8") or "{}")
|
|
371
|
+
if isinstance(loaded, dict):
|
|
372
|
+
prefs = loaded
|
|
373
|
+
except Exception:
|
|
374
|
+
prefs = {}
|
|
375
|
+
prefs.setdefault("translate", {})["enabled"] = False
|
|
376
|
+
prefs.setdefault("intl", {})["accept_languages"] = "en-US,en"
|
|
377
|
+
prefs.setdefault("download", {})["prompt_for_download"] = False
|
|
378
|
+
prefs.setdefault("profile", {}).setdefault("default_content_setting_values", {}).update({
|
|
379
|
+
"notifications": 2, "geolocation": 2, "media_stream_camera": 2, "media_stream_mic": 2,
|
|
380
|
+
})
|
|
381
|
+
prefs.setdefault("signin", {})["allowed_on_next_launch"] = False
|
|
382
|
+
prefs.setdefault("sync", {}).update({
|
|
383
|
+
"requested": False, "first_setup_complete": True,
|
|
384
|
+
"has_setup_completed": True, "keep_everything_synced": False,
|
|
385
|
+
})
|
|
386
|
+
prefs.setdefault("browser", {}).update({"has_seen_welcome_page": True, "check_default_browser": False})
|
|
387
|
+
prefs.setdefault("profile", {})["exit_type"] = "Normal"
|
|
388
|
+
try:
|
|
389
|
+
prefs_file.write_text(json.dumps(prefs), encoding="utf-8")
|
|
390
|
+
logger.info("已写 profile Preferences 抑制弹窗: %s", prefs_file)
|
|
391
|
+
except Exception as e:
|
|
392
|
+
logger.warning("写 Preferences 失败(仅靠 --disable-features 兜底): %s", e)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _hide_window(page: Any) -> None:
|
|
396
|
+
"""隐藏 CF 兜底浏览器窗口(Win32 ShowWindow SW_HIDE)。
|
|
397
|
+
|
|
398
|
+
窗口仍在(真 headed 渲染,CF 检测不到 headless),但不占视野——区别于 headless(headless 实测
|
|
399
|
+
即便覆盖 UA+screen 仍被 CF 深层指纹识别)。仅 Windows + pywin32 生效;其他平台或缺依赖时
|
|
400
|
+
静默跳过(窗口可见,不影响功能)。
|
|
401
|
+
"""
|
|
402
|
+
try:
|
|
403
|
+
from DrissionPage._functions.tools import show_or_hide_browser
|
|
404
|
+
|
|
405
|
+
show_or_hide_browser(page, hide=True)
|
|
406
|
+
logger.info("CF 绕过浏览器窗口已隐藏")
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.debug("隐藏窗口跳过(%s)", e)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _close_browser(page: Any, browser_pid: int) -> None:
|
|
412
|
+
"""关 DrissionPage 浏览器并确保进程退出(CDT 风格:显式杀进程防残留)。
|
|
413
|
+
|
|
414
|
+
page.quit()(force=True 默认,杀 browser 进程)+ 兜底按 pid 杀进程树——防 quit 不彻底时
|
|
415
|
+
Edge 进程残留致窗口不关 + profile 文件锁不释放(Windows 下 rmtree 会因锁失败,profile 堆积)。
|
|
416
|
+
page 为 None(ChromiumPage 构造失败)时跳过 quit;对已退出的 pid 杀无害。
|
|
417
|
+
"""
|
|
418
|
+
if page is not None:
|
|
419
|
+
try:
|
|
420
|
+
page.quit()
|
|
421
|
+
except Exception as e:
|
|
422
|
+
logger.debug("page.quit() 异常: %s", e)
|
|
423
|
+
if browser_pid:
|
|
424
|
+
_kill_pid(browser_pid)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# 网络静默等待(替代 html 长度轮询)
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _wait_network_idle(
|
|
433
|
+
tab: Any,
|
|
434
|
+
total_timeout: float = 10.0,
|
|
435
|
+
idle_window: float = 2.0,
|
|
436
|
+
poll_interval: float = 0.3,
|
|
437
|
+
) -> None:
|
|
438
|
+
"""等网络稳定,不只看归零——有些站一进去就有计数器/定时 polling,进行中永不归零。
|
|
439
|
+
|
|
440
|
+
判稳定(任一即返):
|
|
441
|
+
- idle 静默:idle_window(2s)内无新请求发起 → 加载完成(无 polling 或 polling 间隔 >2s)。
|
|
442
|
+
- 稳态 polling:最近 4 个请求间隔稳定(均值 0.3-3s 且各 ±40%)→ 定时 polling,加载完成。
|
|
443
|
+
total_timeout 兜底(超时接受当前)。listen 监听请求发起事件(requestWillBeSent → DataPacket)。
|
|
444
|
+
"""
|
|
445
|
+
try:
|
|
446
|
+
tab.listen.start() # 监听所有 URL
|
|
447
|
+
except Exception as e:
|
|
448
|
+
logger.debug("listen.start 失败,跳过网络等待: %s", e)
|
|
449
|
+
return
|
|
450
|
+
deadline = time.monotonic() + total_timeout
|
|
451
|
+
last_req_time = time.monotonic() # idle 基准:循环开始(全静默也能判 idle,不必等到首个请求)
|
|
452
|
+
saw_first = False
|
|
453
|
+
intervals: list[float] = []
|
|
454
|
+
while time.monotonic() < deadline:
|
|
455
|
+
try:
|
|
456
|
+
pkt = tab.listen.wait(count=1, timeout=poll_interval, fit_count=True, raise_err=False)
|
|
457
|
+
except Exception:
|
|
458
|
+
pkt = None
|
|
459
|
+
now = time.monotonic()
|
|
460
|
+
if pkt: # poll_interval 内有新请求发起
|
|
461
|
+
if saw_first: # 首请求只记时间不记间隔(间隔从第 2 个请求起算)
|
|
462
|
+
intervals.append(now - last_req_time)
|
|
463
|
+
# 稳态 polling 检测:最近 4 间隔稳定
|
|
464
|
+
if len(intervals) >= 4:
|
|
465
|
+
recent = intervals[-4:]
|
|
466
|
+
avg = sum(recent) / 4
|
|
467
|
+
if 0.3 <= avg <= 3.0 and all(abs(x - avg) <= avg * 0.4 for x in recent):
|
|
468
|
+
logger.debug("网络稳态 polling(间隔均值 %.2fs),加载完成", avg)
|
|
469
|
+
return
|
|
470
|
+
else:
|
|
471
|
+
saw_first = True
|
|
472
|
+
last_req_time = now
|
|
473
|
+
else: # poll_interval 无新请求
|
|
474
|
+
if (now - last_req_time) >= idle_window:
|
|
475
|
+
logger.debug("网络 idle %.1fs 无新请求,加载完成", idle_window)
|
|
476
|
+
return
|
|
477
|
+
logger.debug("网络等待 total_timeout %.0fs 到,接受当前", total_timeout)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ---------------------------------------------------------------------------
|
|
481
|
+
# 抓取主流程:单 tab → 单 url → 多 url(1 browser 多 tab)
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _fetch_one_tab(tab: Any, url: str) -> str | None:
|
|
486
|
+
"""单 tab 抓取:导航 → CF solve → 网络静默 → html2md。不建 browser/profile(由调用方建)。
|
|
487
|
+
|
|
488
|
+
tab 是 ChromiumPage/ChromiumTab/MixTab(同套 API,cfbypass.solve 兼容)。
|
|
489
|
+
失败(CF 未过/内容过短)抛异常,由调用方捕获记 None。
|
|
490
|
+
"""
|
|
491
|
+
logger.info("GET %s", url)
|
|
492
|
+
tab.get(url)
|
|
493
|
+
time.sleep(2.5) # 等 CF widget 渲染
|
|
494
|
+
passed = cfbypass.solve(tab)
|
|
495
|
+
logger.info("cfbypass.solve 返回: %s(url=%s)", passed, url)
|
|
496
|
+
if not passed:
|
|
497
|
+
raise AntibotError(f"CF 挑战未通过(指纹/IP 被拒): {url}")
|
|
498
|
+
_wait_network_idle(tab)
|
|
499
|
+
html = tab.html or ""
|
|
500
|
+
if len(html) < 200:
|
|
501
|
+
raise AntibotError(f"CF 绕过后内容仍过短: {url}")
|
|
502
|
+
return html_to_md(html, url)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _fetch_single_sync(config: Config, url: str, cookies: list[dict[str, Any]]) -> str | None:
|
|
506
|
+
"""单 url 同步:建 1 browser + tab0,hide + 注 cookie,_fetch_one_tab(tab0),关 browser。"""
|
|
507
|
+
from DrissionPage import ChromiumPage
|
|
508
|
+
|
|
509
|
+
profile_dir = _new_profile_dir()
|
|
510
|
+
lock_dir = _acquire_browser_slot(config)
|
|
511
|
+
page = None
|
|
512
|
+
browser_pid = 0
|
|
513
|
+
try:
|
|
514
|
+
_write_suppress_prefs(profile_dir)
|
|
515
|
+
page = ChromiumPage(_build_dp_options(config, profile_dir))
|
|
516
|
+
browser_pid = getattr(getattr(page, "browser", None), "process_id", 0) or 0
|
|
517
|
+
_hide_window(page)
|
|
518
|
+
_inject_cookies_dp(page, cookies)
|
|
519
|
+
return _fetch_one_tab(page, url) # page(ChromiumPage)即 tab0,可直接喂 _fetch_one_tab
|
|
520
|
+
finally:
|
|
521
|
+
_close_browser(page, browser_pid)
|
|
522
|
+
_safe_rmtree(profile_dir)
|
|
523
|
+
_release_browser_slot(lock_dir)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
async def _fetch_with_stealth(
|
|
527
|
+
config: Config, url: str, cookies: list[dict[str, Any]]
|
|
528
|
+
) -> str | None:
|
|
529
|
+
"""单 url 薄包装(建 browser+tab0 调 _fetch_one_tab)。供 test_cf_live 与单 url 用。"""
|
|
530
|
+
if _channel_executable(config.browser.channel) is None:
|
|
531
|
+
raise ResearchAssistantError(
|
|
532
|
+
f"未找到本地浏览器 ({config.browser.channel}),浏览器抓取不可用"
|
|
533
|
+
)
|
|
534
|
+
return await asyncio.to_thread(_fetch_single_sync, config, url, cookies)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _fetch_all_tabs_sync(
|
|
538
|
+
config: Config, urls: list[str], cookies: list[dict[str, Any]], concurrency: int
|
|
539
|
+
) -> dict[str, str | None]:
|
|
540
|
+
"""多 url 1 浏览器多 tab:建 browser + tab0,hide + 注 cookie(共享存储),主线程 new_tab 建 N tab,
|
|
541
|
+
ThreadPoolExecutor 并发各 tab _fetch_one_tab。失败 url 记 None,不抛。
|
|
542
|
+
|
|
543
|
+
线程安全:new_tab(browser 级)只主线程调;各 tab 的 get/solve/listen/html/close(tab 级独立 WS)
|
|
544
|
+
在工作线程,单 tab 内顺序。
|
|
545
|
+
"""
|
|
546
|
+
from DrissionPage import ChromiumPage
|
|
547
|
+
|
|
548
|
+
profile_dir = _new_profile_dir()
|
|
549
|
+
lock_dir = _acquire_browser_slot(config)
|
|
550
|
+
page = None
|
|
551
|
+
browser_pid = 0
|
|
552
|
+
try:
|
|
553
|
+
_write_suppress_prefs(profile_dir)
|
|
554
|
+
page = ChromiumPage(_build_dp_options(config, profile_dir))
|
|
555
|
+
browser_pid = getattr(getattr(page, "browser", None), "process_id", 0) or 0
|
|
556
|
+
_hide_window(page)
|
|
557
|
+
_inject_cookies_dp(page, cookies) # 注一次,所有 tab 共享
|
|
558
|
+
|
|
559
|
+
# 主线程:预建 tab。tab0(page) 给第 1 个 url;其余 new_tab(background) 空 tab。
|
|
560
|
+
tabs: list[Any] = []
|
|
561
|
+
for i in range(len(urls)):
|
|
562
|
+
if i == 0:
|
|
563
|
+
tabs.append(page)
|
|
564
|
+
else:
|
|
565
|
+
tabs.append(page.new_tab(background=True))
|
|
566
|
+
|
|
567
|
+
results: dict[str, str | None] = {}
|
|
568
|
+
|
|
569
|
+
def work(idx_url: tuple[int, str]) -> tuple[str, str | None]:
|
|
570
|
+
i, url = idx_url
|
|
571
|
+
tab = tabs[i]
|
|
572
|
+
try:
|
|
573
|
+
md = _fetch_one_tab(tab, url)
|
|
574
|
+
return url, md
|
|
575
|
+
except Exception as e:
|
|
576
|
+
logger.warning("tab 抓取失败 %s: %s", url, e)
|
|
577
|
+
return url, None
|
|
578
|
+
finally:
|
|
579
|
+
if tab is not page: # 新 tab 关闭;tab0 留给 _close_browser(page)
|
|
580
|
+
try:
|
|
581
|
+
tab.close()
|
|
582
|
+
except Exception:
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
with ThreadPoolExecutor(max_workers=max(1, concurrency)) as ex:
|
|
586
|
+
for url, md in ex.map(work, enumerate(urls)):
|
|
587
|
+
results[url] = md
|
|
588
|
+
return results
|
|
589
|
+
finally:
|
|
590
|
+
_close_browser(page, browser_pid)
|
|
591
|
+
_safe_rmtree(profile_dir)
|
|
592
|
+
_release_browser_slot(lock_dir)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
async def fetch_with_browser(
|
|
596
|
+
config: Config,
|
|
597
|
+
urls: list[str],
|
|
598
|
+
*,
|
|
599
|
+
login: bool = True,
|
|
600
|
+
concurrency: int = 4,
|
|
601
|
+
) -> dict[str, str | None]:
|
|
602
|
+
"""批量用 headed 浏览器抓取(1 browser 多 tab,fetch 浏览器层与 browser fetch 共用)。
|
|
603
|
+
|
|
604
|
+
返回 {url: markdown_or_None}。失败(CF 未过/无浏览器/内容过短/进程数达上限)记 None 或抛
|
|
605
|
+
ConfigError(进程上限)。多 url 并发(1 browser + N tab,ThreadPoolExecutor 限并发数)。
|
|
606
|
+
"""
|
|
607
|
+
cleanup_orphans()
|
|
608
|
+
|
|
609
|
+
cookies: list[dict[str, Any]] = []
|
|
610
|
+
if login:
|
|
611
|
+
cookies = await _get_login_cookies(config)
|
|
612
|
+
|
|
613
|
+
return await asyncio.to_thread(_fetch_all_tabs_sync, config, urls, cookies, concurrency)
|