@dsh-plus/web-search-services 0.1.20 → 0.2.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.
@@ -1,259 +0,0 @@
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())
@@ -1,46 +0,0 @@
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
package/scripts/exa.py DELETED
@@ -1,251 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Exa REST API helper.
3
-
4
- 不使用 MCP,仅通过 Exa 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.request
15
- from typing import Any, Dict, Optional
16
-
17
- from env_loader import load_env_file
18
-
19
- load_env_file()
20
-
21
-
22
- class ServiceError(RuntimeError):
23
- """服务调用失败。"""
24
-
25
- def __init__(self, message: str, *, status: Optional[int] = None, details: Any = None):
26
- super().__init__(message)
27
- self.status = status
28
- self.details = details
29
-
30
-
31
- class MissingAPIKey(ServiceError):
32
- """缺少 API Key。"""
33
-
34
-
35
- DEFAULT_BASE_URL = "https://api.exa.ai"
36
-
37
-
38
- def _timeout() -> float:
39
- return float(os.getenv("SEARCH_TIMEOUT") or os.getenv("EXA_TIMEOUT") or "30")
40
-
41
-
42
- def _base_url() -> str:
43
- return (os.getenv("EXA_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
44
-
45
-
46
- def _api_key() -> str:
47
- key = os.getenv("EXA_API_KEY", "").strip()
48
- if not key:
49
- raise MissingAPIKey("缺少 EXA_API_KEY")
50
- return key
51
-
52
-
53
- def _decode_response(raw: bytes) -> Any:
54
- text = raw.decode("utf-8", "replace")
55
- try:
56
- return json.loads(text)
57
- except json.JSONDecodeError:
58
- return text
59
-
60
-
61
- def request(path: str, body: Dict[str, Any], *, method: str = "POST", timeout: Optional[float] = None) -> Dict[str, Any]:
62
- url = f"{_base_url()}{path}"
63
- headers = {
64
- "x-api-key": _api_key(),
65
- "Content-Type": "application/json",
66
- "Accept": "application/json",
67
- "User-Agent": "pi-search-services-skill/1.0",
68
- }
69
- data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
70
- req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
71
- try:
72
- with urllib.request.urlopen(req, timeout=timeout if timeout is not None else _timeout()) as resp:
73
- parsed = _decode_response(resp.read())
74
- return {"ok": True, "service": "exa", "status": resp.status, "data": parsed}
75
- except urllib.error.HTTPError as exc:
76
- details = _decode_response(exc.read())
77
- raise ServiceError(f"Exa HTTP {exc.code}", status=exc.code, details=details) from exc
78
- except urllib.error.URLError as exc:
79
- raise ServiceError(f"Exa 网络错误: {exc.reason}", details=str(exc)) from exc
80
-
81
-
82
- def search(
83
- query: str,
84
- *,
85
- search_type: str = "auto",
86
- num_results: int = 10,
87
- category: Optional[str] = None,
88
- include_domains: Optional[list[str]] = None,
89
- exclude_domains: Optional[list[str]] = None,
90
- start_published_date: Optional[str] = None,
91
- end_published_date: Optional[str] = None,
92
- user_location: Optional[str] = None,
93
- contents: Optional[Dict[str, Any]] = None,
94
- output_schema: Optional[Dict[str, Any]] = None,
95
- system_prompt: Optional[str] = None,
96
- extra: Optional[Dict[str, Any]] = None,
97
- ) -> Dict[str, Any]:
98
- """调用 Exa /search。REST API 使用 camelCase 参数。"""
99
-
100
- body: Dict[str, Any] = {
101
- "query": query,
102
- "type": search_type,
103
- "numResults": num_results,
104
- }
105
- optional = {
106
- "category": category,
107
- "includeDomains": include_domains,
108
- "excludeDomains": exclude_domains,
109
- "startPublishedDate": start_published_date,
110
- "endPublishedDate": end_published_date,
111
- "userLocation": user_location,
112
- "contents": contents if contents is not None else {"highlights": True},
113
- "outputSchema": output_schema,
114
- "systemPrompt": system_prompt,
115
- }
116
- body.update({k: v for k, v in optional.items() if v is not None})
117
- if extra:
118
- body.update(extra)
119
- return request("/search", body)
120
-
121
-
122
- def contents(
123
- ids: list[str],
124
- *,
125
- text: Any = True,
126
- highlights: Any = None,
127
- summary: Any = None,
128
- extra: Optional[Dict[str, Any]] = None,
129
- timeout: Optional[float] = None,
130
- ) -> Dict[str, Any]:
131
- """调用 Exa /contents。
132
-
133
- Exa 搜索结果 id 通常就是 URL。/contents 与 /search 不同:text/highlights/summary 是顶层参数。
134
- """
135
-
136
- body: Dict[str, Any] = {"ids": ids}
137
- if text is not None:
138
- body["text"] = text
139
- if highlights is not None:
140
- body["highlights"] = highlights
141
- if summary is not None:
142
- body["summary"] = summary
143
- if extra:
144
- body.update(extra)
145
- return request("/contents", body, timeout=timeout)
146
-
147
-
148
- def raw(path: str, *, body_json: str, method: str = "POST") -> Dict[str, Any]:
149
- return request(path, json.loads(body_json), method=method)
150
-
151
-
152
- def _split_csv(value: Optional[str]) -> Optional[list[str]]:
153
- if not value:
154
- return None
155
- return [part.strip() for part in value.split(",") if part.strip()]
156
-
157
-
158
- def _json_or_none(value: Optional[str]) -> Optional[Dict[str, Any]]:
159
- if not value:
160
- return None
161
- return json.loads(value)
162
-
163
-
164
- def _print_json(value: Any) -> None:
165
- print(json.dumps(value, ensure_ascii=False, indent=2))
166
-
167
-
168
- def main(argv: Optional[list[str]] = None) -> int:
169
- parser = argparse.ArgumentParser(description="Exa REST API helper")
170
- sub = parser.add_subparsers(dest="operation", required=True)
171
-
172
- p = sub.add_parser("search", help="Exa 语义搜索")
173
- p.add_argument("--query", required=True)
174
- p.add_argument("--type", default="auto", choices=["auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"])
175
- p.add_argument("--num-results", type=int, default=10)
176
- p.add_argument("--category")
177
- p.add_argument("--include-domains", help="逗号分隔")
178
- p.add_argument("--exclude-domains", help="逗号分隔")
179
- p.add_argument("--start-published-date")
180
- p.add_argument("--end-published-date")
181
- p.add_argument("--user-location")
182
- p.add_argument("--contents-json", help="完整 contents JSON;默认 {\"highlights\": true}")
183
- p.add_argument("--text", action="store_true", help="请求 contents.text")
184
- p.add_argument("--summary", action="store_true", help="请求 contents.summary")
185
- p.add_argument("--fresh", action="store_true", help="设置 contents.maxAgeHours=0 强制实时抓取")
186
- p.add_argument("--output-schema-json")
187
- p.add_argument("--system-prompt")
188
- p.add_argument("--extra-json", help="合并到请求体的额外 JSON")
189
-
190
- p = sub.add_parser("contents", help="按 URL/ID 获取内容")
191
- p.add_argument("--ids", required=True, help="逗号分隔 URL 或 Exa result id")
192
- p.add_argument("--text-json", help="text 顶层参数 JSON;未提供则为 true")
193
- p.add_argument("--highlights-json")
194
- p.add_argument("--summary-json")
195
- p.add_argument("--extra-json")
196
-
197
- p = sub.add_parser("raw", help="原始 Exa API 调用")
198
- p.add_argument("--path", required=True)
199
- p.add_argument("--method", default="POST")
200
- p.add_argument("--body-json", required=True)
201
-
202
- args = parser.parse_args(argv)
203
- try:
204
- if args.operation == "search":
205
- contents_value = _json_or_none(args.contents_json)
206
- if contents_value is None:
207
- contents_value = {"highlights": True}
208
- if args.text:
209
- contents_value["text"] = {"maxCharacters": 8000}
210
- if args.summary:
211
- contents_value["summary"] = True
212
- if args.fresh:
213
- contents_value["maxAgeHours"] = 0
214
- out = search(
215
- args.query,
216
- search_type=args.type,
217
- num_results=args.num_results,
218
- category=args.category,
219
- include_domains=_split_csv(args.include_domains),
220
- exclude_domains=_split_csv(args.exclude_domains),
221
- start_published_date=args.start_published_date,
222
- end_published_date=args.end_published_date,
223
- user_location=args.user_location,
224
- contents=contents_value,
225
- output_schema=_json_or_none(args.output_schema_json),
226
- system_prompt=args.system_prompt,
227
- extra=_json_or_none(args.extra_json),
228
- )
229
- elif args.operation == "contents":
230
- text_value = json.loads(args.text_json) if args.text_json else True
231
- out = contents(
232
- _split_csv(args.ids) or [],
233
- text=text_value,
234
- highlights=json.loads(args.highlights_json) if args.highlights_json else None,
235
- summary=json.loads(args.summary_json) if args.summary_json else None,
236
- extra=_json_or_none(args.extra_json),
237
- )
238
- elif args.operation == "raw":
239
- out = raw(args.path, body_json=args.body_json, method=args.method)
240
- else:
241
- parser.error("未知操作")
242
- return 2
243
- _print_json(out)
244
- return 0
245
- except ServiceError as exc:
246
- _print_json({"ok": False, "service": "exa", "error": str(exc), "status": exc.status, "details": exc.details})
247
- return 1
248
-
249
-
250
- if __name__ == "__main__":
251
- sys.exit(main())