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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README-ZH.md +115 -0
  3. package/README.md +124 -0
  4. package/npm/bin/research-assistant.js +70 -0
  5. package/npm/scripts/postinstall.js +112 -0
  6. package/package.json +38 -0
  7. package/pyproject.toml +84 -0
  8. package/src/research_assistant/__init__.py +7 -0
  9. package/src/research_assistant/__main__.py +6 -0
  10. package/src/research_assistant/assets/researcher_persona.md +94 -0
  11. package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
  12. package/src/research_assistant/cli.py +155 -0
  13. package/src/research_assistant/commands/__init__.py +7 -0
  14. package/src/research_assistant/commands/ask.py +54 -0
  15. package/src/research_assistant/commands/doctor.py +201 -0
  16. package/src/research_assistant/commands/fetch.py +99 -0
  17. package/src/research_assistant/commands/locate.py +71 -0
  18. package/src/research_assistant/commands/search.py +161 -0
  19. package/src/research_assistant/commands/setup.py +249 -0
  20. package/src/research_assistant/commands/skills.py +60 -0
  21. package/src/research_assistant/config.py +258 -0
  22. package/src/research_assistant/errors.py +106 -0
  23. package/src/research_assistant/fetch/__init__.py +20 -0
  24. package/src/research_assistant/fetch/browser.py +613 -0
  25. package/src/research_assistant/fetch/cfbypass.py +318 -0
  26. package/src/research_assistant/fetch/html2md.py +44 -0
  27. package/src/research_assistant/fetch/normal.py +69 -0
  28. package/src/research_assistant/fetch/search_engine.py +324 -0
  29. package/src/research_assistant/fetch/snapshot.py +50 -0
  30. package/src/research_assistant/http.py +118 -0
  31. package/src/research_assistant/installer.py +270 -0
  32. package/src/research_assistant/locate/__init__.py +5 -0
  33. package/src/research_assistant/locate/engine.py +289 -0
  34. package/src/research_assistant/loginstate/__init__.py +10 -0
  35. package/src/research_assistant/loginstate/daemon.py +145 -0
  36. package/src/research_assistant/loginstate/daemon_cli.py +21 -0
  37. package/src/research_assistant/loginstate/daemonctl.py +116 -0
  38. package/src/research_assistant/loginstate/extension/background.js +167 -0
  39. package/src/research_assistant/loginstate/extension/manifest.json +15 -0
  40. package/src/research_assistant/loginstate/extension/popup.html +31 -0
  41. package/src/research_assistant/loginstate/extension/popup.js +33 -0
  42. package/src/research_assistant/output.py +140 -0
  43. package/src/research_assistant/providers/__init__.py +10 -0
  44. package/src/research_assistant/providers/base.py +93 -0
  45. package/src/research_assistant/providers/browser.py +91 -0
  46. package/src/research_assistant/providers/context7.py +171 -0
  47. package/src/research_assistant/providers/exa.py +353 -0
  48. package/src/research_assistant/providers/firecrawl.py +644 -0
  49. package/src/research_assistant/providers/openai_compat.py +293 -0
  50. package/src/research_assistant/providers/registry.py +62 -0
  51. package/src/research_assistant/providers/tavily.py +346 -0
  52. package/src/research_assistant/proxy.py +58 -0
  53. 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)