@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/scripts/exa.py ADDED
@@ -0,0 +1,251 @@
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())