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,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