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,324 @@
|
|
|
1
|
+
"""浏览器搜索引擎(browser 平台 search 能力):无头 DrissionPage 抓必应/谷歌结果页。
|
|
2
|
+
|
|
3
|
+
关键词进 → 结果列表出(url/title/snippet)。无头跑(结果页无 CF 挑战,headless 快且不打扰用户);
|
|
4
|
+
偶发 consent/挑战由 cfbypass.solve 兜底(无挑战 is_cf_challenge 秒返 True,开销极低)。
|
|
5
|
+
|
|
6
|
+
翻页:首页底部的页数表里,每个页码 <a> 的 href 就是该页完整链接(必应 /search?q=...&first=N、
|
|
7
|
+
谷歌 /search?q=...&start=N)。一次抽全所有页码链接、按页码升序、逐个访问;累计结果数 ≥ limit 即停,
|
|
8
|
+
按 url 去重,最多翻 max_pages 页(默认 10,--max-pages 可调)。不必反复点"下一页"。
|
|
9
|
+
|
|
10
|
+
结果 URL:优先结果块的 <cite>(必应/谷歌在 cite 显示真实 URL);cite 不可用(面包屑或缺失)时
|
|
11
|
+
回退标题链接的 href(必应是 /ck/a 跳转包装,解其 u= base64 取真实 URL)。
|
|
12
|
+
|
|
13
|
+
选择器集中放 SELECTORS(CSS,DrissionPage ele/eles),实测于 2026-07。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
import math
|
|
21
|
+
import re
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import quote_plus, urljoin
|
|
25
|
+
|
|
26
|
+
from ..config import Config
|
|
27
|
+
from ..errors import ArgsError, ResearchAssistantError
|
|
28
|
+
from . import cfbypass
|
|
29
|
+
from .browser import (
|
|
30
|
+
_acquire_browser_slot,
|
|
31
|
+
_build_dp_options,
|
|
32
|
+
_channel_executable,
|
|
33
|
+
_close_browser,
|
|
34
|
+
_new_profile_dir,
|
|
35
|
+
_release_browser_slot,
|
|
36
|
+
_safe_rmtree,
|
|
37
|
+
_wait_network_idle,
|
|
38
|
+
_write_suppress_prefs,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger("research_assistant.fetch.search_engine")
|
|
42
|
+
|
|
43
|
+
DEFAULT_MAX_PAGES = 10 # 默认翻页上限,防失控;可由调用方(CLI --max-pages)覆盖
|
|
44
|
+
_SEARCH_CONCURRENCY = 4 # 搜索翻页并发 tab 数(与 fetch 默认并发对齐)
|
|
45
|
+
|
|
46
|
+
# 引擎搜索 URL 模板({q}=URL 编码后的关键词)。翻页靠页数表链接,不硬编码 offset 参数。
|
|
47
|
+
ENGINE_URLS: dict[str, str] = {
|
|
48
|
+
"bing-cn": "https://cn.bing.com/search?q={q}",
|
|
49
|
+
"bing-intl": "https://www.bing.com/search?q={q}",
|
|
50
|
+
"google": "https://www.google.com/search?q={q}",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# 选择器(CSS,DrissionPage ele/eles),实测于 2026-07。
|
|
54
|
+
# - item: 结果块;link: 标题链接(取 title);snippet: 摘要;cite: 真实 URL(优先于 link href)。
|
|
55
|
+
# - pages: 首页底部页数表里的页码 <a>(一次抽所有页,按页码排序逐个访问)。
|
|
56
|
+
SELECTORS: dict[str, dict[str, list[str]]] = {
|
|
57
|
+
"bing-cn": {
|
|
58
|
+
"item": ["css:li.b_algo"],
|
|
59
|
+
"link": ["css:h2 a"],
|
|
60
|
+
"snippet": ["css:p.b_lineclamp2", "css:.b_caption p", "css:div.b_lineclamp4"],
|
|
61
|
+
"cite": ["css:.b_caption cite", "css:cite"],
|
|
62
|
+
"pages": ["css:li.b_pag a[href]"],
|
|
63
|
+
},
|
|
64
|
+
"bing-intl": {
|
|
65
|
+
"item": ["css:li.b_algo"],
|
|
66
|
+
"link": ["css:h2 a"],
|
|
67
|
+
"snippet": ["css:p.b_lineclamp2", "css:.b_caption p", "css:div.b_lineclamp4"],
|
|
68
|
+
"cite": ["css:.b_caption cite", "css:cite"],
|
|
69
|
+
"pages": ["css:li.b_pag a[href]"],
|
|
70
|
+
},
|
|
71
|
+
"google": {
|
|
72
|
+
"item": ["css:div.g", "css:div.MXl0lf"],
|
|
73
|
+
"link": ["css:h3", "css:a h3"],
|
|
74
|
+
"snippet": ["css:div.VwiC3b", "css:span.aCOpRe"],
|
|
75
|
+
"cite": ["css:cite"],
|
|
76
|
+
"pages": ["css:table.AaVjTc a[href]", "css:div[role=navigation] a[href]"],
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _engine_url(engine: str, query: str) -> str:
|
|
82
|
+
"""构造引擎首页搜索 URL。engine 非法抛 ArgsError。"""
|
|
83
|
+
if engine not in ENGINE_URLS:
|
|
84
|
+
raise ArgsError(f"未知搜索引擎: {engine}(可选 bing-cn|bing-intl|google)")
|
|
85
|
+
return ENGINE_URLS[engine].format(q=quote_plus(query))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _decode_bing_url(url: str) -> str | None:
|
|
89
|
+
"""bing /ck/a 跳转链接解出真实 URL(u= 参数 base64url)。非 ck/a 或失败返回 None。
|
|
90
|
+
|
|
91
|
+
结果块 cite 不可用时的回退:标题链接 href 是 https://www.bing.com/ck/a?...&u=a1<base64url> 跳转包装。
|
|
92
|
+
"""
|
|
93
|
+
if "/ck/a" not in url:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
import base64
|
|
97
|
+
from urllib.parse import urlparse, parse_qs
|
|
98
|
+
|
|
99
|
+
u = (parse_qs(urlparse(url).query).get("u") or [""])[0]
|
|
100
|
+
if not u:
|
|
101
|
+
return None
|
|
102
|
+
payload = u[2:] if u[:2].lower() == "a1" else u
|
|
103
|
+
payload += "=" * (-len(payload) % 4) # 补 base64 padding
|
|
104
|
+
decoded = base64.urlsafe_b64decode(payload).decode("utf-8", errors="replace")
|
|
105
|
+
return decoded if decoded.startswith("http") else None
|
|
106
|
+
except Exception:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _first(container: Any, selectors: list[str]) -> Any | None:
|
|
111
|
+
"""按顺序试多个 CSS 选择器,返回首个查到的元素(DrissionPage ele);都无返回 None。"""
|
|
112
|
+
for sel in selectors:
|
|
113
|
+
try:
|
|
114
|
+
el = container.ele(sel, timeout=1.5)
|
|
115
|
+
if el:
|
|
116
|
+
return el
|
|
117
|
+
except Exception:
|
|
118
|
+
continue
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _all(container: Any, selectors: list[str]) -> list[Any]:
|
|
123
|
+
"""按顺序试多个 CSS 选择器,返回首个查到非空的结果列表(DrissionPage eles);都无返回 []。"""
|
|
124
|
+
for sel in selectors:
|
|
125
|
+
try:
|
|
126
|
+
els = container.eles(sel, timeout=1.5)
|
|
127
|
+
if els:
|
|
128
|
+
return list(els)
|
|
129
|
+
except Exception:
|
|
130
|
+
continue
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_page_num(a: Any, text: str) -> int | None:
|
|
135
|
+
"""从页码 <a> 解析页码 int。必应 a 文本是纯数字;谷歌 aria-label="Page N"。非页码返回 None。"""
|
|
136
|
+
t = (text or "").strip()
|
|
137
|
+
if t.isdigit():
|
|
138
|
+
return int(t)
|
|
139
|
+
try:
|
|
140
|
+
label = a.attr("aria-label") or ""
|
|
141
|
+
except Exception:
|
|
142
|
+
label = ""
|
|
143
|
+
m = re.search(r"\d+", label)
|
|
144
|
+
return int(m.group(1)) if m else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _extract_results(page: Any, engine: str) -> list[dict[str, str]]:
|
|
148
|
+
"""从当前结果页抽 [{url, title, snippet}]。
|
|
149
|
+
|
|
150
|
+
url 取标题链接 href:必应是 /ck/a 跳转,解其 u= base64 得完整真实 URL;非 ck 直接用 href。
|
|
151
|
+
(cite 元素只显示根域名、不完整,实测确认不用——完整真实 URL 只在标题链接的 ck/a 跳转里。)
|
|
152
|
+
"""
|
|
153
|
+
sels = SELECTORS[engine]
|
|
154
|
+
items = _all(page, sels["item"])
|
|
155
|
+
out: list[dict[str, str]] = []
|
|
156
|
+
for it in items:
|
|
157
|
+
link_el = _first(it, sels["link"])
|
|
158
|
+
if link_el is None:
|
|
159
|
+
continue
|
|
160
|
+
raw = (getattr(link_el, "link", None) or link_el.attr("href") or "").strip()
|
|
161
|
+
if not raw or raw.startswith(("javascript:", "#")):
|
|
162
|
+
continue
|
|
163
|
+
url = _decode_bing_url(raw) or raw
|
|
164
|
+
title = (getattr(link_el, "text", "") or "").strip()
|
|
165
|
+
snippet_el = _first(it, sels["snippet"])
|
|
166
|
+
snippet = (getattr(snippet_el, "text", "") or "").strip() if snippet_el else ""
|
|
167
|
+
out.append({"url": url, "title": title, "snippet": snippet})
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _extract_page_links(page: Any, engine: str) -> list[tuple[int, str]]:
|
|
172
|
+
"""从首页底部页数表抽所有页码链接 [(page_num, abs_url)],按页码升序、去重。
|
|
173
|
+
|
|
174
|
+
每个页码 <a> 的 href 就是该页完整链接(必应 &first=N / 谷歌 &start=N)。排除当前页(1)与
|
|
175
|
+
非页码(下一页/上一页,文本非数字、aria-label 无数字)。相对 href 按当前页 url 解析为绝对。
|
|
176
|
+
"""
|
|
177
|
+
sels = SELECTORS[engine]
|
|
178
|
+
links = _all(page, sels["pages"])
|
|
179
|
+
by_num: dict[int, str] = {}
|
|
180
|
+
base = getattr(page, "url", "") or ""
|
|
181
|
+
for a in links:
|
|
182
|
+
try:
|
|
183
|
+
text = (getattr(a, "text", "") or "").strip()
|
|
184
|
+
href = (getattr(a, "link", None) or a.attr("href") or "").strip()
|
|
185
|
+
except Exception:
|
|
186
|
+
continue
|
|
187
|
+
if not href or href.startswith(("javascript:", "#")):
|
|
188
|
+
continue
|
|
189
|
+
num = _parse_page_num(a, text)
|
|
190
|
+
if num is None or num <= 1: # 跳过当前页(1)与非页码(下一页/上一页)
|
|
191
|
+
continue
|
|
192
|
+
if not href.startswith(("http://", "https://")):
|
|
193
|
+
href = urljoin(base, href)
|
|
194
|
+
by_num.setdefault(num, href)
|
|
195
|
+
return sorted(by_num.items())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _run_engine_sync(config: Config, query: str, engine: str, limit: int, max_pages: int) -> list[dict[str, str]]:
|
|
199
|
+
"""同步:headless DrissionPage,1 browser 多 tab 智能并发翻页。
|
|
200
|
+
|
|
201
|
+
1. 首页(tab0):get → _wait_network_idle → CF 兜底 solve → _extract_results(N) + _extract_page_links。
|
|
202
|
+
2. 推断:据已抓每页结果数算需补页数 ceil((limit-已抓)/per_page),受 max_pages 与页数表长度约束。
|
|
203
|
+
3. 并发:主线程 new_tab 建 tab,ThreadPoolExecutor 每页 tab get → idle → solve → _extract_results。
|
|
204
|
+
4. 两阶段补页:累计 < limit 且页数表还有未抓页 → 据已抓页最少结果数重算下批页数,取下一批。
|
|
205
|
+
5. 边缘:页数表页数有限(如必应某词只 3 页)用完仍 < limit → 返回已有。
|
|
206
|
+
"""
|
|
207
|
+
if _channel_executable(config.browser.channel) is None:
|
|
208
|
+
raise ResearchAssistantError(
|
|
209
|
+
f"未找到本地浏览器 ({config.browser.channel}),搜索引擎不可用"
|
|
210
|
+
)
|
|
211
|
+
from DrissionPage import ChromiumPage
|
|
212
|
+
|
|
213
|
+
profile_dir = _new_profile_dir()
|
|
214
|
+
lock_dir = _acquire_browser_slot(config)
|
|
215
|
+
page = None
|
|
216
|
+
browser_pid = 0
|
|
217
|
+
seen: set[str] = set()
|
|
218
|
+
collected: list[dict[str, str]] = []
|
|
219
|
+
|
|
220
|
+
def absorb(results: list[dict[str, str]]) -> None:
|
|
221
|
+
for r in results:
|
|
222
|
+
if r["url"] in seen:
|
|
223
|
+
continue
|
|
224
|
+
seen.add(r["url"])
|
|
225
|
+
collected.append(r)
|
|
226
|
+
|
|
227
|
+
def fetch_in_tab(tab: Any, url: str) -> list[dict[str, str]]:
|
|
228
|
+
logger.info("GET %s", url)
|
|
229
|
+
tab.get(url)
|
|
230
|
+
_wait_network_idle(tab)
|
|
231
|
+
try: # 偶发 CF/挑战兜底(无挑战 solve 秒过,开销极低)
|
|
232
|
+
if cfbypass.is_cf_challenge(tab):
|
|
233
|
+
logger.info("结果页遇 CF 挑战,调用 cfbypass.solve")
|
|
234
|
+
cfbypass.solve(tab)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.debug("solve 兜底跳过: %s", e)
|
|
237
|
+
return _extract_results(tab, engine)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
_write_suppress_prefs(profile_dir)
|
|
241
|
+
page = ChromiumPage(_build_dp_options(config, profile_dir, headless=True))
|
|
242
|
+
browser_pid = getattr(getattr(page, "browser", None), "process_id", 0) or 0
|
|
243
|
+
logger.info(
|
|
244
|
+
"搜索引擎(%s) headless 多 tab(%s),query=%r limit=%d max_pages=%d",
|
|
245
|
+
engine, config.browser.channel, query, limit, max_pages,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# 1) 首页(tab0)
|
|
249
|
+
first_results = fetch_in_tab(page, _engine_url(engine, query))
|
|
250
|
+
per_page_est = max(len(first_results), 1)
|
|
251
|
+
absorb(first_results)
|
|
252
|
+
logger.info("首页:累计 %d/%d(本页 %d 结果)", len(collected), limit, per_page_est)
|
|
253
|
+
if len(collected) >= limit:
|
|
254
|
+
return collected
|
|
255
|
+
|
|
256
|
+
# 首页页数表抽所有页码链接
|
|
257
|
+
page_links = _extract_page_links(page, engine)
|
|
258
|
+
if not page_links:
|
|
259
|
+
return collected
|
|
260
|
+
|
|
261
|
+
# 2-4) 智能并发翻页(两阶段补页)
|
|
262
|
+
idx = 0 # page_links 游标
|
|
263
|
+
pages_done = 1 # 含首页
|
|
264
|
+
pages_cap = max(1, max_pages)
|
|
265
|
+
while len(collected) < limit and idx < len(page_links) and pages_done < pages_cap:
|
|
266
|
+
# 据已抓每页结果数推断本批该抓多少页(ceil 补足,受剩余页数额度约束)
|
|
267
|
+
need_for_limit = math.ceil(max(limit - len(collected), 1) / max(per_page_est, 1))
|
|
268
|
+
batch_size = max(1, min(need_for_limit, pages_cap - pages_done))
|
|
269
|
+
batch = page_links[idx: idx + batch_size]
|
|
270
|
+
idx += len(batch)
|
|
271
|
+
if not batch:
|
|
272
|
+
break
|
|
273
|
+
pages_done += len(batch)
|
|
274
|
+
|
|
275
|
+
# 主线程建 tab,工作线程并发抓(new_tab 浏览器级只主线程;tab 级 WS 工作线程安全)
|
|
276
|
+
tabs = [page.new_tab(background=True) for _ in batch]
|
|
277
|
+
|
|
278
|
+
def work(iu: tuple[int, tuple[int, str]]) -> tuple[int, list[dict[str, str]]]:
|
|
279
|
+
i, (num, purl) = iu
|
|
280
|
+
tab = tabs[i]
|
|
281
|
+
try:
|
|
282
|
+
results = fetch_in_tab(tab, purl)
|
|
283
|
+
return num, results
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.warning("翻页 tab 抓取失败(页码 %d): %s", num, e)
|
|
286
|
+
return num, []
|
|
287
|
+
finally:
|
|
288
|
+
try:
|
|
289
|
+
tab.close()
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
batch_results: list[list[dict[str, str]]] = []
|
|
294
|
+
with ThreadPoolExecutor(max_workers=min(len(batch), _SEARCH_CONCURRENCY)) as ex:
|
|
295
|
+
for num, results in ex.map(work, enumerate(batch)):
|
|
296
|
+
batch_results.append(results)
|
|
297
|
+
logger.info("页码 %d:本页 %d 结果", num, len(results))
|
|
298
|
+
|
|
299
|
+
for results in batch_results:
|
|
300
|
+
absorb(results)
|
|
301
|
+
|
|
302
|
+
# 据本批更新 per_page_est(取最少,保守估计下批页数)
|
|
303
|
+
nonzero = [len(r) for r in batch_results if r]
|
|
304
|
+
if nonzero:
|
|
305
|
+
per_page_est = max(min(nonzero), 1)
|
|
306
|
+
|
|
307
|
+
return collected
|
|
308
|
+
finally:
|
|
309
|
+
_close_browser(page, browser_pid)
|
|
310
|
+
_safe_rmtree(profile_dir)
|
|
311
|
+
_release_browser_slot(lock_dir)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def search_engine(
|
|
315
|
+
config: Config, query: str, engine: str = "bing-intl", limit: int = 10,
|
|
316
|
+
max_pages: int = DEFAULT_MAX_PAGES,
|
|
317
|
+
) -> dict[str, Any]:
|
|
318
|
+
"""浏览器搜索引擎:返回 {query, engine, results[]{url, title, snippet}}。
|
|
319
|
+
|
|
320
|
+
无头 DrissionPage:首页 → 从页数表抽所有页码链接 → 按页码升序逐个访问,累计 ≥ limit 去重,
|
|
321
|
+
最多 max_pages 页(默认 10)。engine 默认 bing-intl(国内必应对部分敏感词会拒搜,国际版更稳)。
|
|
322
|
+
"""
|
|
323
|
+
results = await asyncio.to_thread(_run_engine_sync, config, query, engine, limit, max_pages)
|
|
324
|
+
return {"query": query, "engine": engine, "results": results}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""网页副本落盘(fetch 与 browser fetch 共用,D8 只写快照)。
|
|
2
|
+
|
|
3
|
+
写带 frontmatter 的 markdown:默认 tmp-doc/webcopy-<slug>-<ts>.md,或 --output 指定路径。
|
|
4
|
+
frontmatter 记录来源 url、抓取时间、抓取方式(normal/browser),便于后续 locate 与溯源。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_snapshot(url: str, method: str, md: str, output: str | None) -> str:
|
|
15
|
+
"""落盘网页副本(只写快照,不覆盖既有文件之外的状态)。返回写入路径。
|
|
16
|
+
|
|
17
|
+
output 给定 → 写到该路径(父目录自动创建);
|
|
18
|
+
否则 → tmp-doc/webcopy-<slug>-<ts>.md。
|
|
19
|
+
"""
|
|
20
|
+
fetched_at = datetime.now(timezone.utc).isoformat()
|
|
21
|
+
frontmatter = (
|
|
22
|
+
"---\n"
|
|
23
|
+
f"url: {url}\n"
|
|
24
|
+
f"fetched_at: {fetched_at}\n"
|
|
25
|
+
f"fetch_method: {method}\n"
|
|
26
|
+
"---\n\n"
|
|
27
|
+
)
|
|
28
|
+
body = frontmatter + md + "\n"
|
|
29
|
+
|
|
30
|
+
if output:
|
|
31
|
+
out_path = Path(output).expanduser()
|
|
32
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
out_path.write_text(body, encoding="utf-8")
|
|
34
|
+
return str(out_path)
|
|
35
|
+
|
|
36
|
+
slug = _slugify(url)
|
|
37
|
+
ts = datetime.now().strftime("%m-%d-%H-%M")
|
|
38
|
+
tmp_dir = Path("tmp-doc")
|
|
39
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
path = tmp_dir / f"webcopy-{slug}-{ts}.md"
|
|
41
|
+
path.write_text(body, encoding="utf-8")
|
|
42
|
+
return str(path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _slugify(url: str) -> str:
|
|
46
|
+
"""从 url 抽主机名做文件名 slug(点 → 连字符,去非法字符)。"""
|
|
47
|
+
m = re.search(r"https?://([^/]+)", url)
|
|
48
|
+
host = m.group(1).replace(".", "-") if m else "page"
|
|
49
|
+
host = re.sub(r"[^a-zA-Z0-9\-]", "", host)
|
|
50
|
+
return host or "page"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""共享 httpx 客户端工厂(横切复用,ADR-0011)。
|
|
2
|
+
|
|
3
|
+
各 provider 自写 HTTP(ADR-0010),但共用:
|
|
4
|
+
- httpx.AsyncClient 实例(连接池复用)
|
|
5
|
+
- 代理注入(ADR-0007)
|
|
6
|
+
- 超时(按 provider config)
|
|
7
|
+
- 统一 User-Agent
|
|
8
|
+
- 统一的 HTTP 错误 → ResearchAssistantError 映射
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .errors import NetworkError, ProviderError, ResearchAssistantError
|
|
20
|
+
|
|
21
|
+
USER_AGENT = f"research-assistant/{__version__}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def make_client(
|
|
25
|
+
*,
|
|
26
|
+
proxy_url: str = "",
|
|
27
|
+
timeout: float = 30.0,
|
|
28
|
+
base_url: str = "",
|
|
29
|
+
headers: dict[str, str] | None = None,
|
|
30
|
+
) -> httpx.AsyncClient:
|
|
31
|
+
"""构造一个注入了代理/超时/UA 的 AsyncClient。调用方负责 close(或用上下文)。"""
|
|
32
|
+
client_kwargs: dict[str, Any] = {
|
|
33
|
+
"timeout": httpx.Timeout(timeout, connect=min(15.0, timeout)),
|
|
34
|
+
"headers": {"User-Agent": USER_AGENT, **(headers or {})},
|
|
35
|
+
"follow_redirects": True,
|
|
36
|
+
# 代理由 resolve_proxy(ADR-0007)统一解析后显式传入;禁止 httpx 再二次读 env 代理变量,
|
|
37
|
+
# 否则 trust_env 默认会把 HTTPS_PROXY 叠加(导致 --proxy none 仍走系统代理)。
|
|
38
|
+
"trust_env": False,
|
|
39
|
+
}
|
|
40
|
+
if base_url:
|
|
41
|
+
client_kwargs["base_url"] = base_url.rstrip("/") + "/"
|
|
42
|
+
if proxy_url:
|
|
43
|
+
client_kwargs["proxy"] = proxy_url
|
|
44
|
+
return httpx.AsyncClient(**client_kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def handle_http_error(provider: str, exc: httpx.HTTPStatusError, *, extra_body_text: str | None = None) -> ResearchAssistantError:
|
|
48
|
+
"""把 httpx HTTP 状态错误映射为 ProviderError(带 details)。"""
|
|
49
|
+
status = exc.response.status_code
|
|
50
|
+
body_text = extra_body_text if extra_body_text is not None else ""
|
|
51
|
+
if not body_text:
|
|
52
|
+
try:
|
|
53
|
+
body_text = exc.response.text[:1000]
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
detail: dict[str, Any] = {"status_code": status}
|
|
57
|
+
|
|
58
|
+
# 尝试解析 provider 的 JSON error 体,抽取更友好的信息
|
|
59
|
+
# 覆盖常见形态:{"error":"str"} / {"error":{"message":..}} / {"message":..} / {"detail":..}
|
|
60
|
+
provider_msg = ""
|
|
61
|
+
if body_text and body_text.lstrip().startswith("{"):
|
|
62
|
+
try:
|
|
63
|
+
j = json.loads(body_text)
|
|
64
|
+
err_obj = j.get("error") if isinstance(j, dict) else None
|
|
65
|
+
if isinstance(err_obj, dict):
|
|
66
|
+
provider_msg = str(err_obj.get("message") or err_obj.get("type") or "")
|
|
67
|
+
if err_obj.get("code"):
|
|
68
|
+
detail["provider_code"] = err_obj.get("code")
|
|
69
|
+
elif isinstance(err_obj, str) and err_obj.strip():
|
|
70
|
+
provider_msg = err_obj.strip()
|
|
71
|
+
elif isinstance(j, dict):
|
|
72
|
+
provider_msg = str(j.get("message") or j.get("detail") or "")
|
|
73
|
+
except (ValueError, json.JSONDecodeError):
|
|
74
|
+
pass
|
|
75
|
+
elif body_text:
|
|
76
|
+
detail["body"] = body_text
|
|
77
|
+
|
|
78
|
+
# 常见归类
|
|
79
|
+
if status in (401, 403):
|
|
80
|
+
msg = f"{provider}: 鉴权失败({status}),请检查 API key"
|
|
81
|
+
elif status == 429:
|
|
82
|
+
msg = f"{provider}: 请求被限流(429),稍后重试"
|
|
83
|
+
elif 400 <= status < 500:
|
|
84
|
+
msg = f"{provider}: 请求被拒绝({status})"
|
|
85
|
+
elif 500 <= status < 600:
|
|
86
|
+
msg = f"{provider}: 服务端错误({status})"
|
|
87
|
+
else:
|
|
88
|
+
msg = f"{provider}: 非 2xx 响应({status})"
|
|
89
|
+
if provider_msg:
|
|
90
|
+
msg = f"{msg} — {provider_msg}"
|
|
91
|
+
return ProviderError(msg, provider=provider, details=detail)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def request_json(
|
|
95
|
+
client: httpx.AsyncClient,
|
|
96
|
+
method: str,
|
|
97
|
+
url: str,
|
|
98
|
+
*,
|
|
99
|
+
provider: str,
|
|
100
|
+
json_body: Any = None,
|
|
101
|
+
params: dict[str, Any] | None = None,
|
|
102
|
+
headers: dict[str, str] | None = None,
|
|
103
|
+
) -> Any:
|
|
104
|
+
"""发起请求并返回解析后的 JSON;统一错误映射。"""
|
|
105
|
+
try:
|
|
106
|
+
resp = await client.request(method, url, json=json_body, params=params, headers=headers)
|
|
107
|
+
except httpx.HTTPError as e:
|
|
108
|
+
from .errors import wrap_provider_http_error
|
|
109
|
+
|
|
110
|
+
raise wrap_provider_http_error(provider, e) from e
|
|
111
|
+
if resp.status_code >= 400:
|
|
112
|
+
raise handle_http_error(provider, httpx.HTTPStatusError(
|
|
113
|
+
f"{provider} HTTP {resp.status_code}", request=resp.request, response=resp
|
|
114
|
+
))
|
|
115
|
+
try:
|
|
116
|
+
return resp.json()
|
|
117
|
+
except ValueError as e:
|
|
118
|
+
raise NetworkError(f"{provider}: 响应非 JSON ({e})", provider=provider) from e
|