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,93 @@
|
|
|
1
|
+
"""Provider 统一接口(ADR-0011 插件化解耦的核心)。
|
|
2
|
+
|
|
3
|
+
每个 provider 声明:
|
|
4
|
+
type —— config 中的 type 键(如 "exa")
|
|
5
|
+
command —— 主命令名(如 "exa")
|
|
6
|
+
aliases —— 命令别名(如 ["x"])
|
|
7
|
+
capabilities() —— 返回 Capability 列表(子命令 → async 处理函数 + 参数声明)
|
|
8
|
+
|
|
9
|
+
加/删/换 provider = 1 文件 + 1 条 config,不改 CLI 入口/路由/横切/其他 provider。
|
|
10
|
+
能力差异用 capabilities{} 声明式表达,CLI 按声明动态生成命令(不强制统一方法签名)。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from ..config import Config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ArgSpec:
|
|
23
|
+
"""单个 CLI 参数声明(透传到 argparse.add_argument)。
|
|
24
|
+
|
|
25
|
+
kind="positional" 时 name_or_flags 给单个位置参数名(如 ["query"]);
|
|
26
|
+
kind="optional" 时给 flag(如 ["--num-results"])。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name_or_flags: list[str]
|
|
30
|
+
help: str = ""
|
|
31
|
+
default: Any = None
|
|
32
|
+
required: bool = False
|
|
33
|
+
type: Callable[[str], Any] | None = None
|
|
34
|
+
choices: list[str] | None = None
|
|
35
|
+
nargs: str | int | None = None
|
|
36
|
+
action: str | None = None
|
|
37
|
+
dest: str | None = None
|
|
38
|
+
metavar: str | None = None
|
|
39
|
+
kind: str = "optional"
|
|
40
|
+
|
|
41
|
+
def add_to(self, parser: Any) -> None:
|
|
42
|
+
kwargs: dict[str, Any] = {"help": self.help}
|
|
43
|
+
if self.kind == "optional":
|
|
44
|
+
if self.default is not None:
|
|
45
|
+
kwargs["default"] = self.default
|
|
46
|
+
if self.required:
|
|
47
|
+
kwargs["required"] = True
|
|
48
|
+
if self.type is not None:
|
|
49
|
+
kwargs["type"] = self.type
|
|
50
|
+
if self.choices is not None:
|
|
51
|
+
kwargs["choices"] = self.choices
|
|
52
|
+
if self.nargs is not None:
|
|
53
|
+
kwargs["nargs"] = self.nargs
|
|
54
|
+
if self.action is not None:
|
|
55
|
+
kwargs["action"] = self.action
|
|
56
|
+
if self.dest is not None:
|
|
57
|
+
kwargs["dest"] = self.dest
|
|
58
|
+
if self.metavar is not None:
|
|
59
|
+
kwargs["metavar"] = self.metavar
|
|
60
|
+
parser.add_argument(*self.name_or_flags, **kwargs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Capability:
|
|
65
|
+
"""一个子命令(如 exa search):参数声明 + async 处理函数。
|
|
66
|
+
|
|
67
|
+
handler 签名:async (provider_instance, argparse.Namespace) -> dict
|
|
68
|
+
返回 dict 直接作为 JSON 输出(对齐 api-contract.md §3)。
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
help: str = ""
|
|
73
|
+
args: list[ArgSpec] = field(default_factory=list)
|
|
74
|
+
handler: Callable[..., Any] | None = None # async callable
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Provider:
|
|
78
|
+
"""所有 provider 的基类。子类设置类属性 + 实现 capabilities() 与各 handler。"""
|
|
79
|
+
|
|
80
|
+
type: str = ""
|
|
81
|
+
command: str = ""
|
|
82
|
+
aliases: list[str] = []
|
|
83
|
+
help: str = ""
|
|
84
|
+
|
|
85
|
+
def __init__(self, config: Config) -> None:
|
|
86
|
+
self.config = config
|
|
87
|
+
|
|
88
|
+
def capabilities(self) -> list[Capability]:
|
|
89
|
+
raise NotImplementedError
|
|
90
|
+
|
|
91
|
+
def client_kwargs(self) -> dict[str, Any]:
|
|
92
|
+
"""子类可覆盖:构造 httpx 客户端时的额外 header/base_url。"""
|
|
93
|
+
return {}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""browser provider:本地浏览器作为一个"平台"(与 exa/tavily/firecrawl 同级,走 registry 自动发现)。
|
|
2
|
+
|
|
3
|
+
不是 HTTP API(无 key/base_url),直接驱动本地浏览器:
|
|
4
|
+
- browser fetch:headed DrissionPage + CF auto-detect 抓取(跳过普通 API,直接浏览器入口)。
|
|
5
|
+
- browser search:headless DrissionPage 抓搜索引擎结果页(必应国内/国际、Google),页数表翻页。
|
|
6
|
+
与 fetch 命令的浏览器层共用 fetch_with_browser(去 headless 后统一 headed+CF auto-detect)。
|
|
7
|
+
config 无需 [[provider]] type=browser,运行参数从 config.browser 读(channel 等);永远可用。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..errors import ArgsError
|
|
16
|
+
from ..fetch import fetch_with_browser, write_snapshot
|
|
17
|
+
from ..fetch.search_engine import search_engine
|
|
18
|
+
from .base import ArgSpec, Capability, Provider
|
|
19
|
+
from .registry import register
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@register
|
|
23
|
+
class BrowserProvider(Provider):
|
|
24
|
+
type = "browser"
|
|
25
|
+
command = "browser"
|
|
26
|
+
aliases = ["br"]
|
|
27
|
+
help = "Local browser platform: headed fetch with CF auto-detect, or headless search-engine search."
|
|
28
|
+
|
|
29
|
+
def capabilities(self) -> list[Capability]:
|
|
30
|
+
return [
|
|
31
|
+
Capability(
|
|
32
|
+
name="fetch",
|
|
33
|
+
help="Headed browser fetch with CF auto-detect (skips normal API).",
|
|
34
|
+
args=[
|
|
35
|
+
ArgSpec(["urls"], kind="positional", nargs="+", metavar="URL", help="URL(s) to fetch."),
|
|
36
|
+
ArgSpec(["--no-login"], action="store_true", help="Do not inject login cookies (default injects)."),
|
|
37
|
+
ArgSpec(["--concurrency"], type=int, default=4, help="Browser concurrency (default 4)."),
|
|
38
|
+
ArgSpec(["--write"], metavar="PATH", help="Write markdown to this path (single URL only)."),
|
|
39
|
+
],
|
|
40
|
+
handler=self.fetch,
|
|
41
|
+
),
|
|
42
|
+
Capability(
|
|
43
|
+
name="search",
|
|
44
|
+
help="Headless search-engine search (default bing-intl; bing-cn|bing-intl|google).",
|
|
45
|
+
args=[
|
|
46
|
+
ArgSpec(["query"], kind="positional", metavar="QUERY", help="Search query."),
|
|
47
|
+
ArgSpec(
|
|
48
|
+
["--engine"],
|
|
49
|
+
choices=["bing-cn", "bing-intl", "google"],
|
|
50
|
+
default="bing-intl",
|
|
51
|
+
help="Search engine (default bing-intl; intl avoids CN-sensitive-word filtering).",
|
|
52
|
+
),
|
|
53
|
+
ArgSpec(["--limit"], type=int, default=10, help="Min results to collect (default 10)."),
|
|
54
|
+
ArgSpec(["--max-pages"], type=int, default=10, help="Max pages to paginate (default 10)."),
|
|
55
|
+
],
|
|
56
|
+
handler=self.search,
|
|
57
|
+
),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
async def fetch(self, args_ns: argparse.Namespace) -> dict[str, Any]:
|
|
61
|
+
urls = list(args_ns.urls)
|
|
62
|
+
if args_ns.write and len(urls) > 1:
|
|
63
|
+
raise ArgsError("browser fetch: --write 仅支持单个 URL")
|
|
64
|
+
use_login = not getattr(args_ns, "no_login", False)
|
|
65
|
+
raw = await fetch_with_browser(
|
|
66
|
+
self.config, urls, login=use_login, concurrency=args_ns.concurrency
|
|
67
|
+
)
|
|
68
|
+
results: list[dict[str, Any]] = []
|
|
69
|
+
for url in urls:
|
|
70
|
+
md = raw.get(url)
|
|
71
|
+
if md:
|
|
72
|
+
md_path = write_snapshot(url, "browser", md, args_ns.write)
|
|
73
|
+
results.append(
|
|
74
|
+
{"url": url, "method": "browser", "status": "success", "md_path": md_path}
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
results.append(
|
|
78
|
+
{
|
|
79
|
+
"url": url,
|
|
80
|
+
"method": "browser",
|
|
81
|
+
"status": "failed",
|
|
82
|
+
"md_path": None,
|
|
83
|
+
"error": "浏览器抓取失败(CF 未通过/无浏览器/内容过短)",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
return {"results": results}
|
|
87
|
+
|
|
88
|
+
async def search(self, args_ns: argparse.Namespace) -> dict[str, Any]:
|
|
89
|
+
return await search_engine(
|
|
90
|
+
self.config, args_ns.query, args_ns.engine, args_ns.limit, args_ns.max_pages
|
|
91
|
+
)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Context7 provider(自写 HTTP,ADR-0010;Context7 无 Python SDK 本就需自写)。
|
|
2
|
+
|
|
3
|
+
公共 REST API(实测确认,context7.com):
|
|
4
|
+
GET https://context7.com/api/v1/search?libraryName=<name>&query=<question>
|
|
5
|
+
鉴权 Authorization: Bearer <key>
|
|
6
|
+
resp: {results[]{id"/org/repo", title, description, totalSnippets, trustScore, ...}}
|
|
7
|
+
GET https://context7.com/api/v1/<libraryId>?topic=<query>&tokens=<n>&format=markdown
|
|
8
|
+
注:libraryId 在路径里(catch-all 路由),需去掉前导 /
|
|
9
|
+
resp: text/markdown,块间以 '--------------------------------' 分隔,每块含 'Source: <url>'
|
|
10
|
+
|
|
11
|
+
对齐 api-contract.md §2.1/§3:
|
|
12
|
+
ctx7 library → {results[]{id, name, description}}
|
|
13
|
+
ctx7 docs → {id, contents[]{text, source_url?}}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ..config import ProviderConfig
|
|
23
|
+
from ..errors import ArgsError, ProviderError
|
|
24
|
+
from .. import http as http_mod
|
|
25
|
+
from ..proxy import resolve_proxy
|
|
26
|
+
from .base import ArgSpec, Capability, Provider
|
|
27
|
+
from .registry import register
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://context7.com"
|
|
30
|
+
_SEPARATOR = re.compile(r"\n-{10,}\n")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@register
|
|
34
|
+
class Context7Provider(Provider):
|
|
35
|
+
type = "context7"
|
|
36
|
+
command = "ctx7"
|
|
37
|
+
aliases = ["c7"]
|
|
38
|
+
help = "Context7 library resolution and up-to-date docs retrieval."
|
|
39
|
+
|
|
40
|
+
def capabilities(self) -> list[Capability]:
|
|
41
|
+
return [
|
|
42
|
+
Capability(
|
|
43
|
+
name="library",
|
|
44
|
+
help="Resolve a library ID by name.",
|
|
45
|
+
args=[
|
|
46
|
+
ArgSpec(["name"], kind="positional", help="Library name (e.g. 'Next.js')."),
|
|
47
|
+
ArgSpec(["query"], kind="positional", nargs="?", help="Your question (for relevance ranking)."),
|
|
48
|
+
],
|
|
49
|
+
handler=self.library,
|
|
50
|
+
),
|
|
51
|
+
Capability(
|
|
52
|
+
name="docs",
|
|
53
|
+
help="Fetch up-to-date docs for a library ID (must start with '/').",
|
|
54
|
+
args=[
|
|
55
|
+
ArgSpec(["library_id"], kind="positional", metavar="LIBRARY_ID", help="Library ID, e.g. /facebook/react."),
|
|
56
|
+
ArgSpec(["query"], kind="positional", help="Topic / question."),
|
|
57
|
+
ArgSpec(["--tokens"], type=int, default=5000, help="Max tokens to return (default 5000)."),
|
|
58
|
+
],
|
|
59
|
+
handler=self.docs,
|
|
60
|
+
),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
def _cfg(self) -> ProviderConfig:
|
|
64
|
+
cfg = self.config.require_provider("context7")
|
|
65
|
+
if not cfg.api_key:
|
|
66
|
+
raise ArgsError("context7: 缺少 api_key", provider="context7")
|
|
67
|
+
if not cfg.base_url:
|
|
68
|
+
cfg.base_url = DEFAULT_BASE_URL
|
|
69
|
+
return cfg
|
|
70
|
+
|
|
71
|
+
async def library(self, args: Any) -> dict[str, Any]:
|
|
72
|
+
cfg = self._cfg()
|
|
73
|
+
params: dict[str, str] = {"libraryName": args.name}
|
|
74
|
+
if args.query:
|
|
75
|
+
params["query"] = args.query
|
|
76
|
+
else:
|
|
77
|
+
params["query"] = args.name
|
|
78
|
+
async with http_mod.make_client(
|
|
79
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
80
|
+
timeout=cfg.timeout,
|
|
81
|
+
headers={"Authorization": f"Bearer {cfg.api_key}"},
|
|
82
|
+
) as client:
|
|
83
|
+
data = await http_mod.request_json(
|
|
84
|
+
client, "GET", f"{cfg.base_url}/api/v1/search", provider="context7", params=params
|
|
85
|
+
)
|
|
86
|
+
results = []
|
|
87
|
+
for r in data.get("results", []) or []:
|
|
88
|
+
results.append(
|
|
89
|
+
{
|
|
90
|
+
"id": r.get("id"),
|
|
91
|
+
"name": r.get("title"),
|
|
92
|
+
"description": r.get("description"),
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
return {"results": results}
|
|
96
|
+
|
|
97
|
+
async def docs(self, args: Any) -> dict[str, Any]:
|
|
98
|
+
cfg = self._cfg()
|
|
99
|
+
lib_id = args.library_id
|
|
100
|
+
if not lib_id.startswith("/"):
|
|
101
|
+
raise ArgsError(
|
|
102
|
+
"context7 docs: libraryId 必须以 '/' 开头(如 /facebook/react)", provider="context7"
|
|
103
|
+
)
|
|
104
|
+
params = {"topic": args.query, "tokens": str(args.tokens), "format": "markdown"}
|
|
105
|
+
async with http_mod.make_client(
|
|
106
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
107
|
+
timeout=cfg.timeout,
|
|
108
|
+
headers={"Authorization": f"Bearer {cfg.api_key}"},
|
|
109
|
+
) as client:
|
|
110
|
+
text, final_id = await _fetch_docs(client, cfg.base_url, lib_id, params)
|
|
111
|
+
contents = _parse_docs_text(text)
|
|
112
|
+
return {"id": final_id, "contents": contents}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _fetch_docs(
|
|
116
|
+
client: httpx.AsyncClient, base_url: str, lib_id: str, params: dict[str, str], _depth: int = 0
|
|
117
|
+
) -> tuple[str, str]:
|
|
118
|
+
"""取 docs 文本;Context7 对旧 id 会 404 重定向,自动跟随(最多 3 次)。"""
|
|
119
|
+
if _depth > 3:
|
|
120
|
+
raise ProviderError("context7: libraryId 重定向次数过多", provider="context7")
|
|
121
|
+
path_id = lib_id.lstrip("/")
|
|
122
|
+
try:
|
|
123
|
+
resp = await client.request("GET", f"{base_url}/api/v1/{path_id}", params=params)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
from ..errors import wrap_provider_http_error
|
|
126
|
+
|
|
127
|
+
raise wrap_provider_http_error("context7", e) from e
|
|
128
|
+
if resp.status_code == 404:
|
|
129
|
+
# 解析重定向提示:"Library X has been redirected to this library: /new/id."
|
|
130
|
+
import re
|
|
131
|
+
|
|
132
|
+
m = re.search(r"redirected to this library:\s*(/[^\s.]+)", resp.text)
|
|
133
|
+
if m:
|
|
134
|
+
new_id = m.group(1)
|
|
135
|
+
return await _fetch_docs(client, base_url, new_id, params, _depth + 1)
|
|
136
|
+
if resp.status_code >= 400:
|
|
137
|
+
raise http_mod.handle_http_error(
|
|
138
|
+
"context7",
|
|
139
|
+
httpx.HTTPStatusError(f"context7 HTTP {resp.status_code}", request=resp.request, response=resp),
|
|
140
|
+
)
|
|
141
|
+
return resp.text, lib_id
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_docs_text(text: str) -> list[dict[str, Any]]:
|
|
145
|
+
"""把 Context7 markdown 文本拆成 contents[]{text, source_url?}。
|
|
146
|
+
|
|
147
|
+
块间以一行短横线分隔;每块若含 'Source: <url>' 行,抽出为 source_url。
|
|
148
|
+
"""
|
|
149
|
+
if not text or not text.strip():
|
|
150
|
+
return []
|
|
151
|
+
blocks = _SEPARATOR.split(text)
|
|
152
|
+
out: list[dict[str, Any]] = []
|
|
153
|
+
for block in blocks:
|
|
154
|
+
block = block.strip()
|
|
155
|
+
if not block:
|
|
156
|
+
continue
|
|
157
|
+
source_url = None
|
|
158
|
+
lines = block.splitlines()
|
|
159
|
+
# 找 Source: 行
|
|
160
|
+
clean_lines: list[str] = []
|
|
161
|
+
for line in lines:
|
|
162
|
+
m = re.match(r"\s*Source:\s*(\S+)", line)
|
|
163
|
+
if m and source_url is None:
|
|
164
|
+
source_url = m.group(1)
|
|
165
|
+
continue # 去掉 Source 行本身
|
|
166
|
+
clean_lines.append(line)
|
|
167
|
+
item: dict[str, Any] = {"text": "\n".join(clean_lines).strip()}
|
|
168
|
+
if source_url:
|
|
169
|
+
item["source_url"] = source_url
|
|
170
|
+
out.append(item)
|
|
171
|
+
return out
|