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,145 @@
1
+ """cookie daemon(Python 移植自 cdt bridge/daemon.mjs,ADR-0006)。
2
+
3
+ 单 aiohttp 进程同时承载:
4
+ WS ws://127.0.0.1:<port>/ws 扩展主动连,保持长连接,响应 getCookies
5
+ HTTP http://127.0.0.1:<port>/status|/cookies fetch/doctor 按需调
6
+ 保活:每 25s 向扩展 ping(< service worker 30s 不活动阈值)。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import os
15
+ import sys
16
+ from typing import Any
17
+
18
+ from aiohttp import web, WSMsgType
19
+
20
+ log = logging.getLogger("research_assistant.loginstate.daemon")
21
+
22
+ PING_INTERVAL = 25.0 # 秒
23
+
24
+
25
+ class CookieDaemon:
26
+ def __init__(self, port: int) -> None:
27
+ self.port = port
28
+ self._ext_ws: web.WebSocketResponse | None = None
29
+ self._last_cookies: list[dict[str, Any]] | None = None
30
+ self._last_cookie_at: float = 0.0
31
+ self._pending: asyncio.Future | None = None
32
+ self._lock = asyncio.Lock()
33
+
34
+ # ---- WebSocket(扩展连这里)----
35
+ async def ws_handler(self, request: web.Request) -> web.WebSocketResponse:
36
+ ws = web.WebSocketResponse()
37
+ await ws.prepare(request)
38
+ self._ext_ws = ws
39
+ log.info("扩展已连接")
40
+ async for msg in ws:
41
+ if msg.type == WSMsgType.TEXT:
42
+ try:
43
+ m = json.loads(msg.data)
44
+ except json.JSONDecodeError:
45
+ continue
46
+ await self._on_ext_message(m)
47
+ elif msg.type == WSMsgType.ERROR:
48
+ log.warning("扩展 WS 错误: %s", ws.exception())
49
+ self._ext_ws = None
50
+ log.info("扩展断开,等待重连")
51
+ return ws
52
+
53
+ async def _on_ext_message(self, m: dict[str, Any]) -> None:
54
+ t = m.get("type")
55
+ if t == "cookies":
56
+ self._last_cookies = m.get("data") or []
57
+ self._last_cookie_at = asyncio.get_event_loop().time()
58
+ if self._pending and not self._pending.done():
59
+ self._pending.set_result(self._last_cookies)
60
+ self._pending = None
61
+ elif t == "hello":
62
+ log.info("收到扩展 hello")
63
+ elif t == "pong":
64
+ pass
65
+ elif t == "error":
66
+ log.warning("扩展报错: %s", m.get("message"))
67
+ if self._pending and not self._pending.done():
68
+ self._pending.set_exception(RuntimeError(m.get("message", "扩展错误")))
69
+ self._pending = None
70
+
71
+ async def _ping_loop(self) -> None:
72
+ while True:
73
+ await asyncio.sleep(PING_INTERVAL)
74
+ if self._ext_ws is not None and not self._ext_ws.closed:
75
+ try:
76
+ await self._ext_ws.send_str(json.dumps({"type": "ping"}))
77
+ except Exception:
78
+ pass
79
+
80
+ # ---- HTTP(fetch/doctor 调)----
81
+ async def status_handler(self, request: web.Request) -> web.Response:
82
+ return web.json_response(
83
+ {
84
+ "extConnected": self._ext_ws is not None and not self._ext_ws.closed,
85
+ "cachedCookieCount": len(self._last_cookies) if self._last_cookies else 0,
86
+ "lastCookieAt": self._last_cookie_at or None,
87
+ }
88
+ )
89
+
90
+ async def cookies_handler(self, request: web.Request) -> web.Response:
91
+ try:
92
+ cookies = await self._fetch_cookies(timeout=10.0)
93
+ return web.json_response({"count": len(cookies), "data": cookies})
94
+ except Exception as e:
95
+ return web.json_response({"error": str(e)}, status=503)
96
+
97
+ async def _fetch_cookies(self, timeout: float = 10.0) -> list[dict[str, Any]]:
98
+ if self._ext_ws is None or self._ext_ws.closed:
99
+ raise RuntimeError("扩展未连接(请确认日常浏览器已加载 research-assistant 扩展且 daemon 在线)")
100
+ async with self._lock:
101
+ if self._pending is not None:
102
+ raise RuntimeError("已有进行中的 cookie 请求")
103
+ loop = asyncio.get_event_loop()
104
+ self._pending = loop.create_future()
105
+ await self._ext_ws.send_str(json.dumps({"type": "getCookies"}))
106
+ try:
107
+ return await asyncio.wait_for(self._pending, timeout=timeout)
108
+ except asyncio.TimeoutError:
109
+ if not self._pending.done():
110
+ self._pending.set_exception(RuntimeError("扩展响应超时"))
111
+ self._pending = None
112
+ raise
113
+
114
+ def build_app(self) -> web.Application:
115
+ app = web.Application()
116
+ app.router.add_get("/ws", self.ws_handler)
117
+ app.router.add_get("/status", self.status_handler)
118
+ app.router.add_get("/cookies", self.cookies_handler)
119
+ # ping 循环用 aiohttp 规范的 async startup/cleanup 生命周期管理
120
+ app.on_startup.append(self._start_ping)
121
+ app.on_cleanup.append(self._stop_ping)
122
+ return app
123
+
124
+ async def _start_ping(self, app: web.Application) -> None:
125
+ self._ping_task = asyncio.create_task(self._ping_loop())
126
+
127
+ async def _stop_ping(self, app: web.Application) -> None:
128
+ task = getattr(self, "_ping_task", None)
129
+ if task:
130
+ task.cancel()
131
+ try:
132
+ await task
133
+ except asyncio.CancelledError:
134
+ pass
135
+
136
+
137
+ def run(port: int) -> None:
138
+ """启动 daemon(前台运行;由 daemonctl.ensure_daemon 以子进程拉起)。"""
139
+ logging.basicConfig(
140
+ level=logging.INFO,
141
+ format="%(asctime)s [daemon] %(message)s",
142
+ stream=sys.stderr,
143
+ )
144
+ daemon = CookieDaemon(port=port)
145
+ web.run_app(daemon.build_app(), host="127.0.0.1", port=port, print=lambda *a: None)
@@ -0,0 +1,21 @@
1
+ """cookie daemon 的模块入口:python -m research_assistant.loginstate.daemon_cli <port>"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .daemon import run
8
+
9
+
10
+ def main() -> None:
11
+ port = 17890
12
+ if len(sys.argv) > 1:
13
+ try:
14
+ port = int(sys.argv[1])
15
+ except ValueError:
16
+ pass
17
+ run(port)
18
+
19
+
20
+ if __name__ == "__main__":
21
+ main()
@@ -0,0 +1,116 @@
1
+ """登录态层:cookie daemon 控制 + cookie 获取(ADR-0006)。
2
+
3
+ - ensure_daemon(config): 若 daemon 未运行则以子进程拉起(与 fetch 同生命周期足够;这里是按需拉起)。
4
+ - daemon_status(config): GET /status。
5
+ - get_cookies(config): GET /cookies(若 daemon 未运行会自动拉起;扩展未连则抛错)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import sys
13
+ from typing import Any
14
+
15
+ import aiohttp
16
+
17
+ from .. import config as config_mod
18
+ from . import daemon as daemon_mod
19
+
20
+
21
+ def _base_url(config: Any) -> str:
22
+ port = config.browser.daemon_port
23
+ return f"http://127.0.0.1:{port}"
24
+
25
+
26
+ async def _http_get(config: Any, path: str, timeout: float = 3.0) -> dict[str, Any] | None:
27
+ try:
28
+ async with aiohttp.ClientSession() as session:
29
+ async with session.get(f"{_base_url(config)}{path}", timeout=aiohttp.ClientTimeout(total=timeout)) as resp:
30
+ if resp.status == 200:
31
+ return await resp.json()
32
+ return None
33
+ except Exception:
34
+ return None
35
+
36
+
37
+ async def daemon_status(config: Any) -> dict[str, Any] | None:
38
+ return await _http_get(config, "/status")
39
+
40
+
41
+ async def ensure_daemon(config: Any) -> bool:
42
+ """确保 daemon 在线;若离线则后台拉起子进程。返回最终是否在线。"""
43
+ status = await daemon_status(config)
44
+ if status is not None:
45
+ return True
46
+ _spawn_daemon(config)
47
+ # 等待启动
48
+ for _ in range(20):
49
+ await asyncio.sleep(0.3)
50
+ if await daemon_status(config) is not None:
51
+ return True
52
+ return False
53
+
54
+
55
+ def _spawn_daemon(config: Any) -> None:
56
+ """以 detached 子进程启动 daemon(out/err 落 .logs/)。"""
57
+ logs_dir = config_mod.logs_dir()
58
+ logs_dir.mkdir(parents=True, exist_ok=True)
59
+ log_file = logs_dir / "daemon.log"
60
+ port = str(config.browser.daemon_port)
61
+ # sys.executable + 模块入口
62
+ import subprocess
63
+
64
+ with log_file.open("a", encoding="utf-8") as f:
65
+ creationflags = 0
66
+ if sys.platform.startswith("win"):
67
+ creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
68
+ subprocess.Popen(
69
+ [sys.executable, "-m", "research_assistant.loginstate.daemon_cli", port],
70
+ stdout=f,
71
+ stderr=subprocess.STDOUT,
72
+ stdin=subprocess.DEVNULL,
73
+ close_fds=True,
74
+ creationflags=creationflags,
75
+ )
76
+
77
+
78
+ async def get_cookies(config: Any, *, ensure: bool = True, timeout: float = 12.0) -> list[dict[str, Any]]:
79
+ """从 daemon 取明文 cookie 列表(扩展推送的)。"""
80
+ if ensure:
81
+ if not await ensure_daemon(config):
82
+ raise RuntimeError("cookie daemon 无法启动")
83
+ try:
84
+ async with aiohttp.ClientSession() as session:
85
+ async with session.get(
86
+ f"{_base_url(config)}/cookies", timeout=aiohttp.ClientTimeout(total=timeout)
87
+ ) as resp:
88
+ if resp.status != 200:
89
+ body = await resp.text()
90
+ raise RuntimeError(f"daemon /cookies 返回 {resp.status}: {body}")
91
+ data = await resp.json()
92
+ return data.get("data") or []
93
+ except aiohttp.ClientConnectorError as e:
94
+ raise RuntimeError(f"cookie daemon 不可达({e})") from e
95
+
96
+
97
+ def to_playwright_cookies(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
98
+ """把 chrome.cookies Cookie → Playwright add_cookies 格式。"""
99
+ same_map = {"strict": "Strict", "lax": "Lax", "no_restriction": "None", "none": "None", "unspecified": "Lax"}
100
+ out: list[dict[str, Any]] = []
101
+ for c in cookies:
102
+ item: dict[str, Any] = {
103
+ "name": c.get("name", ""),
104
+ "value": c.get("value", ""),
105
+ "domain": c.get("domain", ""),
106
+ "path": c.get("path") or "/",
107
+ "httpOnly": bool(c.get("httpOnly")),
108
+ "secure": bool(c.get("secure")),
109
+ }
110
+ if c.get("expirationDate"):
111
+ item["expires"] = float(c["expirationDate"])
112
+ ss = c.get("sameSite")
113
+ if ss and same_map.get(str(ss).lower()):
114
+ item["sameSite"] = same_map[str(ss).lower()]
115
+ out.append(item)
116
+ return out
@@ -0,0 +1,167 @@
1
+ // Research Assistant Bridge — background service worker (MV3)
2
+ // 架构(搬自 cdt):扩展主动连本地 daemon 保持持久长连接,被动响应 daemon 的 getCookies/ping。
3
+ // 保活:daemon 25s ping → onmessage 重置 30s 不活动计时器;chrome.alarms 30s 兜底重连。
4
+ // 去噪:重连指数退避(1→60s 封顶),只在状态变化时记日志。
5
+ // 端口:从 chrome.storage.local 读 ra_port(默认 17890),可在 popup 改。
6
+
7
+ const DEFAULT_PORT = 17890;
8
+ const ALARM = "ra-keepalive";
9
+ const MAX_LOGS = 30;
10
+
11
+ const state = {
12
+ status: "disconnected", // disconnected|connecting|connected
13
+ port: DEFAULT_PORT,
14
+ cookieCount: 0,
15
+ served: 0,
16
+ lastEvent: null,
17
+ logs: [],
18
+ };
19
+
20
+ let wsRef = null;
21
+ let backoff = 1000; // 重连退避 ms
22
+
23
+ function wsUrl() {
24
+ return `ws://127.0.0.1:${state.port}/ws`;
25
+ }
26
+
27
+ function persist() {
28
+ try {
29
+ chrome.storage.session.set({ state }).catch(() => {});
30
+ } catch {}
31
+ }
32
+
33
+ function log(text) {
34
+ const time = new Date().toLocaleTimeString();
35
+ state.logs.push(`${time} ${text}`);
36
+ if (state.logs.length > MAX_LOGS) state.logs.shift();
37
+ state.lastEvent = { time, text };
38
+ console.log("[ra-bridge]", text);
39
+ persist();
40
+ }
41
+
42
+ function setStatus(s) {
43
+ if (state.status !== s) {
44
+ state.status = s;
45
+ log("状态 → " + s);
46
+ }
47
+ persist();
48
+ }
49
+
50
+ async function loadPort() {
51
+ try {
52
+ const v = await chrome.storage.local.get("ra_port");
53
+ state.port = Number(v.ra_port) || DEFAULT_PORT;
54
+ } catch {
55
+ state.port = DEFAULT_PORT;
56
+ }
57
+ }
58
+
59
+ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
60
+ if (msg?.type === "getStatus") {
61
+ sendResponse({ ...state });
62
+ return false;
63
+ }
64
+ if (msg?.type === "setPort") {
65
+ const p = Number(msg.port);
66
+ if (p > 0 && p < 65536) {
67
+ chrome.storage.local.set({ ra_port: p });
68
+ state.port = p;
69
+ log("端口改为 " + p);
70
+ backoff = 1000;
71
+ if (wsRef) {
72
+ try { wsRef.close(); } catch {}
73
+ } else {
74
+ connect();
75
+ }
76
+ sendResponse({ ok: true, port: p });
77
+ } else {
78
+ sendResponse({ ok: false });
79
+ }
80
+ return false;
81
+ }
82
+ if (msg?.type === "reset") {
83
+ backoff = 1000;
84
+ log("手动重连");
85
+ if (!wsRef) connect();
86
+ sendResponse({ ok: true });
87
+ return false;
88
+ }
89
+ return false;
90
+ });
91
+
92
+ function connect() {
93
+ if (wsRef) return;
94
+ setStatus("connecting");
95
+ let ws;
96
+ try {
97
+ ws = new WebSocket(wsUrl());
98
+ wsRef = ws;
99
+ } catch (e) {
100
+ log("WebSocket 构造失败: " + (e?.message || e));
101
+ scheduleReconnect();
102
+ return;
103
+ }
104
+
105
+ ws.onopen = () => {
106
+ backoff = 1000;
107
+ setStatus("connected");
108
+ ws.send(JSON.stringify({ type: "hello" }));
109
+ };
110
+
111
+ ws.onmessage = async (event) => {
112
+ let m;
113
+ try {
114
+ m = JSON.parse(event.data);
115
+ } catch {
116
+ return;
117
+ }
118
+ if (m.type === "ping") {
119
+ ws.send(JSON.stringify({ type: "pong" }));
120
+ return;
121
+ }
122
+ if (m.type === "getCookies") {
123
+ try {
124
+ const cookies = await chrome.cookies.getAll({});
125
+ state.cookieCount = cookies.length;
126
+ state.served += 1;
127
+ persist();
128
+ ws.send(JSON.stringify({ type: "cookies", count: cookies.length, data: cookies }));
129
+ log(`响应 getCookies #${state.served}: ${cookies.length} cookie`);
130
+ } catch (e) {
131
+ ws.send(JSON.stringify({ type: "error", message: String(e) }));
132
+ log("读 cookie 报错: " + (e?.message || e));
133
+ }
134
+ }
135
+ };
136
+
137
+ ws.onerror = () => {};
138
+
139
+ ws.onclose = () => {
140
+ if (wsRef === ws) wsRef = null;
141
+ setStatus("disconnected");
142
+ scheduleReconnect();
143
+ };
144
+ }
145
+
146
+ function scheduleReconnect() {
147
+ if (wsRef) return;
148
+ const wait = backoff;
149
+ backoff = Math.min(backoff * 2, 60000);
150
+ if (wait <= 2000) log(`${wait}ms 后重连…`);
151
+ setTimeout(connect, wait);
152
+ }
153
+
154
+ chrome.alarms.onAlarm.addListener((a) => {
155
+ if (a.name !== ALARM) return;
156
+ if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
157
+ if (!wsRef) connect();
158
+ }
159
+ });
160
+
161
+ chrome.alarms.create(ALARM, { periodInMinutes: 0.5 });
162
+
163
+ (async () => {
164
+ await loadPort();
165
+ log("Research Assistant Bridge 启动 (端口 " + state.port + ")");
166
+ connect();
167
+ })();
@@ -0,0 +1,15 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Research Assistant Bridge",
4
+ "version": "0.1.0",
5
+ "description": "Expose this browser's cookies to the local research-assistant fetch tool (reads via chrome.cookies, sends over a local WebSocket).",
6
+ "permissions": ["cookies", "storage", "alarms"],
7
+ "host_permissions": ["<all_urls>"],
8
+ "background": {
9
+ "service_worker": "background.js"
10
+ },
11
+ "action": {
12
+ "default_popup": "popup.html",
13
+ "default_title": "Research Assistant Bridge"
14
+ }
15
+ }
@@ -0,0 +1,31 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <style>
6
+ body { font-family: system-ui, sans-serif; width: 320px; margin: 0; padding: 12px; font-size: 13px; }
7
+ h3 { margin: 0 0 8px; font-size: 14px; }
8
+ .row { margin: 6px 0; display: flex; gap: 6px; align-items: center; }
9
+ input[type=number] { width: 90px; }
10
+ button { cursor: pointer; padding: 4px 10px; }
11
+ pre { white-space: pre-wrap; max-height: 160px; overflow: auto; background: #f4f4f4; padding: 6px; border-radius: 4px; }
12
+ .status { font-weight: 600; }
13
+ .ok { color: #080; } .bad { color: #c00; } .warn { color: #c80; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <h3>Research Assistant Bridge</h3>
18
+ <div class="row">
19
+ <span>状态:</span><span id="status" class="status">—</span>
20
+ <span>cookie 数:</span><span id="count">—</span>
21
+ </div>
22
+ <div class="row">
23
+ <label>daemon 端口:</label>
24
+ <input id="port" type="number" min="1" max="65535" />
25
+ <button id="save">保存</button>
26
+ <button id="reset" title="强制重连">重连</button>
27
+ </div>
28
+ <pre id="logs"></pre>
29
+ <script src="popup.js"></script>
30
+ </body>
31
+ </html>
@@ -0,0 +1,33 @@
1
+ // popup:展示状态 + 改端口 + 强制重连
2
+ const $ = (id) => document.getElementById(id);
3
+
4
+ function clsFor(status) {
5
+ if (status === "connected") return "ok";
6
+ if (status === "connecting") return "warn";
7
+ return "bad";
8
+ }
9
+
10
+ async function refresh() {
11
+ const bg = chrome.runtime.getBackgroundPage
12
+ ? null
13
+ : await chrome.runtime.sendMessage({ type: "getStatus" });
14
+ const s = bg || { status: "unknown", cookieCount: 0, port: 17890, logs: [] };
15
+ $("status").textContent = s.status || "unknown";
16
+ $("status").className = "status " + clsFor(s.status);
17
+ $("count").textContent = s.cookieCount ?? 0;
18
+ $("port").value = s.port || 17890;
19
+ $("logs").textContent = (s.logs || []).join("\n");
20
+ }
21
+
22
+ $("save").addEventListener("click", async () => {
23
+ const port = Number($("port").value);
24
+ await chrome.runtime.sendMessage({ type: "setPort", port });
25
+ setTimeout(refresh, 300);
26
+ });
27
+
28
+ $("reset").addEventListener("click", async () => {
29
+ await chrome.runtime.sendMessage({ type: "reset" });
30
+ setTimeout(refresh, 300);
31
+ });
32
+
33
+ refresh();
@@ -0,0 +1,140 @@
1
+ """输出渲染(横切约定 #5,api-contract.md §3/§4)。
2
+
3
+ stdout 默认 JSON(供脚本解析);--output markdown 给人/AI 可读的 markdown。
4
+ 错误:stdout 仍出 JSON `{error:{...}}`(供脚本解析),stderr 同时出一行人类可读提示。
5
+
6
+ 格式而非受众:json 是结构化格式、markdown 是可读格式,二者对举;
7
+ 不再用 "human" 这种指代受众的词(它其实就是 markdown)。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ from typing import Any, IO
15
+
16
+
17
+ def _safe_write(stream: IO[str], text: str) -> None:
18
+ """按流编码安全写出(Windows 控制台编码兜底)。"""
19
+ encoding = getattr(stream, "encoding", None) or "utf-8"
20
+ try:
21
+ text.encode(encoding)
22
+ except UnicodeEncodeError:
23
+ text = text.encode(encoding, errors="backslashreplace").decode(encoding)
24
+ stream.write(text)
25
+
26
+
27
+ def emit_json(data: Any) -> None:
28
+ payload = json.dumps(data, ensure_ascii=False, indent=2)
29
+ _safe_write(sys.stdout, payload)
30
+ if not payload.endswith("\n"):
31
+ _safe_write(sys.stdout, "\n")
32
+
33
+
34
+ def emit_markdown(data: Any) -> None:
35
+ _safe_write(sys.stdout, _markdown_render(data))
36
+ _safe_write(sys.stdout, "\n")
37
+
38
+
39
+ def emit(data: Any, fmt: str) -> None:
40
+ if fmt == "json":
41
+ emit_json(data)
42
+ else:
43
+ emit_markdown(data)
44
+
45
+
46
+ def emit_error(message: str) -> None:
47
+ """stderr 单行错误提示(plain text,不参与 --output 格式选择)。"""
48
+ _safe_write(sys.stderr, f"error: {message}\n")
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # markdown 渲染(标准 markdown:列表用 -、加粗 **、引用 >、斜体 _)
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ def _truncate(text: Any, limit: int = 120) -> str:
57
+ s = "" if text is None else str(text)
58
+ s = " ".join(s.replace("\r", " ").replace("\n", " ").split())
59
+ if limit > 0 and len(s) > limit:
60
+ return s[: max(0, limit - 3)] + "..."
61
+ return s
62
+
63
+
64
+ def _markdown_render(data: Any, indent: int = 0) -> str:
65
+ pad = " " * indent
66
+ if isinstance(data, dict):
67
+ # 错误体:引用块形式
68
+ if "error" in data and isinstance(data["error"], dict):
69
+ err = data["error"]
70
+ line = f"{pad}> **[ERROR] {err.get('code', 'INTERNAL')}**: {err.get('message', '')}"
71
+ if err.get("provider"):
72
+ line += f" _(provider: {err['provider']})_"
73
+ return line
74
+ # 结果列表(results / data / anchors 等):先出元数据,再以列表渲染主体
75
+ for list_key in ("results", "data", "anchors", "contents", "checks", "targets"):
76
+ if list_key in data and isinstance(data[list_key], list):
77
+ return _render_list_markdown(data, list_key, indent)
78
+ # 普通 dict:每项一行 `- **key**: value`
79
+ lines: list[str] = []
80
+ for k, v in data.items():
81
+ if isinstance(v, (dict, list)) and v:
82
+ lines.append(f"{pad}- **{k}**:")
83
+ lines.append(_markdown_render(v, indent + 1))
84
+ else:
85
+ lines.append(f"{pad}- **{k}**: {_truncate(v, 200)}")
86
+ return "\n".join(lines)
87
+ if isinstance(data, list):
88
+ if not data:
89
+ return f"{pad}_(空)_"
90
+ lines: list[str] = []
91
+ for i, item in enumerate(data, 1):
92
+ if isinstance(item, dict):
93
+ title = item.get("title") or item.get("url") or item.get("id") or item.get("name") or ""
94
+ extra = item.get("url") if item.get("url") != title else ""
95
+ summary_keys = ("content", "text", "snippet", "description", "markdown")
96
+ summary = ""
97
+ for sk in summary_keys:
98
+ val = item.get(sk)
99
+ if val:
100
+ # 摘要/正文不截断:长度由源决定(搜索引擎/浏览器给多长就多长),
101
+ # 只合并空白(含换行)避免破坏 markdown,不丢内容
102
+ summary = " ".join(str(val).split())
103
+ break
104
+ head = f"{pad}{i}. **{title}**" if title else f"{pad}{i}."
105
+ if extra and extra != title:
106
+ head += f" — `{extra}`"
107
+ lines.append(head)
108
+ if summary:
109
+ lines.append(f"{pad} {summary}")
110
+ # 次要字段(子列表项)
111
+ for k, v in item.items():
112
+ if k in ("title", "url", "id", "name", "content", "text", "snippet", "description", "markdown"):
113
+ continue
114
+ if isinstance(v, (dict, list)):
115
+ continue
116
+ if v in (None, "", 0, 0.0):
117
+ continue
118
+ lines.append(f"{pad} - {k}: {_truncate(v, 80)}")
119
+ else:
120
+ lines.append(f"{pad}{i}. {item}")
121
+ return "\n".join(lines)
122
+ return f"{pad}{_truncate(data, 400)}"
123
+
124
+
125
+ def _render_list_markdown(data: dict[str, Any], list_key: str, indent: int) -> str:
126
+ pad = " " * indent
127
+ lines: list[str] = []
128
+ # 先渲染标量元数据字段
129
+ for k, v in data.items():
130
+ if k == list_key:
131
+ continue
132
+ if isinstance(v, (dict, list)):
133
+ continue
134
+ lines.append(f"{pad}- **{k}**: {_truncate(v, 160)}")
135
+ items = data[list_key]
136
+ if lines:
137
+ lines.append("")
138
+ lines.append(f"{pad}**{list_key}** ({len(items)}):")
139
+ lines.append(_markdown_render(items, indent + 1))
140
+ return "\n".join(lines)
@@ -0,0 +1,10 @@
1
+ """providers 包:各搜索/文档 provider 的自写 HTTP 客户端(ADR-0010)。
2
+
3
+ 加 provider = 在本包新增一个 .py 文件(类用 @register 装饰)+ 1 条 config。
4
+ registry.discover() 会自动发现,CLI 据此动态生成命令——无需改本文件之外的东西。
5
+ """
6
+
7
+ from .base import ArgSpec, Capability, Provider
8
+ from .registry import all_provider_classes, register, resolve_type
9
+
10
+ __all__ = ["ArgSpec", "Capability", "Provider", "all_provider_classes", "register", "resolve_type"]