@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 +22 -7
- package/package.json +3 -2
- package/scripts/README.md +34 -0
- package/scripts/context7.py +259 -0
- package/scripts/env_loader.py +46 -0
- package/scripts/exa.py +251 -0
- package/scripts/fetch.py +938 -0
- package/scripts/fetch_playwright.py +492 -0
- package/scripts/openai_chat.py +570 -0
- package/scripts/search.py +962 -0
- package/scripts/tavily.py +609 -0
- package/src/config.ts +3 -2
- package/src/normalize.ts +16 -6
- package/src/provider.ts +13 -2
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())
|