@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.
@@ -0,0 +1,609 @@
1
+ #!/usr/bin/env python3
2
+ """Tavily REST API helper.
3
+
4
+ 不使用 MCP,仅通过 Tavily HTTP API 执行搜索、提取、爬取、映射和研究任务。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ from typing import Any, Dict, Optional
18
+
19
+ from env_loader import load_env_file
20
+
21
+ load_env_file()
22
+
23
+
24
+ class ServiceError(RuntimeError):
25
+ """服务调用失败。"""
26
+
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ *,
31
+ status: Optional[int] = None,
32
+ details: Any = None,
33
+ key_attempts: Optional[list[Dict[str, Any]]] = None,
34
+ ):
35
+ super().__init__(message)
36
+ self.status = status
37
+ self.details = details
38
+ self.key_attempts = key_attempts or []
39
+
40
+
41
+ class MissingAPIKey(ServiceError):
42
+ """缺少 API Key。"""
43
+
44
+
45
+ DEFAULT_BASE_URL = "https://api.tavily.com"
46
+
47
+
48
+ def _timeout() -> float:
49
+ return float(os.getenv("SEARCH_TIMEOUT") or os.getenv("TAVILY_TIMEOUT") or "60")
50
+
51
+
52
+ def _base_url() -> str:
53
+ return (os.getenv("TAVILY_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
54
+
55
+
56
+ def _split_key_list(value: str) -> list[str]:
57
+ """解析逗号/分号/换行分隔的 key 列表,保持顺序。"""
58
+
59
+ return [part.strip() for part in re.split(r"[,;\n]+", value or "") if part.strip()]
60
+
61
+
62
+ def _api_keys() -> list[tuple[str, str]]:
63
+ """返回按优先级排序的 Tavily key。
64
+
65
+ 优先级:
66
+ 1. TAVILY_API_KEYS 中的显式顺序
67
+ 2. TAVILY_API_KEY_0、TAVILY_API_KEY_1 ... 的数字顺序
68
+ 3. TAVILY_API_KEY 兼容单 key 配置
69
+ """
70
+
71
+ candidates: list[tuple[str, str]] = []
72
+
73
+ for index, key in enumerate(_split_key_list(os.getenv("TAVILY_API_KEYS", "")), start=1):
74
+ candidates.append((f"TAVILY_API_KEYS[{index}]", key))
75
+
76
+ numbered: list[tuple[int, str, str]] = []
77
+ for name, value in os.environ.items():
78
+ match = re.fullmatch(r"TAVILY_API_KEY_(\d+)", name)
79
+ if match and value.strip():
80
+ numbered.append((int(match.group(1)), name, value.strip()))
81
+ for _, name, key in sorted(numbered):
82
+ candidates.append((name, key))
83
+
84
+ legacy_key = os.getenv("TAVILY_API_KEY", "").strip()
85
+ if legacy_key:
86
+ candidates.append(("TAVILY_API_KEY", legacy_key))
87
+
88
+ deduped: list[tuple[str, str]] = []
89
+ seen: set[str] = set()
90
+ for label, key in candidates:
91
+ if key in seen:
92
+ continue
93
+ seen.add(key)
94
+ deduped.append((label, key))
95
+
96
+ if not deduped:
97
+ raise MissingAPIKey("缺少 Tavily API key:请设置 TAVILY_API_KEYS、TAVILY_API_KEY_0... 或 TAVILY_API_KEY")
98
+ return deduped
99
+
100
+
101
+ def _redact_value(value: Any, keys: list[tuple[str, str]]) -> Any:
102
+ """递归脱敏,避免错误详情中意外回显 key。"""
103
+
104
+ if isinstance(value, str):
105
+ redacted = value
106
+ for _, key in keys:
107
+ if key:
108
+ redacted = redacted.replace(key, "<redacted-tavily-key>")
109
+ return redacted
110
+ if isinstance(value, list):
111
+ return [_redact_value(item, keys) for item in value]
112
+ if isinstance(value, dict):
113
+ return {k: _redact_value(v, keys) for k, v in value.items()}
114
+ return value
115
+
116
+
117
+ def _should_try_next_key(status: Optional[int]) -> bool:
118
+ """这些状态通常与 key、额度或限流相关,可尝试下一个 key。"""
119
+
120
+ return status in {401, 402, 429, 432, 433}
121
+
122
+
123
+ def _decode_response(raw: bytes) -> Any:
124
+ text = raw.decode("utf-8", "replace")
125
+ try:
126
+ return json.loads(text)
127
+ except json.JSONDecodeError:
128
+ return text
129
+
130
+
131
+ def request(path: str, *, body: Optional[Dict[str, Any]] = None, method: str = "POST", timeout: Optional[float] = None) -> Dict[str, Any]:
132
+ url = f"{_base_url()}{path}"
133
+ keys = _api_keys()
134
+ data = None
135
+ if body is not None:
136
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
137
+
138
+ attempts: list[Dict[str, Any]] = []
139
+ last_error: Optional[ServiceError] = None
140
+ for key_index, (key_label, key) in enumerate(keys, start=1):
141
+ headers = {
142
+ "Authorization": f"Bearer {key}",
143
+ "Accept": "application/json",
144
+ "User-Agent": "pi-search-services-skill/1.0",
145
+ }
146
+ if body is not None:
147
+ headers["Content-Type"] = "application/json"
148
+ req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
149
+ try:
150
+ with urllib.request.urlopen(req, timeout=timeout if timeout is not None else _timeout()) as resp:
151
+ parsed = _decode_response(resp.read())
152
+ result: Dict[str, Any] = {
153
+ "ok": True,
154
+ "service": "tavily",
155
+ "status": resp.status,
156
+ "data": _redact_value(parsed, keys),
157
+ "keyLabel": key_label,
158
+ "keyIndex": key_index,
159
+ }
160
+ if attempts:
161
+ result["keyFallbackAttempts"] = attempts
162
+ return result
163
+ except urllib.error.HTTPError as exc:
164
+ details = _redact_value(_decode_response(exc.read()), keys)
165
+ attempt = {"keyLabel": key_label, "keyIndex": key_index, "status": exc.code, "error": f"Tavily HTTP {exc.code}"}
166
+ attempts.append(attempt)
167
+ last_error = ServiceError(f"Tavily HTTP {exc.code}", status=exc.code, details=details, key_attempts=attempts.copy())
168
+ if _should_try_next_key(exc.code) and key_index < len(keys):
169
+ continue
170
+ raise last_error from exc
171
+ except urllib.error.URLError as exc:
172
+ details = _redact_value(str(exc), keys)
173
+ attempt = {"keyLabel": key_label, "keyIndex": key_index, "status": None, "error": f"Tavily 网络错误: {exc.reason}"}
174
+ attempts.append(attempt)
175
+ last_error = ServiceError(f"Tavily 网络错误: {exc.reason}", details=details, key_attempts=attempts.copy())
176
+ # 网络错误通常与 key 无关,直接抛出,避免重复消耗请求。
177
+ raise last_error from exc
178
+
179
+ if last_error is not None:
180
+ raise last_error
181
+ raise MissingAPIKey("缺少 Tavily API key:请设置 TAVILY_API_KEYS、TAVILY_API_KEY_0... 或 TAVILY_API_KEY")
182
+
183
+
184
+ def search(
185
+ query: str,
186
+ *,
187
+ search_depth: str = "basic",
188
+ max_results: int = 5,
189
+ topic: str = "general",
190
+ time_range: Optional[str] = None,
191
+ start_date: Optional[str] = None,
192
+ end_date: Optional[str] = None,
193
+ include_answer: Any = False,
194
+ include_raw_content: Any = False,
195
+ include_images: bool = False,
196
+ include_image_descriptions: bool = False,
197
+ include_favicon: bool = False,
198
+ include_domains: Optional[list[str]] = None,
199
+ exclude_domains: Optional[list[str]] = None,
200
+ country: Optional[str] = None,
201
+ chunks_per_source: Optional[int] = None,
202
+ auto_parameters: bool = False,
203
+ exact_match: bool = False,
204
+ include_usage: bool = False,
205
+ extra: Optional[Dict[str, Any]] = None,
206
+ ) -> Dict[str, Any]:
207
+ body: Dict[str, Any] = {
208
+ "query": query,
209
+ "search_depth": search_depth,
210
+ "max_results": max_results,
211
+ "topic": topic,
212
+ "include_answer": include_answer,
213
+ "include_raw_content": include_raw_content,
214
+ "include_images": include_images,
215
+ "include_image_descriptions": include_image_descriptions,
216
+ "include_favicon": include_favicon,
217
+ "auto_parameters": auto_parameters,
218
+ "exact_match": exact_match,
219
+ "include_usage": include_usage,
220
+ }
221
+ optional = {
222
+ "time_range": time_range,
223
+ "start_date": start_date,
224
+ "end_date": end_date,
225
+ "include_domains": include_domains,
226
+ "exclude_domains": exclude_domains,
227
+ "country": country,
228
+ "chunks_per_source": chunks_per_source,
229
+ }
230
+ body.update({k: v for k, v in optional.items() if v is not None})
231
+ if extra:
232
+ body.update(extra)
233
+ return request("/search", body=body)
234
+
235
+
236
+ def extract(
237
+ urls: list[str] | str,
238
+ *,
239
+ query: Optional[str] = None,
240
+ chunks_per_source: Optional[int] = None,
241
+ extract_depth: str = "basic",
242
+ include_images: bool = False,
243
+ include_favicon: bool = False,
244
+ fmt: str = "markdown",
245
+ timeout: Optional[float] = None,
246
+ http_timeout: Optional[float] = None,
247
+ include_usage: bool = False,
248
+ extra: Optional[Dict[str, Any]] = None,
249
+ ) -> Dict[str, Any]:
250
+ body: Dict[str, Any] = {
251
+ "urls": urls,
252
+ "extract_depth": extract_depth,
253
+ "include_images": include_images,
254
+ "include_favicon": include_favicon,
255
+ "format": fmt,
256
+ "include_usage": include_usage,
257
+ }
258
+ optional = {"query": query, "chunks_per_source": chunks_per_source, "timeout": timeout}
259
+ body.update({k: v for k, v in optional.items() if v is not None})
260
+ if extra:
261
+ body.update(extra)
262
+ return request("/extract", body=body, timeout=http_timeout)
263
+
264
+
265
+ def crawl(
266
+ url: str,
267
+ *,
268
+ instructions: Optional[str] = None,
269
+ chunks_per_source: Optional[int] = None,
270
+ max_depth: int = 1,
271
+ max_breadth: int = 20,
272
+ limit: int = 50,
273
+ select_paths: Optional[list[str]] = None,
274
+ select_domains: Optional[list[str]] = None,
275
+ exclude_paths: Optional[list[str]] = None,
276
+ exclude_domains: Optional[list[str]] = None,
277
+ allow_external: bool = False,
278
+ include_images: bool = False,
279
+ extract_depth: str = "basic",
280
+ fmt: str = "markdown",
281
+ include_favicon: bool = False,
282
+ timeout: Optional[float] = None,
283
+ include_usage: bool = False,
284
+ extra: Optional[Dict[str, Any]] = None,
285
+ ) -> Dict[str, Any]:
286
+ body: Dict[str, Any] = {
287
+ "url": url,
288
+ "max_depth": max_depth,
289
+ "max_breadth": max_breadth,
290
+ "limit": limit,
291
+ "allow_external": allow_external,
292
+ "include_images": include_images,
293
+ "extract_depth": extract_depth,
294
+ "format": fmt,
295
+ "include_favicon": include_favicon,
296
+ "include_usage": include_usage,
297
+ }
298
+ optional = {
299
+ "instructions": instructions,
300
+ "chunks_per_source": chunks_per_source,
301
+ "select_paths": select_paths,
302
+ "select_domains": select_domains,
303
+ "exclude_paths": exclude_paths,
304
+ "exclude_domains": exclude_domains,
305
+ "timeout": timeout,
306
+ }
307
+ body.update({k: v for k, v in optional.items() if v is not None})
308
+ if extra:
309
+ body.update(extra)
310
+ return request("/crawl", body=body)
311
+
312
+
313
+ def map_site(
314
+ url: str,
315
+ *,
316
+ instructions: Optional[str] = None,
317
+ max_depth: int = 1,
318
+ max_breadth: int = 20,
319
+ limit: int = 50,
320
+ select_paths: Optional[list[str]] = None,
321
+ select_domains: Optional[list[str]] = None,
322
+ exclude_paths: Optional[list[str]] = None,
323
+ exclude_domains: Optional[list[str]] = None,
324
+ allow_external: bool = True,
325
+ timeout: Optional[float] = None,
326
+ include_usage: bool = False,
327
+ extra: Optional[Dict[str, Any]] = None,
328
+ ) -> Dict[str, Any]:
329
+ body: Dict[str, Any] = {
330
+ "url": url,
331
+ "max_depth": max_depth,
332
+ "max_breadth": max_breadth,
333
+ "limit": limit,
334
+ "allow_external": allow_external,
335
+ "include_usage": include_usage,
336
+ }
337
+ optional = {
338
+ "instructions": instructions,
339
+ "select_paths": select_paths,
340
+ "select_domains": select_domains,
341
+ "exclude_paths": exclude_paths,
342
+ "exclude_domains": exclude_domains,
343
+ "timeout": timeout,
344
+ }
345
+ body.update({k: v for k, v in optional.items() if v is not None})
346
+ if extra:
347
+ body.update(extra)
348
+ return request("/map", body=body)
349
+
350
+
351
+ def research(
352
+ input_text: str,
353
+ *,
354
+ model: str = "auto",
355
+ output_schema: Optional[Dict[str, Any]] = None,
356
+ citation_format: Optional[str] = None,
357
+ include_domains: Optional[list[str]] = None,
358
+ exclude_domains: Optional[list[str]] = None,
359
+ output_length: Optional[str] = None,
360
+ stream: bool = False,
361
+ extra: Optional[Dict[str, Any]] = None,
362
+ ) -> Dict[str, Any]:
363
+ body: Dict[str, Any] = {"input": input_text, "model": model, "stream": stream}
364
+ optional = {
365
+ "output_schema": output_schema,
366
+ "citation_format": citation_format,
367
+ "include_domains": include_domains,
368
+ "exclude_domains": exclude_domains,
369
+ "output_length": output_length,
370
+ }
371
+ body.update({k: v for k, v in optional.items() if v is not None})
372
+ if extra:
373
+ body.update(extra)
374
+ return request("/research", body=body)
375
+
376
+
377
+ def research_status(request_id: str) -> Dict[str, Any]:
378
+ safe_id = urllib.parse.quote(request_id, safe="")
379
+ return request(f"/research/{safe_id}", method="GET")
380
+
381
+
382
+ def usage() -> Dict[str, Any]:
383
+ return request("/usage", method="GET")
384
+
385
+
386
+ def raw(path: str, *, method: str = "POST", body_json: Optional[str] = None) -> Dict[str, Any]:
387
+ body = json.loads(body_json) if body_json else None
388
+ return request(path, body=body, method=method)
389
+
390
+
391
+ def _split_csv(value: Optional[str]) -> Optional[list[str]]:
392
+ if not value:
393
+ return None
394
+ return [part.strip() for part in value.split(",") if part.strip()]
395
+
396
+
397
+ def _json_or_none(value: Optional[str]) -> Optional[Dict[str, Any]]:
398
+ if not value:
399
+ return None
400
+ return json.loads(value)
401
+
402
+
403
+ def _json_scalar(value: Optional[str], default: Any) -> Any:
404
+ if value is None:
405
+ return default
406
+ try:
407
+ return json.loads(value)
408
+ except json.JSONDecodeError:
409
+ return value
410
+
411
+
412
+ def _print_json(value: Any) -> None:
413
+ print(json.dumps(value, ensure_ascii=False, indent=2))
414
+
415
+
416
+ def main(argv: Optional[list[str]] = None) -> int:
417
+ parser = argparse.ArgumentParser(description="Tavily REST API helper")
418
+ sub = parser.add_subparsers(dest="operation", required=True)
419
+
420
+ p = sub.add_parser("search", help="Tavily Web Search")
421
+ p.add_argument("--query", required=True)
422
+ p.add_argument("--search-depth", default="basic", choices=["basic", "advanced", "fast", "ultra-fast"])
423
+ p.add_argument("--max-results", type=int, default=5)
424
+ p.add_argument("--topic", default="general", choices=["general", "news", "finance"])
425
+ p.add_argument("--time-range", choices=["day", "week", "month", "year"])
426
+ p.add_argument("--start-date")
427
+ p.add_argument("--end-date")
428
+ p.add_argument("--include-answer-json", help="bool 或 \"basic\"/\"advanced\" 的 JSON")
429
+ p.add_argument("--include-raw-content-json", help="bool 或 \"markdown\"/\"text\" 的 JSON")
430
+ p.add_argument("--include-images", action="store_true")
431
+ p.add_argument("--include-image-descriptions", action="store_true")
432
+ p.add_argument("--include-favicon", action="store_true")
433
+ p.add_argument("--include-domains", help="逗号分隔")
434
+ p.add_argument("--exclude-domains", help="逗号分隔")
435
+ p.add_argument("--country")
436
+ p.add_argument("--chunks-per-source", type=int)
437
+ p.add_argument("--auto-parameters", action="store_true")
438
+ p.add_argument("--exact-match", action="store_true")
439
+ p.add_argument("--include-usage", action="store_true")
440
+ p.add_argument("--extra-json")
441
+
442
+ p = sub.add_parser("extract", help="Tavily Extract")
443
+ p.add_argument("--urls", required=True, help="逗号分隔 URL")
444
+ p.add_argument("--query")
445
+ p.add_argument("--chunks-per-source", type=int)
446
+ p.add_argument("--extract-depth", default="basic", choices=["basic", "advanced"])
447
+ p.add_argument("--include-images", action="store_true")
448
+ p.add_argument("--include-favicon", action="store_true")
449
+ p.add_argument("--format", dest="fmt", default="markdown", choices=["markdown", "text"])
450
+ p.add_argument("--timeout", type=float)
451
+ p.add_argument("--include-usage", action="store_true")
452
+ p.add_argument("--extra-json")
453
+
454
+ for name, help_text in [("crawl", "Tavily Crawl"), ("map", "Tavily Map")]:
455
+ p = sub.add_parser(name, help=help_text)
456
+ p.add_argument("--url", required=True)
457
+ p.add_argument("--instructions")
458
+ p.add_argument("--max-depth", type=int, default=1)
459
+ p.add_argument("--max-breadth", type=int, default=20)
460
+ p.add_argument("--limit", type=int, default=50)
461
+ p.add_argument("--select-paths", help="逗号分隔 regex")
462
+ p.add_argument("--select-domains", help="逗号分隔 regex")
463
+ p.add_argument("--exclude-paths", help="逗号分隔 regex")
464
+ p.add_argument("--exclude-domains", help="逗号分隔 regex")
465
+ p.add_argument("--allow-external", action="store_true")
466
+ p.add_argument("--timeout", type=float)
467
+ p.add_argument("--include-usage", action="store_true")
468
+ p.add_argument("--extra-json")
469
+ if name == "crawl":
470
+ p.add_argument("--chunks-per-source", type=int)
471
+ p.add_argument("--include-images", action="store_true")
472
+ p.add_argument("--extract-depth", default="basic", choices=["basic", "advanced"])
473
+ p.add_argument("--format", dest="fmt", default="markdown", choices=["markdown", "text"])
474
+ p.add_argument("--include-favicon", action="store_true")
475
+
476
+ p = sub.add_parser("research", help="创建 Tavily Research 任务")
477
+ p.add_argument("--input", "--query", dest="input_text", required=True)
478
+ p.add_argument("--model", default="auto", choices=["mini", "pro", "auto"])
479
+ p.add_argument("--output-schema-json")
480
+ p.add_argument("--citation-format")
481
+ p.add_argument("--include-domains", help="逗号分隔")
482
+ p.add_argument("--exclude-domains", help="逗号分隔")
483
+ p.add_argument("--output-length")
484
+ p.add_argument("--stream", action="store_true")
485
+ p.add_argument("--extra-json")
486
+
487
+ p = sub.add_parser("research-status", help="获取研究任务状态")
488
+ p.add_argument("--request-id", required=True)
489
+
490
+ sub.add_parser("usage", help="查询用量")
491
+
492
+ p = sub.add_parser("raw", help="原始 Tavily API 调用")
493
+ p.add_argument("--path", required=True)
494
+ p.add_argument("--method", default="POST")
495
+ p.add_argument("--body-json")
496
+
497
+ args = parser.parse_args(argv)
498
+ try:
499
+ if args.operation == "search":
500
+ out = search(
501
+ args.query,
502
+ search_depth=args.search_depth,
503
+ max_results=args.max_results,
504
+ topic=args.topic,
505
+ time_range=args.time_range,
506
+ start_date=args.start_date,
507
+ end_date=args.end_date,
508
+ include_answer=_json_scalar(args.include_answer_json, False),
509
+ include_raw_content=_json_scalar(args.include_raw_content_json, False),
510
+ include_images=args.include_images,
511
+ include_image_descriptions=args.include_image_descriptions,
512
+ include_favicon=args.include_favicon,
513
+ include_domains=_split_csv(args.include_domains),
514
+ exclude_domains=_split_csv(args.exclude_domains),
515
+ country=args.country,
516
+ chunks_per_source=args.chunks_per_source,
517
+ auto_parameters=args.auto_parameters,
518
+ exact_match=args.exact_match,
519
+ include_usage=args.include_usage,
520
+ extra=_json_or_none(args.extra_json),
521
+ )
522
+ elif args.operation == "extract":
523
+ urls = _split_csv(args.urls) or []
524
+ out = extract(
525
+ urls[0] if len(urls) == 1 else urls,
526
+ query=args.query,
527
+ chunks_per_source=args.chunks_per_source,
528
+ extract_depth=args.extract_depth,
529
+ include_images=args.include_images,
530
+ include_favicon=args.include_favicon,
531
+ fmt=args.fmt,
532
+ timeout=args.timeout,
533
+ include_usage=args.include_usage,
534
+ extra=_json_or_none(args.extra_json),
535
+ )
536
+ elif args.operation == "crawl":
537
+ out = crawl(
538
+ args.url,
539
+ instructions=args.instructions,
540
+ chunks_per_source=args.chunks_per_source,
541
+ max_depth=args.max_depth,
542
+ max_breadth=args.max_breadth,
543
+ limit=args.limit,
544
+ select_paths=_split_csv(args.select_paths),
545
+ select_domains=_split_csv(args.select_domains),
546
+ exclude_paths=_split_csv(args.exclude_paths),
547
+ exclude_domains=_split_csv(args.exclude_domains),
548
+ allow_external=args.allow_external,
549
+ include_images=args.include_images,
550
+ extract_depth=args.extract_depth,
551
+ fmt=args.fmt,
552
+ include_favicon=args.include_favicon,
553
+ timeout=args.timeout,
554
+ include_usage=args.include_usage,
555
+ extra=_json_or_none(args.extra_json),
556
+ )
557
+ elif args.operation == "map":
558
+ out = map_site(
559
+ args.url,
560
+ instructions=args.instructions,
561
+ max_depth=args.max_depth,
562
+ max_breadth=args.max_breadth,
563
+ limit=args.limit,
564
+ select_paths=_split_csv(args.select_paths),
565
+ select_domains=_split_csv(args.select_domains),
566
+ exclude_paths=_split_csv(args.exclude_paths),
567
+ exclude_domains=_split_csv(args.exclude_domains),
568
+ allow_external=args.allow_external,
569
+ timeout=args.timeout,
570
+ include_usage=args.include_usage,
571
+ extra=_json_or_none(args.extra_json),
572
+ )
573
+ elif args.operation == "research":
574
+ out = research(
575
+ args.input_text,
576
+ model=args.model,
577
+ output_schema=_json_or_none(args.output_schema_json),
578
+ citation_format=args.citation_format,
579
+ include_domains=_split_csv(args.include_domains),
580
+ exclude_domains=_split_csv(args.exclude_domains),
581
+ output_length=args.output_length,
582
+ stream=args.stream,
583
+ extra=_json_or_none(args.extra_json),
584
+ )
585
+ elif args.operation == "research-status":
586
+ out = research_status(args.request_id)
587
+ elif args.operation == "usage":
588
+ out = usage()
589
+ elif args.operation == "raw":
590
+ out = raw(args.path, method=args.method, body_json=args.body_json)
591
+ else:
592
+ parser.error("未知操作")
593
+ return 2
594
+ _print_json(out)
595
+ return 0
596
+ except ServiceError as exc:
597
+ _print_json({
598
+ "ok": False,
599
+ "service": "tavily",
600
+ "error": str(exc),
601
+ "status": exc.status,
602
+ "details": exc.details,
603
+ "keyFallbackAttempts": exc.key_attempts,
604
+ })
605
+ return 1
606
+
607
+
608
+ if __name__ == "__main__":
609
+ sys.exit(main())
package/src/config.ts CHANGED
@@ -13,7 +13,8 @@ import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
13
13
 
14
14
  export const SETTINGS_NS = NS_LITERAL
15
15
 
16
- export const DEFAULT_SCRIPT_PATH = '~/.dsh/skills/search-services/scripts/search.py'
16
+ /** 空串表示使用随包内置脚本(本包 scripts/search.py,见 scripts/README.md)。 */
17
+ export const DEFAULT_SCRIPT_PATH = ''
17
18
  export const DEFAULT_ENV_FILE = '~/.config/search-services/env'
18
19
  export const DEFAULT_TIMEOUT_MS = 55_000
19
20
  export const DEFAULT_PRIORITY: readonly SearchBackend[] = ['tavily', 'exa', 'openai-chat']
@@ -51,7 +52,7 @@ const KeysSchema = z.object({
51
52
  export const Config = z.object({
52
53
  scriptPath: z
53
54
  .string()
54
- .description('search-services skill 的 search.py 路径(支持 ~ 展开)')
55
+ .description('search.py 路径(支持 ~ 展开);空串 = 使用随包内置脚本副本')
55
56
  .default(DEFAULT_SCRIPT_PATH),
56
57
  envFile: z
57
58
  .string()
package/src/normalize.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * search.py 输出 → dsh-web 标准 WebSearchResult 归一化(纯函数)。
3
3
  *
4
- * skill 各后端 payload 形状不同:tavily/exa 是原生 API 形状(results[]),
5
- * openai-chat 是生成式回答(data.answer)。按 selectedService 归一;
6
- * 字段缺失降级而非报错(对 skill 脚本版本漂移保持容差)。
4
+ * skill 各服务 request() 统一信封 {ok, service, status, data: <API 原始响应>}:
5
+ * tavily/exa 业务字段在 data 下(results/answer),openai-chat 回答在
6
+ * data.answer。按 selectedService 归一;字段缺失降级而非报错(对 skill
7
+ * 脚本版本漂移保持容差)。
7
8
  * @module web-search-services/normalize
8
9
  */
9
10
  import type { WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web'
@@ -38,6 +39,13 @@ function makeSource(
38
39
  }
39
40
  }
40
41
 
42
+ /** skill 各服务 request() 统一信封 {ok, service, status, data: <API 响应>};
43
+ * 取 data 为业务 payload,data 缺失/空对象时回退顶层(旧形状容差)。 */
44
+ function payloadOf(raw: JsonObject): JsonObject {
45
+ const data = asObject(raw.data)
46
+ return Object.keys(data).length > 0 ? data : raw
47
+ }
48
+
41
49
  function finish(content: string | undefined, sources: WebSearchSource[]): WebSearchResult {
42
50
  const seen = new Set<string>()
43
51
  const deduped = sources.filter((source) => {
@@ -65,7 +73,8 @@ function sourcesFromResults(
65
73
  }
66
74
 
67
75
  function normalizeTavily(raw: JsonObject): WebSearchResult {
68
- const sources = sourcesFromResults(raw.results, (item) => {
76
+ const payload = payloadOf(raw)
77
+ const sources = sourcesFromResults(payload.results, (item) => {
69
78
  const url = asNonEmptyString(item.url)
70
79
  if (url === undefined) return undefined
71
80
  return makeSource(
@@ -75,11 +84,12 @@ function normalizeTavily(raw: JsonObject): WebSearchResult {
75
84
  asNonEmptyString(item.published_date),
76
85
  )
77
86
  })
78
- return finish(asNonEmptyString(raw.answer), sources)
87
+ return finish(asNonEmptyString(payload.answer), sources)
79
88
  }
80
89
 
81
90
  function normalizeExa(raw: JsonObject): WebSearchResult {
82
- const sources = sourcesFromResults(raw.results, (item) => {
91
+ const payload = payloadOf(raw)
92
+ const sources = sourcesFromResults(payload.results, (item) => {
83
93
  const url = asNonEmptyString(item.url) ?? asNonEmptyString(item.id)
84
94
  if (url === undefined) return undefined
85
95
  const highlights = asArray(item.highlights)