@dsh-plus/web-search-services 0.1.19 → 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.
package/scripts/tavily.py DELETED
@@ -1,609 +0,0 @@
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/args.ts DELETED
@@ -1,30 +0,0 @@
1
- /**
2
- * search.py 子命令参数组装(纯函数)。
3
- * 优先级直接透传 --service:search.py 自带链内 fallback 与 Tavily 多 key 轮转,
4
- * 插件不重复实现回退编排。
5
- * @module web-search-services/args
6
- */
7
- import type { WebSearchRequest } from '@deepseek-ai/dsh-web'
8
-
9
- import type { WebSearchServicesConfig } from './config.ts'
10
-
11
- /** dsh-tool-web 之外的调用方未给 maxResults 时的请求层缺省。 */
12
- export const FALLBACK_MAX_RESULTS = 8
13
-
14
- /** 由 web 请求 + 插件配置生成 search.py 的 argv(不含解释器与脚本路径)。 */
15
- export function buildSearchArgv(cfg: WebSearchServicesConfig, request: WebSearchRequest): string[] {
16
- const query = request.query.trim()
17
- if (query === '') {
18
- throw new Error('query must be a non-empty string')
19
- }
20
- const maxResults = request.maxResults ?? FALLBACK_MAX_RESULTS
21
- return [
22
- 'search',
23
- '--query',
24
- query,
25
- '--service',
26
- cfg.priority.join(','),
27
- '--max-results',
28
- String(maxResults),
29
- ]
30
- }
package/src/env-file.ts DELETED
@@ -1,49 +0,0 @@
1
- /**
2
- * search-services skill env 文件解析(纯函数)。
3
- * 只解析不执行:按行提取 KEY=VALUE,不展开 $ 变量、不运行 shell,畸形行跳过;
4
- * 文件缺失/不可读返回空表(envFile 是优化来源,不是硬依赖)。
5
- * @module web-search-services/env-file
6
- */
7
- import { readFileSync } from 'node:fs'
8
- import { homedir } from 'node:os'
9
- import { join } from 'node:path'
10
-
11
- /** 展开路径开头的 ~(支持 `~`、`~/`、`~\` 三种形态,与官方 dsh-home-paths 对齐,不借 shell)。 */
12
- export function expandHome(path: string): string {
13
- if (path === '~') return homedir()
14
- if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
15
- return path
16
- }
17
-
18
- function unquote(value: string): string {
19
- const first = value.charAt(0)
20
- const last = value.charAt(value.length - 1)
21
- if (value.length >= 2 && first === last && (first === '"' || first === "'")) {
22
- return value.slice(1, -1)
23
- }
24
- return value
25
- }
26
-
27
- export function parseEnvText(text: string): Record<string, string> {
28
- const out: Record<string, string> = {}
29
- for (const rawLine of text.split(/\r?\n/)) {
30
- const line = rawLine.trim()
31
- if (line === '' || line.startsWith('#')) continue
32
- const body = line.startsWith('export ') ? line.slice('export '.length).trim() : line
33
- const eq = body.indexOf('=')
34
- if (eq <= 0) continue
35
- const key = body.slice(0, eq).trim()
36
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
37
- out[key] = unquote(body.slice(eq + 1).trim())
38
- }
39
- return out
40
- }
41
-
42
- export function loadEnvFile(path: string): Record<string, string> {
43
- if (path.trim() === '') return {}
44
- try {
45
- return parseEnvText(readFileSync(expandHome(path), 'utf8'))
46
- } catch {
47
- return {}
48
- }
49
- }