@dsh-plus/web-search-services 0.1.0 → 0.1.2

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/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { WebError } from "@deepseek-ai/dsh-web";
4
5
  import { homedir } from "node:os";
5
6
  import { join } from "node:path";
@@ -16,7 +17,6 @@ import { spawn } from "node:child_process";
16
17
  * @module web-search-services/config
17
18
  */
18
19
  const SETTINGS_NS = "dsh-plus-web-search-services";
19
- const DEFAULT_SCRIPT_PATH = "~/.dsh/skills/search-services/scripts/search.py";
20
20
  const DEFAULT_ENV_FILE = "~/.config/search-services/env";
21
21
  const DEFAULT_TIMEOUT_MS = 55e3;
22
22
  const DEFAULT_PRIORITY = [
@@ -37,7 +37,7 @@ const KeysSchema = z.object({
37
37
  openaiModel: z.string().description("OpenAI-compatible 搜索模型名(覆盖 SEARCH_OPENAI_MODEL)").default("")
38
38
  });
39
39
  const Config = z.object({
40
- scriptPath: z.string().description("search-services skill 的 search.py 路径(支持 ~ 展开)").default(DEFAULT_SCRIPT_PATH),
40
+ scriptPath: z.string().description("search.py 路径(支持 ~ 展开);空串 = 使用随包内置脚本副本").default(""),
41
41
  envFile: z.string().description("skill 密钥 env 文件路径(支持 ~ 展开);空串表示不读 envFile").default(DEFAULT_ENV_FILE),
42
42
  python: z.string().description("python 可执行文件").default("python3"),
43
43
  priority: z.array(PRIORITY_ENTRY).description("后端优先级:靠前的先尝试,失败自动回退到下一个").default([...DEFAULT_PRIORITY]),
@@ -126,6 +126,12 @@ function makeSource(url, title, snippet, publishedAt) {
126
126
  ...publishedAt !== void 0 ? { publishedAt } : {}
127
127
  };
128
128
  }
129
+ /** skill 各服务 request() 统一信封 {ok, service, status, data: <API 响应>};
130
+ * 取 data 为业务 payload,data 缺失/空对象时回退顶层(旧形状容差)。 */
131
+ function payloadOf(raw) {
132
+ const data = asObject(raw.data);
133
+ return Object.keys(data).length > 0 ? data : raw;
134
+ }
129
135
  function finish(content, sources) {
130
136
  const seen = /* @__PURE__ */ new Set();
131
137
  const deduped = sources.filter((source) => {
@@ -148,15 +154,16 @@ function sourcesFromResults(results, pick) {
148
154
  return out;
149
155
  }
150
156
  function normalizeTavily(raw) {
151
- const sources = sourcesFromResults(raw.results, (item) => {
157
+ const payload = payloadOf(raw);
158
+ const sources = sourcesFromResults(payload.results, (item) => {
152
159
  const url = asNonEmptyString(item.url);
153
160
  if (url === void 0) return void 0;
154
161
  return makeSource(url, asNonEmptyString(item.title), asNonEmptyString(item.content), asNonEmptyString(item.published_date));
155
162
  });
156
- return finish(asNonEmptyString(raw.answer), sources);
163
+ return finish(asNonEmptyString(payload.answer), sources);
157
164
  }
158
165
  function normalizeExa(raw) {
159
- return finish(void 0, sourcesFromResults(raw.results, (item) => {
166
+ return finish(void 0, sourcesFromResults(payloadOf(raw).results, (item) => {
160
167
  const url = asNonEmptyString(item.url) ?? asNonEmptyString(item.id);
161
168
  if (url === void 0) return void 0;
162
169
  const highlights = asArray(item.highlights).map((h) => asNonEmptyString(h)).filter((h) => h !== void 0);
@@ -308,6 +315,14 @@ function runProcess(opts) {
308
315
  * @module web-search-services/provider
309
316
  */
310
317
  const PROVIDER_ID = "search-services";
318
+ /**
319
+ * scriptPath 解析:空串 = 随包内置脚本副本(tgz 内 scripts/search.py,与 lib/
320
+ * 同级;src 直跑时为包根 scripts/)。非空按 ~ 展开。
321
+ */
322
+ function resolveScriptPath(scriptPath) {
323
+ if (scriptPath.trim() !== "") return expandHome(scriptPath);
324
+ return fileURLToPath(new URL("../scripts/search.py", import.meta.url));
325
+ }
311
326
  /** 各后端在合并环境(进程 env ⊕ envFile ⊕ 行级 keys)中的可用性判定变量。 */
312
327
  const BACKEND_KEY_VARS = {
313
328
  tavily: ["TAVILY_API_KEY", "TAVILY_API_KEYS"],
@@ -343,7 +358,7 @@ var SearchServicesProvider = class {
343
358
  /** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
344
359
  available() {
345
360
  const cfg = this.current();
346
- if (!existsSync(expandHome(cfg.scriptPath))) return false;
361
+ if (!existsSync(resolveScriptPath(cfg.scriptPath))) return false;
347
362
  const env = this.childEnv(cfg);
348
363
  return cfg.priority.some((backend) => BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? "") !== ""));
349
364
  }
@@ -365,7 +380,7 @@ var SearchServicesProvider = class {
365
380
  try {
366
381
  return await runProcess({
367
382
  command: cfg.python,
368
- args: [expandHome(cfg.scriptPath), ...argv],
383
+ args: [resolveScriptPath(cfg.scriptPath), ...argv],
369
384
  env: this.childEnv(cfg),
370
385
  timeoutMs: cfg.timeoutMs,
371
386
  ...signal !== void 0 ? { signal } : {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/web-search-services",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "dsh-plus service plugin: web_search 免费后端聚合——经官方 ctx.web provider 扩展点桥接 search-services skill(Tavily/Exa/OpenAI Chat 免费额度,优先级+自动回退+key 配置),官方 web_search 工具与输出格式零修改",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -15,7 +15,8 @@
15
15
  },
16
16
  "files": [
17
17
  "lib",
18
- "src"
18
+ "src",
19
+ "scripts"
19
20
  ],
20
21
  "peerDependencies": {
21
22
  "@deepseek-ai/cordis": "^4.0.2",
@@ -0,0 +1,34 @@
1
+ # scripts/:search-services skill Python 脚本的项目维护副本
2
+
3
+ 本目录是从 `~/.dsh/skills/search-services/scripts/` **原样复制**的单向副本
4
+ (标准库实现,无第三方依赖),供 `@dsh-plus/web-search-services` 插件经
5
+ `python3 search.py search ...` 子进程桥接调用。**skill 本体不受本仓维护影响。**
6
+
7
+ ## 为什么是 Python 桥接(特例声明)
8
+
9
+ > **本插件是特例,不是先例。** dsh-plus 集成外部 HTTP 服务(provider、工具、
10
+ > webhook 等)**默认直接用 TS 原生实现**(进程内 fetch、编译期契约、无进程边界)。
11
+ > 仅当需求明确要求复用一套已存在、持续维护、且逻辑非平凡的外部编排
12
+ > (本例:search-services skill 的链内 fallback、`TAVILY_API_KEYS` 多 key
13
+ > 轮转、intent 服务链、per-service 参数细节)时,才允许整体桥接其脚本,
14
+ > 把外部实现当作单一事实源,本仓只维护「dsh-web 契约 ↔ CLI」薄适配层。
15
+
16
+ 判定标准(新增集成时自检):
17
+
18
+ 1. 外部逻辑是否已在维护中且有独立演进(skill/CLI 工具)?——否则 TS 原生重写更便宜。
19
+ 2. 桥接节省的是否是**编排逻辑**而非一个 POST 请求?——单接口直调一律 TS 原生。
20
+ 3. 失败域、超时、密钥注入是否都能在薄适配层兜住?——兜不住则不桥接。
21
+
22
+ ## 同步策略
23
+
24
+ - **单向**:skill → 本目录。skill 升级后人工比对同步(diff 后整体覆盖),
25
+ 不做自动同步、不做双向回写;本目录的定向修改(如有)须注释 `dsh-plus:`
26
+ 标记并记录在下表。
27
+ - 同步时复核文件清单:`search.py fetch.py fetch_playwright.py env_loader.py
28
+ openai_chat.py context7.py exa.py tavily.py`(`__pycache__` 不入仓)。
29
+
30
+ ## 本仓定向修改记录
31
+
32
+ | 日期 | 文件 | 修改 | 原因 |
33
+ |---|---|---|---|
34
+ | — | — | 暂无(verbatim 副本) | — |
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ """Context7 REST API helper.
3
+
4
+ 不使用 MCP,仅通过 Context7 HTTP API 获取库文档、代码片段和相关上下文。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import sys
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ from typing import Any, Dict, Optional
17
+
18
+ from env_loader import load_env_file
19
+
20
+ load_env_file()
21
+
22
+
23
+ class ServiceError(RuntimeError):
24
+ """服务调用失败。"""
25
+
26
+ def __init__(self, message: str, *, status: Optional[int] = None, details: Any = None):
27
+ super().__init__(message)
28
+ self.status = status
29
+ self.details = details
30
+
31
+
32
+ class MissingAPIKey(ServiceError):
33
+ """缺少 API Key。"""
34
+
35
+
36
+ DEFAULT_BASE_URL = "https://context7.com/api"
37
+
38
+
39
+ def _timeout() -> float:
40
+ return float(os.getenv("SEARCH_TIMEOUT") or os.getenv("CONTEXT7_TIMEOUT") or "30")
41
+
42
+
43
+ def _base_url() -> str:
44
+ return (os.getenv("CONTEXT7_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
45
+
46
+
47
+ def _api_key() -> str:
48
+ key = os.getenv("CONTEXT7_API_KEY", "").strip()
49
+ if not key:
50
+ raise MissingAPIKey("缺少 CONTEXT7_API_KEY")
51
+ return key
52
+
53
+
54
+ def _decode_response(raw: bytes) -> Any:
55
+ text = raw.decode("utf-8", "replace")
56
+ try:
57
+ return json.loads(text)
58
+ except json.JSONDecodeError:
59
+ return text
60
+
61
+
62
+ def request(
63
+ method: str,
64
+ path: str,
65
+ *,
66
+ params: Optional[Dict[str, Any]] = None,
67
+ body: Optional[Dict[str, Any]] = None,
68
+ ) -> Dict[str, Any]:
69
+ """调用 Context7 API,返回带元信息的 JSON。"""
70
+
71
+ query = urllib.parse.urlencode({k: v for k, v in (params or {}).items() if v is not None})
72
+ url = f"{_base_url()}{path}"
73
+ if query:
74
+ url = f"{url}?{query}"
75
+
76
+ headers = {
77
+ "Authorization": f"Bearer {_api_key()}",
78
+ "Accept": "application/json",
79
+ "User-Agent": "pi-search-services-skill/1.0",
80
+ }
81
+ data = None
82
+ if body is not None:
83
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
84
+ headers["Content-Type"] = "application/json"
85
+
86
+ req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
87
+ try:
88
+ with urllib.request.urlopen(req, timeout=_timeout()) as resp:
89
+ parsed = _decode_response(resp.read())
90
+ return {"ok": True, "service": "context7", "status": resp.status, "data": parsed}
91
+ except urllib.error.HTTPError as exc:
92
+ details = _decode_response(exc.read())
93
+ raise ServiceError(f"Context7 HTTP {exc.code}", status=exc.code, details=details) from exc
94
+ except urllib.error.URLError as exc:
95
+ raise ServiceError(f"Context7 网络错误: {exc.reason}", details=str(exc)) from exc
96
+
97
+
98
+ def search_libraries(library_name: str, query: Optional[str] = None, fast: bool = False) -> Dict[str, Any]:
99
+ """搜索 Context7 库 ID。"""
100
+
101
+ return request(
102
+ "GET",
103
+ "/v2/libs/search",
104
+ params={"libraryName": library_name, "query": query, "fast": str(fast).lower() if fast else None},
105
+ )
106
+
107
+
108
+ def get_context(
109
+ library_id: str,
110
+ query: str,
111
+ *,
112
+ response_type: str = "json",
113
+ fast: bool = False,
114
+ ) -> Dict[str, Any]:
115
+ """获取某个库的文档上下文。"""
116
+
117
+ return request(
118
+ "GET",
119
+ "/v2/context",
120
+ params={
121
+ "libraryId": library_id,
122
+ "query": query,
123
+ "type": response_type,
124
+ "fast": str(fast).lower() if fast else None,
125
+ },
126
+ )
127
+
128
+
129
+ def lookup(
130
+ library: str,
131
+ query: str,
132
+ *,
133
+ response_type: str = "json",
134
+ fast: bool = False,
135
+ ) -> Dict[str, Any]:
136
+ """先解析库 ID,再获取文档上下文。library 可为 /owner/repo 或库名。"""
137
+
138
+ if library.startswith("/"):
139
+ library_id = library
140
+ search_result = None
141
+ else:
142
+ search_result = search_libraries(library, query=query, fast=fast)
143
+ results = (search_result.get("data") or {}).get("results") or []
144
+ if not results:
145
+ raise ServiceError("Context7 未找到匹配库", status=404, details=search_result.get("data"))
146
+ library_id = results[0].get("id")
147
+ if not library_id:
148
+ raise ServiceError("Context7 搜索结果缺少 library id", details=results[0])
149
+
150
+ context = get_context(library_id, query, response_type=response_type, fast=fast)
151
+ context["resolvedLibraryId"] = library_id
152
+ if search_result is not None:
153
+ context["librarySearch"] = search_result.get("data")
154
+ return context
155
+
156
+
157
+ def refresh(library_name: str, *, branch: Optional[str] = None, git_token: Optional[str] = None) -> Dict[str, Any]:
158
+ body: Dict[str, Any] = {"libraryName": library_name}
159
+ if branch:
160
+ body["branch"] = branch
161
+ if git_token:
162
+ body["gitToken"] = git_token
163
+ return request("POST", "/v1/refresh", body=body)
164
+
165
+
166
+ def add_website(website_url: str) -> Dict[str, Any]:
167
+ return request("POST", "/v2/add/website", body={"websiteUrl": website_url})
168
+
169
+
170
+ def add_llmstxt(llmstxt_url: str) -> Dict[str, Any]:
171
+ return request("POST", "/v2/add/llmstxt", body={"llmstxtUrl": llmstxt_url})
172
+
173
+
174
+ def add_openapi(openapi_url: str) -> Dict[str, Any]:
175
+ return request("POST", "/v2/add/openapi", body={"openApiUrl": openapi_url})
176
+
177
+
178
+ def raw(method: str, path: str, *, params_json: Optional[str] = None, body_json: Optional[str] = None) -> Dict[str, Any]:
179
+ params = json.loads(params_json) if params_json else None
180
+ body = json.loads(body_json) if body_json else None
181
+ return request(method, path, params=params, body=body)
182
+
183
+
184
+ def _print_json(value: Any) -> None:
185
+ print(json.dumps(value, ensure_ascii=False, indent=2))
186
+
187
+
188
+ def main(argv: Optional[list[str]] = None) -> int:
189
+ parser = argparse.ArgumentParser(description="Context7 REST API helper")
190
+ sub = parser.add_subparsers(dest="operation", required=True)
191
+
192
+ p = sub.add_parser("search-libraries", help="搜索库 ID")
193
+ p.add_argument("--library", "--library-name", dest="library", required=True)
194
+ p.add_argument("--query")
195
+ p.add_argument("--fast", action="store_true")
196
+
197
+ p = sub.add_parser("context", help="获取文档上下文")
198
+ p.add_argument("--library-id", required=True)
199
+ p.add_argument("--query", required=True)
200
+ p.add_argument("--type", default="json")
201
+ p.add_argument("--fast", action="store_true")
202
+
203
+ p = sub.add_parser("lookup", help="按库名/库 ID 查询文档上下文")
204
+ p.add_argument("--library", required=True)
205
+ p.add_argument("--query", required=True)
206
+ p.add_argument("--type", default="json")
207
+ p.add_argument("--fast", action="store_true")
208
+
209
+ p = sub.add_parser("refresh", help="刷新库文档")
210
+ p.add_argument("--library-name", required=True)
211
+ p.add_argument("--branch")
212
+ p.add_argument("--git-token-env", help="从指定环境变量读取 gitToken")
213
+
214
+ p = sub.add_parser("add-website", help="提交网站文档源")
215
+ p.add_argument("--url", required=True)
216
+
217
+ p = sub.add_parser("add-llmstxt", help="提交 llms.txt 文档源")
218
+ p.add_argument("--url", required=True)
219
+
220
+ p = sub.add_parser("add-openapi", help="提交 OpenAPI 文档源")
221
+ p.add_argument("--url", required=True)
222
+
223
+ p = sub.add_parser("raw", help="原始 Context7 API 调用")
224
+ p.add_argument("--method", required=True)
225
+ p.add_argument("--path", required=True)
226
+ p.add_argument("--params-json")
227
+ p.add_argument("--body-json")
228
+
229
+ args = parser.parse_args(argv)
230
+ try:
231
+ if args.operation == "search-libraries":
232
+ out = search_libraries(args.library, query=args.query, fast=args.fast)
233
+ elif args.operation == "context":
234
+ out = get_context(args.library_id, args.query, response_type=args.type, fast=args.fast)
235
+ elif args.operation == "lookup":
236
+ out = lookup(args.library, args.query, response_type=args.type, fast=args.fast)
237
+ elif args.operation == "refresh":
238
+ git_token = os.getenv(args.git_token_env, "") if args.git_token_env else None
239
+ out = refresh(args.library_name, branch=args.branch, git_token=git_token)
240
+ elif args.operation == "add-website":
241
+ out = add_website(args.url)
242
+ elif args.operation == "add-llmstxt":
243
+ out = add_llmstxt(args.url)
244
+ elif args.operation == "add-openapi":
245
+ out = add_openapi(args.url)
246
+ elif args.operation == "raw":
247
+ out = raw(args.method, args.path, params_json=args.params_json, body_json=args.body_json)
248
+ else:
249
+ parser.error("未知操作")
250
+ return 2
251
+ _print_json(out)
252
+ return 0
253
+ except ServiceError as exc:
254
+ _print_json({"ok": False, "service": "context7", "error": str(exc), "status": exc.status, "details": exc.details})
255
+ return 1
256
+
257
+
258
+ if __name__ == "__main__":
259
+ sys.exit(main())
@@ -0,0 +1,46 @@
1
+ """Search Services skill 环境变量加载器。
2
+
3
+ 从用户私有 env 文件加载变量到当前 Python 进程环境,避免在脚本中硬编码密钥。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import re
10
+ from pathlib import Path
11
+
12
+
13
+ DEFAULT_ENV_FILE = Path.home() / ".config" / "search-services" / "env"
14
+
15
+
16
+ def _strip_shell_quotes(value: str) -> str:
17
+ value = value.strip()
18
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
19
+ value = value[1:-1]
20
+ return value
21
+
22
+
23
+ def load_env_file(path: str | None = None, *, override: bool = False) -> None:
24
+ """加载 export KEY=value 格式的私有 env 文件。
25
+
26
+ 默认不覆盖已存在的环境变量;可用 SEARCH_SERVICES_ENV_FILE 指定路径。
27
+ """
28
+
29
+ env_path = Path(path or os.getenv("SEARCH_SERVICES_ENV_FILE", "") or DEFAULT_ENV_FILE).expanduser()
30
+ if not env_path.is_file():
31
+ return
32
+
33
+ pattern = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
34
+ try:
35
+ for raw_line in env_path.read_text(encoding="utf-8").splitlines():
36
+ line = raw_line.strip()
37
+ if not line or line.startswith("#"):
38
+ continue
39
+ match = pattern.match(line)
40
+ if not match:
41
+ continue
42
+ key, value = match.group(1), _strip_shell_quotes(match.group(2))
43
+ if override or key not in os.environ:
44
+ os.environ[key] = value
45
+ except OSError:
46
+ return