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,318 @@
|
|
|
1
|
+
"""Cloudflare 绕过(DrissionPage + 默认 Edge/Chromium)。
|
|
2
|
+
|
|
3
|
+
不换浏览器内核(仍在用户的 Edge/Chromium 内,cookie 不跨内核)。配方:
|
|
4
|
+
1. DrissionPage 直接 CDP 驱动用户 Edge,无 playwright 注入痕迹(不经自动化框架运行时)。
|
|
5
|
+
2. headed 启动(headless 会被 CF 识别)。
|
|
6
|
+
3. DOM.getDocument(depth=-1, pierce=True) 穿透 closed shadow root + 嵌套 iframe,
|
|
7
|
+
定位 CF Turnstile checkbox 的 backendNodeId。DrissionPage 的 ele() 底层用
|
|
8
|
+
DOM.performSearch(includeUserAgentShadowDOM=True),只穿透 UA shadow DOM,看不到
|
|
9
|
+
author/closed shadow root 里的 checkbox(CF Turnstile 的 checkbox 正藏在这种 shadow
|
|
10
|
+
root 内);pierce=True 让 CDP 跨过 closed shadow root,是定位 checkbox 的关键。
|
|
11
|
+
4. camoufox 风格 Bezier 轨迹 humanize 点击(移植自 HumanizeMouseTrajectory 算法):
|
|
12
|
+
随机落点(rect 25%-75%)+ 随机停顿 + 按下→松开人类时长 + Bezier 鼠标移动轨迹。
|
|
13
|
+
5. 通过判定:标题脱离 "just a moment"/"请稍候"。cf_clearance cookie 单独出现不代表
|
|
14
|
+
通过——CF 可能在验证中发放它、随后又因识别到自动化点击而重新挑战,唯一可靠判据
|
|
15
|
+
是标题不再含挑战特征。
|
|
16
|
+
|
|
17
|
+
本模块操作 DrissionPage ChromiumPage(同步 API);browser.py 用 asyncio.to_thread 在
|
|
18
|
+
async 上下文中调用。作为 fetch 浏览器路径的最后兜底:headless 抓取遇 CF → 切 headed
|
|
19
|
+
DrissionPage + 本模块解题。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
import random
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from DrissionPage._elements.chromium_element import ChromiumElement
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("research_assistant.cfbypass")
|
|
33
|
+
|
|
34
|
+
# 窗口尺寸(launch 时 --window-size);humanize 起点随机范围据此限定
|
|
35
|
+
REAL_VIEWPORT = {"width": 1440, "height": 900}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# 挑战检测
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cf_title_active(title: str | None) -> bool:
|
|
44
|
+
"""标题是否含 CF 挑战特征。纯字符串判定,不依赖浏览器库——playwright 主路径与
|
|
45
|
+
DrissionPage 兜底路径共用此函数。"""
|
|
46
|
+
t = title or ""
|
|
47
|
+
return "just a moment" in t.lower() or "请稍候" in t
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_cf_challenge(page: Any) -> bool:
|
|
51
|
+
"""检测 DrissionPage 是否处于 CF 挑战页(5 秒盾 / Turnstile)。"""
|
|
52
|
+
try:
|
|
53
|
+
title = page.title or ""
|
|
54
|
+
except Exception:
|
|
55
|
+
title = ""
|
|
56
|
+
if cf_title_active(title):
|
|
57
|
+
return True
|
|
58
|
+
# cf-turnstile-response 输入框出现 = 页面嵌了 Turnstile widget(即便标题还没切到挑战态)
|
|
59
|
+
try:
|
|
60
|
+
if page.ele('css:input[name="cf-turnstile-response"]', timeout=1):
|
|
61
|
+
return True
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def detect_challenge_type(page: Any) -> str | None:
|
|
68
|
+
"""检测 CF challenge 类型(页面 HTML 里的 cType 字段)。
|
|
69
|
+
|
|
70
|
+
返回 "non-interactive" / "managed" / "interactive" / "embedded" / None。
|
|
71
|
+
non-interactive 是无感验证(后台 JS 自动跑),不需点击;其余需点 Turnstile。
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
content = page.html or ""
|
|
75
|
+
except Exception:
|
|
76
|
+
return None
|
|
77
|
+
for ctype in ("non-interactive", "managed", "interactive"):
|
|
78
|
+
if f"cType: '{ctype}'" in content:
|
|
79
|
+
return ctype
|
|
80
|
+
if "challenges.cloudflare.com/turnstile/v" in content:
|
|
81
|
+
return "embedded"
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# 定位 CF frame + 穿透 shadow root 找 checkbox
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _find_cf_frame(page: Any, timeout: float = 12.0) -> Any:
|
|
91
|
+
"""轮询直到 Cloudflare challenge frame 出现,超时返回 None。
|
|
92
|
+
|
|
93
|
+
DrissionPage get_frame 底层 DOM.performSearch 能定位跨域 iframe 元素本身;
|
|
94
|
+
但 frame 内的 checkbox 在 closed shadow root 里,要靠 find_turnstile_checkboxes 穿透。
|
|
95
|
+
"""
|
|
96
|
+
end = time.monotonic() + timeout
|
|
97
|
+
last_err: Exception | None = None
|
|
98
|
+
loc = "css:iframe[src*='challenges.cloudflare.com']"
|
|
99
|
+
while time.monotonic() < end:
|
|
100
|
+
try:
|
|
101
|
+
frame = page.get_frame(loc, timeout=2)
|
|
102
|
+
if frame:
|
|
103
|
+
logger.info("CF frame 找到: %s", (getattr(frame, "url", "") or "")[:100])
|
|
104
|
+
return frame
|
|
105
|
+
except Exception as e:
|
|
106
|
+
last_err = e
|
|
107
|
+
time.sleep(0.5)
|
|
108
|
+
logger.warning("CF frame %.0fs 内未出现%s", timeout, f"({last_err})" if last_err else "")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _attrs_dict(node: dict) -> dict:
|
|
113
|
+
a = node.get("attributes") or []
|
|
114
|
+
return {a[i]: a[i + 1] for i in range(0, len(a) - 1, 2)}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _walk_find_checkbox(node: dict, path: list[str], out: list[dict]) -> None:
|
|
118
|
+
"""递归遍历 pierce=True 的 DOM 树,找 input[type=checkbox] / [role=checkbox]。
|
|
119
|
+
下钻 children / shadowRoots / contentDocument(嵌套 iframe),pierce 已展平它们。"""
|
|
120
|
+
if not isinstance(node, dict):
|
|
121
|
+
return
|
|
122
|
+
ln = (node.get("localName") or "").lower()
|
|
123
|
+
ad = _attrs_dict(node)
|
|
124
|
+
is_cb = (ln == "input" and (ad.get("type") or "").lower() == "checkbox") or (
|
|
125
|
+
ad.get("role") or ""
|
|
126
|
+
).lower() == "checkbox"
|
|
127
|
+
if is_cb and "backendNodeId" in node:
|
|
128
|
+
out.append({"backendNodeId": node["backendNodeId"], "path": " > ".join(path) or "<root>"})
|
|
129
|
+
seg = f'{ln}#{ad["id"]}' if ad.get("id") else (ln or "node")
|
|
130
|
+
for c in node.get("children") or []:
|
|
131
|
+
_walk_find_checkbox(c, path + [seg], out)
|
|
132
|
+
for sr in node.get("shadowRoots") or []:
|
|
133
|
+
_walk_find_checkbox(sr, path + [f"#{sr.get('shadowRootType') or 'shadow'}"], out)
|
|
134
|
+
cd = node.get("contentDocument")
|
|
135
|
+
if cd:
|
|
136
|
+
_walk_find_checkbox(cd, path + ["iframe-doc"], out)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def find_turnstile_checkboxes(frame: Any) -> list[dict]:
|
|
140
|
+
"""用 DOM.getDocument(depth=-1, pierce=True) 拿穿透 closed shadow root + 嵌套 iframe
|
|
141
|
+
的完整树,递归找 checkbox。返回候选列表 [{backendNodeId, path}]。"""
|
|
142
|
+
try:
|
|
143
|
+
root = frame.run_cdp("DOM.getDocument", depth=-1, pierce=True)["root"]
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.warning("DOM.getDocument(pierce) 失败: %s", e)
|
|
146
|
+
return []
|
|
147
|
+
out: list[dict] = []
|
|
148
|
+
_walk_find_checkbox(root, [], out)
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# camoufox 风格 humanize 点击(移植自 HumanizeMouseTrajectory 算法)
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _bezier_point(points: list[tuple[float, float]], t: float) -> tuple[float, float]:
|
|
158
|
+
"""Bernstein 多项式求 Bezier 曲线上 t 处的点。"""
|
|
159
|
+
n = len(points) - 1
|
|
160
|
+
x = y = 0.0
|
|
161
|
+
for i, (px, py) in enumerate(points):
|
|
162
|
+
b = math.comb(n, i) * (t ** i) * ((1 - t) ** (n - i))
|
|
163
|
+
x += px * b
|
|
164
|
+
y += py * b
|
|
165
|
+
return x, y
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _humanize_path(start: tuple[float, float], end: tuple[float, float]) -> list[tuple[float, float]]:
|
|
169
|
+
"""生成人类化鼠标轨迹:Bezier(2 个随机内部 control knots,±80 边界) + 中间点正态抖动
|
|
170
|
+
(mean=1,std=1,freq=0.5) + easeOutQuad 缓动 tween(距离自适应点数,上限 150)。
|
|
171
|
+
每次调用轨迹形状都不同,避免恒定直线/恒速的机械化特征。"""
|
|
172
|
+
(fx, fy), (tx, ty) = start, end
|
|
173
|
+
left, right = min(fx, tx) - 80.0, max(fx, tx) + 80.0
|
|
174
|
+
down, up = min(fy, ty) - 80.0, max(fy, ty) + 80.0
|
|
175
|
+
knots = [(random.uniform(left, right), random.uniform(down, up)) for _ in range(2)]
|
|
176
|
+
control = [start] + knots + [end]
|
|
177
|
+
mid = int(max(abs(fx - tx), abs(fy - ty), 2))
|
|
178
|
+
curve = [_bezier_point(control, i / (mid - 1)) for i in range(mid)]
|
|
179
|
+
# 正态抖动中间点(模拟手部微抖动;首尾不动,保证起止精确)
|
|
180
|
+
distorted = [curve[0]]
|
|
181
|
+
for i in range(1, len(curve) - 1):
|
|
182
|
+
x, y = curve[i]
|
|
183
|
+
if random.random() < 0.5:
|
|
184
|
+
y += round(random.gauss(1.0, 1.0))
|
|
185
|
+
distorted.append((x, y))
|
|
186
|
+
distorted.append(curve[-1])
|
|
187
|
+
# tween:easeOutQuad(开始快、结尾慢,像人手停下)+ 距离自适应点数
|
|
188
|
+
total = sum(math.dist(distorted[i], distorted[i + 1]) for i in range(len(distorted) - 1))
|
|
189
|
+
target = min(150, max(2, int(total ** 0.25 * 20)))
|
|
190
|
+
n = len(distorted) - 1
|
|
191
|
+
out = []
|
|
192
|
+
for i in range(target):
|
|
193
|
+
t = i / (target - 1)
|
|
194
|
+
eased = -t * (t - 2) # easeOutQuad
|
|
195
|
+
out.append(distorted[min(n, int(eased * n))])
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _rand_sleep(lo: float, hi: float) -> None:
|
|
200
|
+
time.sleep(random.uniform(lo, hi))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _humanize_click(page: Any, frame_ele: Any, cb: ChromiumElement) -> None:
|
|
204
|
+
"""人类化点击 checkbox:Bezier 轨迹把鼠标移到目标附近(页坐标)+ 随机落点 +
|
|
205
|
+
按下→松开人类时长。全程在 PAGE target 上 dispatch Input(move 跨整个页面到 frame
|
|
206
|
+
位置,故不能用 frame target)。"""
|
|
207
|
+
(fx, fy), *_ = frame_ele.rect.viewport_corners # iframe 在页面视口左上角
|
|
208
|
+
(bx, by), *_ = cb.rect.viewport_corners # checkbox 在 frame 视口左上角
|
|
209
|
+
w, h = cb.rect.size
|
|
210
|
+
# 随机落点:rect 中部 25%-75% 区域,避免每次都点正中(机械化特征)
|
|
211
|
+
tx = fx + bx + random.uniform(0.25, 0.75) * w
|
|
212
|
+
ty = fy + by + random.uniform(0.30, 0.70) * h
|
|
213
|
+
# 起点:视口内随机位置(模拟鼠标当前已在页面上,需走轨迹过去)
|
|
214
|
+
sx = random.randint(120, max(121, REAL_VIEWPORT["width"] - 120))
|
|
215
|
+
sy = random.randint(120, max(121, REAL_VIEWPORT["height"] - 120))
|
|
216
|
+
logger.info("humanize: start=(%d,%d) target=(%.0f,%.0f) cb_size=(%.0f,%.0f)", sx, sy, tx, ty, w, h)
|
|
217
|
+
# Bezier 轨迹逐点 mouseMoved + 小随机间隔(模拟人手移动速度不均)
|
|
218
|
+
for mx, my in _humanize_path((sx, sy), (tx, ty)):
|
|
219
|
+
page.run_cdp("Input.dispatchMouseEvent", type="mouseMoved", x=round(mx, 1), y=round(my, 1))
|
|
220
|
+
time.sleep(random.uniform(0.002, 0.012))
|
|
221
|
+
# 到达后的人类停顿(看到目标→决定点击)
|
|
222
|
+
time.sleep(random.uniform(0.08, 0.25))
|
|
223
|
+
page.run_cdp("Input.dispatchMouseEvent", type="mousePressed", x=tx, y=ty, button="left", clickCount=1)
|
|
224
|
+
# 按下→松开的人类时长(对抗瞬时点击检测,真人按下会有几十~百毫秒停顿)
|
|
225
|
+
time.sleep(random.uniform(0.04, 0.12))
|
|
226
|
+
page.run_cdp("Input.dispatchMouseEvent", type="mouseReleased", x=tx, y=ty, button="left", clickCount=1)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# 解题主流程
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def try_click_turnstile(page: Any) -> bool:
|
|
235
|
+
"""定位 CF Turnstile checkbox 并 humanize 点击。主路径 page-level Bezier 轨迹点击,
|
|
236
|
+
回退 DrissionPage cb.click()(frame target 物理点击)。任一命中返回 True。"""
|
|
237
|
+
_rand_sleep(1.0, 2.5) # 人类"看到页面后思考"
|
|
238
|
+
frame = _find_cf_frame(page, timeout=12.0)
|
|
239
|
+
if not frame:
|
|
240
|
+
logger.warning("未找到 CF frame")
|
|
241
|
+
return False
|
|
242
|
+
cbs = find_turnstile_checkboxes(frame)
|
|
243
|
+
if not cbs:
|
|
244
|
+
logger.warning("CF frame 内未找到 checkbox(pierce 未命中)")
|
|
245
|
+
return False
|
|
246
|
+
logger.info("pierced 找到 %d 个 checkbox 候选", len(cbs))
|
|
247
|
+
cb = ChromiumElement(frame, backend_id=cbs[0]["backendNodeId"])
|
|
248
|
+
try:
|
|
249
|
+
logger.info("checkbox size=%s", cb.rect.size)
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
# 主路径:humanize Bezier 轨迹点击(PAGE target)
|
|
253
|
+
try:
|
|
254
|
+
_humanize_click(page, frame.frame_ele, cb)
|
|
255
|
+
logger.info("[humanize] 点击完成")
|
|
256
|
+
return True
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.warning("[humanize] 失败: %s,回退 cb.click()", e)
|
|
259
|
+
# 回退:DrissionPage Clicker.left(frame target 上 Input.dispatchMouseEvent)
|
|
260
|
+
try:
|
|
261
|
+
cb.click()
|
|
262
|
+
logger.info("[回退] cb.click() 完成")
|
|
263
|
+
return True
|
|
264
|
+
except Exception as e2:
|
|
265
|
+
logger.warning("[回退] cb.click() 也失败: %s", e2)
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _wait_challenge_cleared(page: Any, timeout: float = 12.0) -> bool:
|
|
270
|
+
"""点击后循环等标题脱离 CF 挑战态。cf_clearance cookie 单独出现不代表通过——CF 可能
|
|
271
|
+
在验证中发放它、随后又重新挑战。唯一可靠判据是标题不再含 "just a moment"/"请稍候"。"""
|
|
272
|
+
end = time.monotonic() + timeout
|
|
273
|
+
last_title: str | None = None
|
|
274
|
+
while time.monotonic() < end:
|
|
275
|
+
try:
|
|
276
|
+
title = page.title or ""
|
|
277
|
+
except Exception:
|
|
278
|
+
title = "(n/a)"
|
|
279
|
+
if not cf_title_active(title):
|
|
280
|
+
return True
|
|
281
|
+
if title != last_title:
|
|
282
|
+
logger.info("等待通过: title=%r", title)
|
|
283
|
+
last_title = title
|
|
284
|
+
time.sleep(0.5)
|
|
285
|
+
return not cf_title_active(getattr(page, "title", "") or "")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def solve(page: Any, max_attempts: int = 5) -> bool:
|
|
289
|
+
"""对 DrissionPage 跑 CF 解题。无挑战返回 True;点击后等验证完成;通过返回 True。
|
|
290
|
+
|
|
291
|
+
返回 False 表示未能通过(CF 可能因指纹/IP 仍拒绝,调用方据此报 AntibotError)。
|
|
292
|
+
"""
|
|
293
|
+
if not is_cf_challenge(page):
|
|
294
|
+
logger.info("无 CF 挑战,solve 直接返回 True")
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
ctype = detect_challenge_type(page)
|
|
298
|
+
logger.info("CF 挑战 type=%s,开始解题(max_attempts=%d)", ctype, max_attempts)
|
|
299
|
+
|
|
300
|
+
if ctype == "non-interactive":
|
|
301
|
+
# non-interactive:CF 后台 JS 自动验证,无需点击。强行点击反而被 CF 判可疑 → 重新挑战。
|
|
302
|
+
logger.info("non-interactive: 等后台 JS 验证(不点击),最多 30s")
|
|
303
|
+
return _wait_challenge_cleared(page, timeout=30.0)
|
|
304
|
+
|
|
305
|
+
for attempt in range(max_attempts):
|
|
306
|
+
logger.info("==== attempt %d/%d ====", attempt + 1, max_attempts)
|
|
307
|
+
if not try_click_turnstile(page):
|
|
308
|
+
logger.info("本轮未点击成功,等 CF frame/checkbox 加载后重试")
|
|
309
|
+
_rand_sleep(2.0, 3.5)
|
|
310
|
+
continue
|
|
311
|
+
if _wait_challenge_cleared(page, timeout=12.0):
|
|
312
|
+
logger.info("CF 通过(attempt %d)", attempt + 1)
|
|
313
|
+
return True
|
|
314
|
+
logger.warning("attempt %d 后仍在挑战态 → 重试", attempt + 1)
|
|
315
|
+
|
|
316
|
+
final = not cf_title_active(getattr(page, "title", "") or "")
|
|
317
|
+
logger.warning("达到 max_attempts=%d,最终通过: %s", max_attempts, final)
|
|
318
|
+
return final
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""HTML → markdown(仅 Playwright 回退路径用,D6)。
|
|
2
|
+
|
|
3
|
+
trafilatura with include_formatting=True 保留代码块/格式(基线 §10)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
_TRAFILATURA_AVAILABLE = True
|
|
11
|
+
try:
|
|
12
|
+
import trafilatura # type: ignore[import-not-found]
|
|
13
|
+
except ImportError: # pragma: no cover
|
|
14
|
+
_TRAFILATURA_AVAILABLE = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def html_to_md(html: str, url: str = "") -> str:
|
|
18
|
+
"""把 HTML 转成 markdown。trafilatura 不可用或抽取失败时退化为 tag 剥离。"""
|
|
19
|
+
if not html or not html.strip():
|
|
20
|
+
return ""
|
|
21
|
+
if _TRAFILATURA_AVAILABLE:
|
|
22
|
+
try:
|
|
23
|
+
md = trafilatura.extract(
|
|
24
|
+
html,
|
|
25
|
+
include_formatting=True,
|
|
26
|
+
include_links=True,
|
|
27
|
+
include_images=False,
|
|
28
|
+
output_format="markdown",
|
|
29
|
+
url=url,
|
|
30
|
+
)
|
|
31
|
+
if md and md.strip():
|
|
32
|
+
return md.strip()
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
return _strip_html_fallback(html)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _strip_html_fallback(html: str) -> str:
|
|
39
|
+
"""极简兜底:去掉标签、合并空白。"""
|
|
40
|
+
html = re.sub(r"(?is)<script.*?</script>", "", html)
|
|
41
|
+
html = re.sub(r"(?is)<style.*?</style>", "", html)
|
|
42
|
+
html = re.sub(r"(?s)<[^>]+>", " ", html)
|
|
43
|
+
html = re.sub(r"\s+", " ", html)
|
|
44
|
+
return html.strip()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""普通抓取:复用已集成的 provider 做直接出 md 的抓取(D4)。
|
|
2
|
+
|
|
3
|
+
优先 tavily extract(quality 好);不可用/失败再用 firecrawl scrape。
|
|
4
|
+
两者都不可用或失败 → 返回 None,由调用方回退浏览器(ADR-0004)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .. import providers as providers_pkg
|
|
13
|
+
from ..config import Config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def fetch_normal(config: Config, url: str) -> str | None:
|
|
17
|
+
"""对一个 URL 做普通抓取,返回 markdown 或 None。"""
|
|
18
|
+
classes = providers_pkg.all_provider_classes()
|
|
19
|
+
|
|
20
|
+
# 1) tavily extract
|
|
21
|
+
if config.provider("tavily"):
|
|
22
|
+
md = await _try_tavily_extract(classes, config, url)
|
|
23
|
+
if md:
|
|
24
|
+
return md
|
|
25
|
+
|
|
26
|
+
# 2) firecrawl scrape
|
|
27
|
+
if config.provider("firecrawl"):
|
|
28
|
+
md = await _try_firecrawl_scrape(classes, config, url)
|
|
29
|
+
if md:
|
|
30
|
+
return md
|
|
31
|
+
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _try_tavily_extract(classes: dict, config: Config, url: str) -> str | None:
|
|
36
|
+
try:
|
|
37
|
+
provider = classes["tavily"](config)
|
|
38
|
+
cap = next((c for c in provider.capabilities() if c.name == "extract"), None)
|
|
39
|
+
if cap is None:
|
|
40
|
+
return None
|
|
41
|
+
ns = argparse.Namespace(urls=[url], extract_depth="advanced", format="markdown")
|
|
42
|
+
result = await cap.handler(ns)
|
|
43
|
+
results = result.get("results") or []
|
|
44
|
+
if results:
|
|
45
|
+
content = results[0].get("content")
|
|
46
|
+
if content and content.strip():
|
|
47
|
+
return content
|
|
48
|
+
return None
|
|
49
|
+
except Exception:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _try_firecrawl_scrape(classes: dict, config: Config, url: str) -> str | None:
|
|
54
|
+
try:
|
|
55
|
+
provider = classes["firecrawl"](config)
|
|
56
|
+
cap = next((c for c in provider.capabilities() if c.name == "scrape"), None)
|
|
57
|
+
if cap is None:
|
|
58
|
+
return None
|
|
59
|
+
ns = argparse.Namespace(
|
|
60
|
+
urls=[url], format="markdown", only_main_content=True, wait_for=None
|
|
61
|
+
)
|
|
62
|
+
result = await cap.handler(ns)
|
|
63
|
+
data = result.get("data") or {}
|
|
64
|
+
md = data.get("markdown")
|
|
65
|
+
if md and md.strip():
|
|
66
|
+
return md
|
|
67
|
+
return None
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|