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

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/provider.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * @module web-search-services/provider
6
6
  */
7
7
  import { existsSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+
8
10
  import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
9
11
  import { WebError } from '@deepseek-ai/dsh-web'
10
12
 
@@ -16,6 +18,15 @@ import { isAbortError, runProcess, type SpawnFn } from './runner.ts'
16
18
 
17
19
  export const PROVIDER_ID = 'search-services'
18
20
 
21
+ /**
22
+ * scriptPath 解析:空串 = 随包内置脚本副本(tgz 内 scripts/search.py,与 lib/
23
+ * 同级;src 直跑时为包根 scripts/)。非空按 ~ 展开。
24
+ */
25
+ export function resolveScriptPath(scriptPath: string): string {
26
+ if (scriptPath.trim() !== '') return expandHome(scriptPath)
27
+ return fileURLToPath(new URL('../scripts/search.py', import.meta.url))
28
+ }
29
+
19
30
  /** 各后端在合并环境(进程 env ⊕ envFile ⊕ 行级 keys)中的可用性判定变量。 */
20
31
  const BACKEND_KEY_VARS: Readonly<Record<SearchBackend, readonly string[]>> = {
21
32
  tavily: ['TAVILY_API_KEY', 'TAVILY_API_KEYS'],
@@ -60,7 +71,7 @@ export class SearchServicesProvider implements WebSearchProvider {
60
71
  /** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
61
72
  available(): boolean {
62
73
  const cfg = this.current()
63
- if (!existsSync(expandHome(cfg.scriptPath))) return false
74
+ if (!existsSync(resolveScriptPath(cfg.scriptPath))) return false
64
75
  const env = this.childEnv(cfg)
65
76
  return cfg.priority.some((backend) =>
66
77
  BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? '') !== ''),
@@ -87,7 +98,7 @@ export class SearchServicesProvider implements WebSearchProvider {
87
98
  try {
88
99
  return await runProcess({
89
100
  command: cfg.python,
90
- args: [expandHome(cfg.scriptPath), ...argv],
101
+ args: [resolveScriptPath(cfg.scriptPath), ...argv],
91
102
  env: this.childEnv(cfg),
92
103
  timeoutMs: cfg.timeoutMs,
93
104
  ...(signal !== undefined ? { signal } : {}),