@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,962 @@
1
+ #!/usr/bin/env python3
2
+ """统一搜索入口:OpenAI Chat + Context7 + Exa + Tavily + 本地 fetch。
3
+
4
+ 推荐优先使用本入口的 search/docs/fetch/research 能力,以获得自动 fallback。
5
+ 服务专属能力通过:search.py openai-chat|context7|exa|tavily <operation> ... 透传。
6
+ URL 抓取默认走本地 fetch;仅当本地请求失败或质量评估不达标时,才 fallback 到 Tavily / Exa。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ from collections.abc import Callable, Iterable
17
+ from typing import Any
18
+
19
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
20
+ if SCRIPT_DIR not in sys.path:
21
+ sys.path.insert(0, SCRIPT_DIR)
22
+
23
+ import context7 # noqa: E402
24
+ import exa # noqa: E402
25
+ import fetch as local_fetch # noqa: E402
26
+ import fetch_playwright # noqa: E402
27
+ import openai_chat # noqa: E402
28
+ import tavily # noqa: E402
29
+
30
+ ResultFunc = Callable[[], dict[str, Any]]
31
+
32
+ DEFAULT_FETCH_TOTAL_TIMEOUT = float(
33
+ os.getenv("FETCH_TOTAL_TIMEOUT") or os.getenv("SEARCH_FETCH_TOTAL_TIMEOUT") or "25"
34
+ )
35
+ DEFAULT_LOCAL_STAGE_TIMEOUT = float(os.getenv("FETCH_LOCAL_STAGE_TIMEOUT") or "5")
36
+ DEFAULT_PLAYWRIGHT_STAGE_TIMEOUT = float(
37
+ os.getenv("FETCH_PLAYWRIGHT_STAGE_TIMEOUT") or "12"
38
+ )
39
+ MIN_FALLBACK_TIMEOUT = float(os.getenv("FETCH_MIN_STAGE_TIMEOUT") or "3")
40
+
41
+
42
+ class FallbackFailure(RuntimeError):
43
+ """所有候选服务都失败。"""
44
+
45
+ def __init__(self, attempts: list[dict[str, Any]]):
46
+ super().__init__("所有候选搜索服务均不可用或返回失败")
47
+ self.attempts = attempts
48
+
49
+
50
+ class SkipService(RuntimeError):
51
+ """当前上下文下跳过某个候选服务,不记录为失败。"""
52
+
53
+
54
+ def _print_json(value: Any) -> None:
55
+ print(json.dumps(value, ensure_ascii=False, indent=2))
56
+
57
+
58
+ def _split_csv(value: str | None) -> list[str] | None:
59
+ if not value:
60
+ return None
61
+ return [part.strip() for part in value.split(",") if part.strip()]
62
+
63
+
64
+ def _json_or_none(value: str | None) -> dict[str, Any] | None:
65
+ if not value:
66
+ return None
67
+ return json.loads(value)
68
+
69
+
70
+ def _parse_services(
71
+ value: str, default: list[str], *, no_fallback: bool = False
72
+ ) -> list[str]:
73
+ if value == "auto":
74
+ return default[:1] if no_fallback else default
75
+ preferred = [part.strip() for part in value.split(",") if part.strip()]
76
+ if no_fallback:
77
+ return preferred
78
+ # 用户指定首选服务时,仍自动追加默认 fallback。
79
+ return preferred + [svc for svc in default if svc not in preferred]
80
+
81
+
82
+ def _service_error(service: str, exc: BaseException) -> dict[str, Any]:
83
+ error = {
84
+ "service": service,
85
+ "ok": False,
86
+ "error": str(exc),
87
+ "status": getattr(exc, "status", None),
88
+ "details": getattr(exc, "details", None),
89
+ "elapsedSeconds": getattr(exc, "elapsed_seconds", None),
90
+ }
91
+ key_attempts = getattr(exc, "key_attempts", None)
92
+ if key_attempts:
93
+ error["keyFallbackAttempts"] = key_attempts
94
+ return error
95
+
96
+
97
+ def _with_fallback(candidates: Iterable[tuple[str, ResultFunc]]) -> dict[str, Any]:
98
+ attempts: list[dict[str, Any]] = []
99
+ for service, func in candidates:
100
+ started = time.monotonic()
101
+ try:
102
+ result = func()
103
+ result.setdefault("ok", True)
104
+ result.setdefault("service", service)
105
+ result.setdefault("elapsedSeconds", round(time.monotonic() - started, 3))
106
+ if attempts:
107
+ result["fallbackAttempts"] = attempts
108
+ result["selectedService"] = service
109
+ return result
110
+ except SkipService:
111
+ continue
112
+ except Exception as exc: # noqa: BLE001 - fallback 需要捕获所有服务错误
113
+ exc.elapsed_seconds = round(time.monotonic() - started, 3) # type: ignore[attr-defined]
114
+ attempts.append(_service_error(service, exc))
115
+ raise FallbackFailure(attempts)
116
+
117
+
118
+ def _context7_search_call(args: argparse.Namespace) -> dict[str, Any]:
119
+ if not args.library:
120
+ raise context7.ServiceError("Context7 搜索需要 --library;无库名时跳过")
121
+ return context7.lookup(args.library, args.query, fast=args.fast_context7)
122
+
123
+
124
+ def _exa_search_call(
125
+ args: argparse.Namespace, *, research: bool = False
126
+ ) -> dict[str, Any]:
127
+ contents: dict[str, Any] = {"highlights": True}
128
+ if args.include_text or research:
129
+ contents["text"] = {"maxCharacters": args.max_characters}
130
+ if args.include_summary or research:
131
+ contents["summary"] = True
132
+ if args.fresh:
133
+ contents["maxAgeHours"] = 0
134
+ search_type = args.exa_type
135
+ if research and search_type == "auto":
136
+ search_type = "deep"
137
+ category = args.category
138
+ if getattr(args, "intent", None) == "news" and category is None:
139
+ category = "news"
140
+ return exa.search(
141
+ args.query,
142
+ search_type=search_type,
143
+ num_results=args.max_results,
144
+ category=category,
145
+ include_domains=_split_csv(args.include_domains),
146
+ exclude_domains=_split_csv(args.exclude_domains),
147
+ contents=contents,
148
+ output_schema=_json_or_none(args.output_schema_json),
149
+ system_prompt=args.system_prompt,
150
+ )
151
+
152
+
153
+ def _tavily_search_call(
154
+ args: argparse.Namespace, *, research_like: bool = False
155
+ ) -> dict[str, Any]:
156
+ topic = args.topic
157
+ if getattr(args, "intent", None) == "news" and topic == "general":
158
+ topic = "news"
159
+ depth = args.tavily_depth
160
+ if research_like and depth == "basic":
161
+ depth = "advanced"
162
+ raw_content: Any = "markdown" if args.include_text else False
163
+ answer: Any = "advanced" if research_like else args.include_answer
164
+ return tavily.search(
165
+ args.query,
166
+ search_depth=depth,
167
+ max_results=args.max_results,
168
+ topic=topic,
169
+ time_range=args.time_range,
170
+ include_answer=answer,
171
+ include_raw_content=raw_content,
172
+ include_domains=_split_csv(args.include_domains),
173
+ exclude_domains=_split_csv(args.exclude_domains),
174
+ chunks_per_source=3 if depth == "advanced" else None,
175
+ include_usage=args.include_usage,
176
+ )
177
+
178
+
179
+ def _openai_chat_search_call(
180
+ args: argparse.Namespace, *, query: str | None = None
181
+ ) -> dict[str, Any]:
182
+ return openai_chat.search(
183
+ query or args.query,
184
+ model=getattr(args, "openai_model", None),
185
+ base_url=getattr(args, "openai_base_url", None),
186
+ answer_format=getattr(args, "openai_answer_format", None),
187
+ platform=(
188
+ getattr(args, "openai_platform", None) or getattr(args, "platform", None)
189
+ ),
190
+ system_prompt=getattr(args, "openai_system_prompt", None),
191
+ thinking_effort=getattr(args, "openai_thinking_effort", None),
192
+ thinking_field=getattr(args, "openai_thinking_field", None),
193
+ max_tokens=getattr(args, "openai_max_tokens", None),
194
+ temperature=getattr(args, "openai_temperature", None),
195
+ timeout=getattr(args, "openai_timeout", None),
196
+ max_elapsed=getattr(args, "openai_max_elapsed", None),
197
+ extra=_json_or_none(getattr(args, "openai_extra_json", None)),
198
+ )
199
+
200
+
201
+ def _quality_flags_need_playwright(value: Any) -> bool:
202
+ """从结果或异常 details 中判断是否值得尝试 Playwright 渲染。"""
203
+
204
+ if isinstance(value, dict):
205
+ flags = value.get("flags")
206
+ if isinstance(flags, dict) and (
207
+ flags.get("jsRequired") or flags.get("blocked")
208
+ ):
209
+ return True
210
+ quality = value.get("quality")
211
+ if isinstance(quality, dict) and _quality_flags_need_playwright(quality):
212
+ return True
213
+ for key in ["qualityFailures", "failures"]:
214
+ failures = value.get(key)
215
+ if isinstance(failures, list) and any(
216
+ _quality_flags_need_playwright(item) for item in failures
217
+ ):
218
+ return True
219
+ data = value.get("data")
220
+ if isinstance(data, dict):
221
+ results = data.get("results")
222
+ if isinstance(results, list) and any(
223
+ _quality_flags_need_playwright(item) for item in results
224
+ ):
225
+ return True
226
+ return False
227
+
228
+
229
+ def _remaining_budget(context: dict[str, Any]) -> float:
230
+ return max(0.0, float(context.get("deadline", time.monotonic())) - time.monotonic())
231
+
232
+
233
+ def _stage_timeout(
234
+ context: dict[str, Any],
235
+ *,
236
+ default_timeout: float,
237
+ explicit_timeout: float | None = None,
238
+ reserve_after_stage: float = 0.0,
239
+ ) -> float:
240
+ """计算本地阶段的实际超时。
241
+
242
+ `reserve_after_stage` 只在预算足够宽裕时预留给后续本地阶段;
243
+ 预算较小时优先让当前阶段有一次完整尝试。显式 `--timeout` 可小于
244
+ `FETCH_MIN_STAGE_TIMEOUT`,方便用户主动快速失败。
245
+ """
246
+
247
+ remaining = _remaining_budget(context)
248
+ if remaining <= 0:
249
+ raise RuntimeError("fetch 本地阶段预算已耗尽")
250
+
251
+ requested = max(
252
+ 0.1, default_timeout if explicit_timeout is None else explicit_timeout
253
+ )
254
+ usable_remaining = remaining
255
+ if (
256
+ explicit_timeout is None
257
+ and reserve_after_stage > 0
258
+ and remaining > reserve_after_stage + MIN_FALLBACK_TIMEOUT
259
+ ):
260
+ usable_remaining = remaining - reserve_after_stage
261
+
262
+ timeout = min(requested, usable_remaining)
263
+ if explicit_timeout is None:
264
+ timeout = max(timeout, min(MIN_FALLBACK_TIMEOUT, remaining))
265
+ timeout = min(timeout, remaining)
266
+ return round(timeout, 3)
267
+
268
+
269
+ def _local_fetch_call(
270
+ args: argparse.Namespace, context: dict[str, Any]
271
+ ) -> dict[str, Any]:
272
+ try:
273
+ return local_fetch.fetch_urls(
274
+ context["urls"],
275
+ timeout=_stage_timeout(
276
+ context,
277
+ default_timeout=DEFAULT_LOCAL_STAGE_TIMEOUT,
278
+ explicit_timeout=args.timeout,
279
+ reserve_after_stage=8.0,
280
+ ),
281
+ max_chars=args.max_characters,
282
+ min_quality_score=args.quality_threshold,
283
+ retries=0 if args.retries is None else args.retries,
284
+ retry_delay=args.retry_delay,
285
+ retry_jitter=args.retry_jitter,
286
+ retry_on_any=args.retry_on_any,
287
+ referer=args.referer,
288
+ accept_language=args.accept_language,
289
+ extra_headers=context.get("extra_headers"),
290
+ extract_mode=args.extract_mode,
291
+ proxy=args.proxy,
292
+ keep_links=False,
293
+ drop_structural_noise=True,
294
+ markdownish=True,
295
+ )
296
+ except Exception as exc:
297
+ context["localError"] = exc
298
+ if args.with_js or (
299
+ args.auto_playwright
300
+ and _quality_flags_need_playwright(getattr(exc, "details", None))
301
+ ):
302
+ context["playwrightNeeded"] = True
303
+ raise
304
+
305
+
306
+ def _playwright_fetch_call(
307
+ args: argparse.Namespace, context: dict[str, Any], *, explicit: bool = False
308
+ ) -> dict[str, Any]:
309
+ if not explicit and not args.with_js and not context.get("playwrightNeeded"):
310
+ raise SkipService("当前 local-fetch 质量信号不需要 Playwright")
311
+ return fetch_playwright.fetch_urls(
312
+ context["urls"],
313
+ timeout=_stage_timeout(
314
+ context,
315
+ default_timeout=DEFAULT_PLAYWRIGHT_STAGE_TIMEOUT,
316
+ explicit_timeout=args.playwright_timeout,
317
+ reserve_after_stage=6.0,
318
+ ),
319
+ wait_ms=args.wait_ms,
320
+ selector=args.selector,
321
+ max_chars=args.max_characters,
322
+ markdownish=True,
323
+ min_quality_score=args.quality_threshold,
324
+ proxy=args.proxy,
325
+ retries=args.playwright_retries,
326
+ )
327
+
328
+
329
+ def command_search(args: argparse.Namespace) -> dict[str, Any]:
330
+ if args.intent == "docs" or args.library:
331
+ default = ["openai-chat", "context7", "exa", "tavily"]
332
+ elif args.intent == "news":
333
+ default = ["openai-chat", "tavily", "exa"]
334
+ elif args.intent == "research":
335
+ default = ["openai-chat", "exa", "tavily"]
336
+ else:
337
+ default = ["openai-chat", "exa", "tavily"]
338
+
339
+ order = _parse_services(args.service, default, no_fallback=args.no_fallback)
340
+ candidates: list[tuple[str, ResultFunc]] = []
341
+ for svc in order:
342
+ if svc == "openai-chat":
343
+ candidates.append((svc, lambda args=args: _openai_chat_search_call(args)))
344
+ elif svc == "context7":
345
+ candidates.append((svc, lambda args=args: _context7_search_call(args)))
346
+ elif svc == "exa":
347
+ candidates.append(
348
+ (
349
+ svc,
350
+ lambda args=args: _exa_search_call(
351
+ args, research=args.intent == "research"
352
+ ),
353
+ )
354
+ )
355
+ elif svc == "tavily":
356
+ candidates.append(
357
+ (
358
+ svc,
359
+ lambda args=args: _tavily_search_call(
360
+ args, research_like=args.intent == "research"
361
+ ),
362
+ )
363
+ )
364
+ else:
365
+ raise ValueError(f"未知服务: {svc}")
366
+ return _with_fallback(candidates)
367
+
368
+
369
+ def command_docs(args: argparse.Namespace) -> dict[str, Any]:
370
+ default = ["context7", "exa", "tavily", "openai-chat"]
371
+ order = _parse_services(args.service, default, no_fallback=args.no_fallback)
372
+ # 复用 search 参数形状。
373
+ args.intent = "docs"
374
+ args.include_text = args.include_text
375
+ args.include_summary = False
376
+ args.fresh = False
377
+ args.category = None
378
+ args.topic = "general"
379
+ args.time_range = None
380
+ args.include_answer = False
381
+ args.include_usage = False
382
+ args.output_schema_json = None
383
+ args.system_prompt = None
384
+ candidates: list[tuple[str, ResultFunc]] = []
385
+ for svc in order:
386
+ if svc == "context7":
387
+ candidates.append(
388
+ (
389
+ svc,
390
+ lambda args=args: context7.lookup(
391
+ args.library, args.query, fast=args.fast_context7
392
+ ),
393
+ )
394
+ )
395
+ elif svc == "exa":
396
+ candidates.append(
397
+ (
398
+ svc,
399
+ lambda args=args: exa.search(
400
+ f"{args.library} documentation: {args.query}",
401
+ search_type=args.exa_type,
402
+ num_results=args.max_results,
403
+ contents={
404
+ "highlights": True,
405
+ "text": {"maxCharacters": args.max_characters},
406
+ }
407
+ if args.include_text
408
+ else {"highlights": True},
409
+ ),
410
+ )
411
+ )
412
+ elif svc == "tavily":
413
+ candidates.append(
414
+ (
415
+ svc,
416
+ lambda args=args: tavily.search(
417
+ f"{args.library} documentation {args.query}",
418
+ search_depth=args.tavily_depth,
419
+ max_results=args.max_results,
420
+ include_raw_content="markdown" if args.include_text else False,
421
+ ),
422
+ )
423
+ )
424
+ elif svc == "openai-chat":
425
+ candidates.append(
426
+ (
427
+ svc,
428
+ lambda args=args: _openai_chat_search_call(
429
+ args, query=f"{args.library} documentation: {args.query}"
430
+ ),
431
+ )
432
+ )
433
+ else:
434
+ raise ValueError(f"未知服务: {svc}")
435
+ return _with_fallback(candidates)
436
+
437
+
438
+ def command_fetch(args: argparse.Namespace) -> dict[str, Any]:
439
+ urls = _split_csv(args.urls) or []
440
+ if not urls:
441
+ raise ValueError("--urls 不能为空")
442
+ default = ["local-fetch", "playwright", "tavily", "exa"]
443
+ order = _parse_services(args.service, default, no_fallback=args.no_fallback)
444
+ explicit_playwright = args.with_js or (
445
+ args.service != "auto"
446
+ and any(svc.strip() == "playwright" for svc in args.service.split(","))
447
+ )
448
+ context: dict[str, Any] = {
449
+ "urls": urls,
450
+ "playwrightNeeded": args.with_js,
451
+ "extra_headers": local_fetch.parse_header_list(args.header),
452
+ "deadline": time.monotonic() + max(1.0, args.total_timeout),
453
+ }
454
+ candidates: list[tuple[str, ResultFunc]] = []
455
+ for svc in order:
456
+ if svc == "tavily":
457
+ candidates.append(
458
+ (
459
+ svc,
460
+ lambda urls=urls, args=args: tavily.extract(
461
+ urls[0] if len(urls) == 1 else urls,
462
+ query=args.query,
463
+ extract_depth="advanced" if args.advanced else "basic",
464
+ include_images=args.include_images,
465
+ include_favicon=args.include_favicon,
466
+ fmt=args.format,
467
+ timeout=args.api_timeout,
468
+ http_timeout=args.api_timeout,
469
+ include_usage=args.include_usage,
470
+ ),
471
+ )
472
+ )
473
+ elif svc == "exa":
474
+ candidates.append(
475
+ (
476
+ svc,
477
+ lambda urls=urls, args=args: exa.contents(
478
+ urls,
479
+ text={"maxCharacters": args.max_characters},
480
+ highlights={"query": args.query, "maxCharacters": 2000}
481
+ if args.query
482
+ else None,
483
+ timeout=args.api_timeout,
484
+ ),
485
+ )
486
+ )
487
+ elif svc in {"local", "local-fetch"}:
488
+ candidates.append(
489
+ (
490
+ "local-fetch",
491
+ lambda args=args, context=context: _local_fetch_call(args, context),
492
+ )
493
+ )
494
+ elif svc in {"playwright", "browser", "render"}:
495
+ candidates.append(
496
+ (
497
+ "playwright",
498
+ lambda args=args, context=context, explicit=explicit_playwright: (
499
+ _playwright_fetch_call(args, context, explicit=explicit)
500
+ ),
501
+ )
502
+ )
503
+ else:
504
+ raise ValueError(f"未知服务: {svc}")
505
+ return _with_fallback(candidates)
506
+
507
+
508
+ def command_simple_fetch(args: argparse.Namespace) -> dict[str, Any]:
509
+ urls = _split_csv(args.urls) or []
510
+ if not urls:
511
+ raise ValueError("--urls 不能为空")
512
+ return local_fetch.fetch_urls(
513
+ urls,
514
+ timeout=args.timeout,
515
+ max_chars=args.max_characters,
516
+ keep_links=args.keep_links,
517
+ drop_structural_noise=not args.keep_structural_noise,
518
+ markdownish=not args.plain,
519
+ user_agent=args.user_agent,
520
+ retries=args.retries,
521
+ retry_delay=args.retry_delay,
522
+ retry_jitter=args.retry_jitter,
523
+ retry_on_any=args.retry_on_any,
524
+ referer=args.referer,
525
+ accept_language=args.accept_language,
526
+ extra_headers=local_fetch.parse_header_list(args.header),
527
+ extract_mode=args.extract_mode,
528
+ proxy=args.proxy,
529
+ )
530
+
531
+
532
+ def command_research(args: argparse.Namespace) -> dict[str, Any]:
533
+ default = ["exa", "tavily", "openai-chat"]
534
+ order = _parse_services(args.service, default, no_fallback=args.no_fallback)
535
+ candidates: list[tuple[str, ResultFunc]] = []
536
+ for svc in order:
537
+ if svc == "exa":
538
+ candidates.append(
539
+ (
540
+ svc,
541
+ lambda args=args: exa.search(
542
+ args.query,
543
+ search_type=args.exa_type,
544
+ num_results=args.max_results,
545
+ include_domains=_split_csv(args.include_domains),
546
+ exclude_domains=_split_csv(args.exclude_domains),
547
+ contents={
548
+ "highlights": True,
549
+ "text": {"maxCharacters": args.max_characters},
550
+ "summary": True,
551
+ },
552
+ output_schema=_json_or_none(args.output_schema_json),
553
+ system_prompt=args.system_prompt,
554
+ ),
555
+ )
556
+ )
557
+ elif svc == "tavily":
558
+ candidates.append(
559
+ (
560
+ svc,
561
+ lambda args=args: tavily.research(
562
+ args.query,
563
+ model=args.tavily_model,
564
+ output_schema=_json_or_none(args.output_schema_json),
565
+ include_domains=_split_csv(args.include_domains),
566
+ exclude_domains=_split_csv(args.exclude_domains),
567
+ output_length=args.output_length,
568
+ ),
569
+ )
570
+ )
571
+ elif svc == "openai-chat":
572
+ candidates.append((svc, lambda args=args: _openai_chat_search_call(args)))
573
+ else:
574
+ raise ValueError(f"未知服务: {svc}")
575
+ return _with_fallback(candidates)
576
+
577
+
578
+ def _add_openai_chat_args(p: argparse.ArgumentParser) -> None:
579
+ p.add_argument(
580
+ "--openai-model",
581
+ help="OpenAI-compatible 模型名;也可用 SEARCH_OPENAI_MODEL,未配置则 openai-chat 失败并 fallback",
582
+ )
583
+ p.add_argument(
584
+ "--openai-base-url",
585
+ help="OpenAI-compatible base URL;默认 SEARCH_OPENAI_BASE_URL 或 https://api.openai.com/v1",
586
+ )
587
+ p.add_argument(
588
+ "--openai-thinking-effort",
589
+ choices=["low", "medium", "high"],
590
+ help="思考程度;会写入 thinking 字段,字段名默认 reasoning_effort",
591
+ )
592
+ p.add_argument(
593
+ "--openai-thinking-field",
594
+ help="思考程度请求字段名;默认 SEARCH_OPENAI_THINKING_FIELD 或 reasoning_effort;传空字符串可禁用",
595
+ )
596
+ p.add_argument(
597
+ "--openai-answer-format",
598
+ choices=["markdown", "text", "json"],
599
+ help="回答格式;默认 SEARCH_OPENAI_ANSWER_FORMAT 或 markdown",
600
+ )
601
+ p.add_argument(
602
+ "--platform",
603
+ "--openai-platform",
604
+ dest="openai_platform",
605
+ help="OpenAI Chat 搜索优先关注的平台、站点或来源类型;会写入 user message",
606
+ )
607
+ p.add_argument(
608
+ "--openai-system-prompt",
609
+ help="显式发送给 OpenAI 的真实 system prompt;默认搜索提示词不会放在 system role",
610
+ )
611
+ p.add_argument("--openai-max-tokens", type=int)
612
+ p.add_argument("--openai-temperature", type=float)
613
+ p.add_argument(
614
+ "--openai-timeout",
615
+ type=float,
616
+ help="连接/空闲超时秒数;持续收到流式 chunk 时会自动延长",
617
+ )
618
+ p.add_argument(
619
+ "--openai-max-elapsed",
620
+ type=float,
621
+ help="OpenAI 调用硬上限秒数;0 表示不设置硬上限",
622
+ )
623
+ p.add_argument(
624
+ "--openai-extra-json", help="合并到 OpenAI Chat Completions 请求体的额外 JSON"
625
+ )
626
+
627
+
628
+ def build_parser() -> argparse.ArgumentParser:
629
+ parser = argparse.ArgumentParser(
630
+ description="OpenAI Chat / Context7 / Exa / Tavily + 本地 fetch 统一入口(fetch 默认本地优先,失败/低质量才 fallback,不使用 MCP)",
631
+ epilog="服务专属能力:search.py openai-chat|context7|exa|tavily <operation> ...;零成本抓取:search.py simple-fetch --urls <url>",
632
+ )
633
+ sub = parser.add_subparsers(dest="command", required=True)
634
+
635
+ p = sub.add_parser(
636
+ "search", help="广搜索:OpenAI Chat 最高优先级,失败后按 intent fallback"
637
+ )
638
+ p.add_argument("--query", required=True)
639
+ p.add_argument(
640
+ "--intent", default="general", choices=["general", "docs", "news", "research"]
641
+ )
642
+ p.add_argument(
643
+ "--service",
644
+ default="auto",
645
+ help="auto 或逗号分隔服务:openai-chat,context7,exa,tavily",
646
+ )
647
+ p.add_argument("--no-fallback", action="store_true")
648
+ p.add_argument("--library", help="文档类搜索的库名或 Context7 libraryId")
649
+ p.add_argument("--max-results", type=int, default=5)
650
+ p.add_argument("--include-domains", help="逗号分隔域名")
651
+ p.add_argument("--exclude-domains", help="逗号分隔域名")
652
+ p.add_argument("--include-text", action="store_true")
653
+ p.add_argument("--include-summary", action="store_true")
654
+ p.add_argument("--fresh", action="store_true", help="Exa 强制实时抓取;可能更慢")
655
+ p.add_argument(
656
+ "--category", help="Exa category,如 news/company/people/research paper"
657
+ )
658
+ p.add_argument(
659
+ "--exa-type",
660
+ default="auto",
661
+ choices=["auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"],
662
+ )
663
+ p.add_argument(
664
+ "--tavily-depth",
665
+ default="basic",
666
+ choices=["basic", "advanced", "fast", "ultra-fast"],
667
+ )
668
+ p.add_argument("--topic", default="general", choices=["general", "news", "finance"])
669
+ p.add_argument("--time-range", choices=["day", "week", "month", "year"])
670
+ p.add_argument(
671
+ "--include-answer", action="store_true", help="Tavily include_answer=true"
672
+ )
673
+ p.add_argument("--include-usage", action="store_true")
674
+ p.add_argument("--max-characters", type=int, default=8000)
675
+ p.add_argument("--output-schema-json")
676
+ p.add_argument("--system-prompt")
677
+ p.add_argument("--fast-context7", action="store_true")
678
+ _add_openai_chat_args(p)
679
+ p.set_defaults(func=command_search)
680
+
681
+ p = sub.add_parser(
682
+ "docs",
683
+ help="库文档查询:Context7 优先,失败后 Exa/Tavily,OpenAI Chat 末位兜底",
684
+ )
685
+ p.add_argument("--library", required=True)
686
+ p.add_argument("--query", required=True)
687
+ p.add_argument(
688
+ "--service",
689
+ default="auto",
690
+ help="auto 或逗号分隔服务:context7,exa,tavily,openai-chat",
691
+ )
692
+ p.add_argument("--no-fallback", action="store_true")
693
+ p.add_argument("--max-results", type=int, default=5)
694
+ p.add_argument("--include-text", action="store_true")
695
+ p.add_argument("--include-domains")
696
+ p.add_argument("--exclude-domains")
697
+ p.add_argument(
698
+ "--exa-type",
699
+ default="auto",
700
+ choices=["auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"],
701
+ )
702
+ p.add_argument(
703
+ "--tavily-depth",
704
+ default="basic",
705
+ choices=["basic", "advanced", "fast", "ultra-fast"],
706
+ )
707
+ p.add_argument("--max-characters", type=int, default=8000)
708
+ p.add_argument("--fast-context7", action="store_true")
709
+ _add_openai_chat_args(p)
710
+ p.set_defaults(func=command_docs)
711
+
712
+ p = sub.add_parser(
713
+ "fetch",
714
+ help="URL fetch/提取:单一入口,本地优先;必要时自动 Playwright,再 Tavily/Exa fallback",
715
+ )
716
+ p.add_argument("--urls", required=True, help="逗号分隔 URL")
717
+ p.add_argument(
718
+ "--query", help="本地/Playwright 失败后传给 Tavily/Exa 的重排/高亮查询"
719
+ )
720
+ p.add_argument(
721
+ "--service",
722
+ default="auto",
723
+ help="auto 或逗号分隔服务:local-fetch,playwright,tavily,exa",
724
+ )
725
+ p.add_argument(
726
+ "--no-fallback",
727
+ action="store_true",
728
+ help="只尝试服务链中的第一个服务;auto 时只尝试 local-fetch",
729
+ )
730
+ p.add_argument(
731
+ "--with-js",
732
+ action="store_true",
733
+ help="本地失败后强制尝试 Playwright 渲染抓取,再 fallback 到 Tavily/Exa",
734
+ )
735
+ p.add_argument(
736
+ "--no-auto-playwright",
737
+ dest="auto_playwright",
738
+ action="store_false",
739
+ help="禁用低质量 JS/拦截页时的自动 Playwright 尝试",
740
+ )
741
+ p.set_defaults(auto_playwright=True)
742
+ p.add_argument(
743
+ "--wait-ms",
744
+ type=int,
745
+ help="Playwright 页面打开后额外等待毫秒;默认 PLAYWRIGHT_WAIT_MS 或 1000",
746
+ )
747
+ p.add_argument("--selector", help="Playwright 优先抽取的 CSS selector")
748
+ p.add_argument(
749
+ "--playwright-timeout",
750
+ type=float,
751
+ help="Playwright 单命令超时秒数;默认 FETCH_PLAYWRIGHT_STAGE_TIMEOUT 或 12,并受 --total-timeout 本地预算约束",
752
+ )
753
+ p.add_argument(
754
+ "--playwright-retries",
755
+ type=int,
756
+ help="Playwright open 失败重试次数;默认 PLAYWRIGHT_RETRIES 或 0,上限 1",
757
+ )
758
+ p.add_argument(
759
+ "--proxy",
760
+ help="仅自实现抓取使用的代理地址,如 127.0.0.1:7890;不传给 Tavily/Exa/Context7 API",
761
+ )
762
+ p.add_argument("--advanced", action="store_true", help="Tavily advanced extract")
763
+ p.add_argument("--include-images", action="store_true")
764
+ p.add_argument("--include-favicon", action="store_true")
765
+ p.add_argument("--format", default="markdown", choices=["markdown", "text"])
766
+ p.add_argument(
767
+ "--total-timeout",
768
+ type=float,
769
+ default=DEFAULT_FETCH_TOTAL_TIMEOUT,
770
+ help="local-fetch + Playwright 的本地阶段预算秒数;默认 25;API fallback 默认不强制裁剪,避免已计费请求因客户端超时丢结果",
771
+ )
772
+ p.add_argument(
773
+ "--timeout",
774
+ type=float,
775
+ help="local-fetch 单请求超时上限;不传给 Tavily/Exa API",
776
+ )
777
+ p.add_argument(
778
+ "--api-timeout",
779
+ type=float,
780
+ help="显式设置 Tavily/Exa HTTP 超时秒数;慎用,过短可能导致已消耗额度但拿不到结果",
781
+ )
782
+ p.add_argument(
783
+ "--retries",
784
+ type=int,
785
+ help="本地 fetch 失败重试次数;默认读取 FETCH_RETRIES 或 1",
786
+ )
787
+ p.add_argument("--retry-delay", type=float, help="本地 fetch 重试初始等待秒数")
788
+ p.add_argument(
789
+ "--retry-jitter",
790
+ type=float,
791
+ default=0.0,
792
+ help="本地 fetch 每次重试额外随机等待上限秒数",
793
+ )
794
+ p.add_argument(
795
+ "--retry-on-any",
796
+ action="store_true",
797
+ help="本地 fetch 对任意 HTTP 错误码也重试",
798
+ )
799
+ p.add_argument("--referer", help="本地 fetch 的 Referer 请求头")
800
+ p.add_argument("--accept-language", help="本地 fetch 的 Accept-Language 请求头")
801
+ p.add_argument(
802
+ "--header",
803
+ action="append",
804
+ help="本地 fetch 自定义请求头,格式 'Key: Value';可重复",
805
+ )
806
+ p.add_argument(
807
+ "--extract-mode",
808
+ choices=["auto", "parser", "bs4"],
809
+ default="auto",
810
+ help="本地 HTML 正文抽取模式",
811
+ )
812
+ p.add_argument(
813
+ "--quality-threshold",
814
+ type=float,
815
+ default=float(
816
+ os.getenv("LOCAL_FETCH_QUALITY_THRESHOLD")
817
+ or os.getenv("FETCH_QUALITY_THRESHOLD")
818
+ or "0.30"
819
+ ),
820
+ help="本地/Playwright fetch 质量评分阈值(0-1),低于阈值才 fallback",
821
+ )
822
+ p.add_argument("--include-usage", action="store_true")
823
+ p.add_argument("--max-characters", type=int, default=20000)
824
+ p.set_defaults(func=command_fetch)
825
+
826
+ p = sub.add_parser(
827
+ "simple-fetch", help="无消耗简单抓取:本地 Python 清理 HTML 噪声并整理格式"
828
+ )
829
+ p.add_argument("--urls", required=True, help="逗号分隔 URL")
830
+ p.add_argument("--timeout", type=float)
831
+ p.add_argument("--max-characters", type=int, default=20000)
832
+ p.add_argument("--keep-links", action="store_true", help="保留 a 标签链接")
833
+ p.add_argument(
834
+ "--keep-structural-noise",
835
+ action="store_true",
836
+ help="不丢弃 nav/header/footer/aside/form",
837
+ )
838
+ p.add_argument("--plain", action="store_true", help="不做标题式 markdown 整理")
839
+ p.add_argument("--user-agent")
840
+ p.add_argument("--accept-language", help="覆盖 Accept-Language")
841
+ p.add_argument("--referer", help="设置 Referer 请求头")
842
+ p.add_argument("--proxy", help="仅本地 fetch 使用的代理地址,如 127.0.0.1:7890")
843
+ p.add_argument(
844
+ "--header", action="append", help="追加自定义请求头,格式:'Key: Value';可重复"
845
+ )
846
+ p.add_argument(
847
+ "--extract-mode",
848
+ choices=["auto", "parser", "bs4"],
849
+ default="auto",
850
+ help="HTML 正文抽取模式",
851
+ )
852
+ p.add_argument(
853
+ "--retries", type=int, help="失败重试次数;默认读取 FETCH_RETRIES 或 1"
854
+ )
855
+ p.add_argument("--retry-delay", type=float, help="重试初始等待秒数")
856
+ p.add_argument(
857
+ "--retry-jitter", type=float, default=0.0, help="每次重试额外随机等待上限秒数"
858
+ )
859
+ p.add_argument(
860
+ "--retry-on-any", action="store_true", help="对任意 HTTP 错误码也重试"
861
+ )
862
+ p.set_defaults(func=command_simple_fetch)
863
+
864
+ p = sub.add_parser(
865
+ "research",
866
+ help="深度研究:Exa deep 搜索优先,Tavily Research fallback,OpenAI Chat 末位兜底",
867
+ )
868
+ p.add_argument("--query", required=True)
869
+ p.add_argument(
870
+ "--service", default="auto", help="auto 或逗号分隔服务:exa,tavily,openai-chat"
871
+ )
872
+ p.add_argument("--no-fallback", action="store_true")
873
+ p.add_argument("--max-results", type=int, default=8)
874
+ p.add_argument("--include-domains")
875
+ p.add_argument("--exclude-domains")
876
+ p.add_argument(
877
+ "--exa-type",
878
+ default="deep",
879
+ choices=["deep-lite", "deep", "deep-reasoning", "auto", "fast", "instant"],
880
+ )
881
+ p.add_argument("--tavily-model", default="auto", choices=["mini", "pro", "auto"])
882
+ p.add_argument("--output-length")
883
+ p.add_argument("--output-schema-json")
884
+ p.add_argument("--system-prompt")
885
+ p.add_argument("--max-characters", type=int, default=12000)
886
+ _add_openai_chat_args(p)
887
+ p.set_defaults(func=command_research)
888
+
889
+ # 为 help 暴露服务透传入口;实际处理在 main() 开头完成。
890
+ for svc in [
891
+ "context7",
892
+ "exa",
893
+ "tavily",
894
+ "openai-chat",
895
+ "local-fetch",
896
+ "playwright",
897
+ ]:
898
+ target = (
899
+ "openai_chat"
900
+ if svc == "openai-chat"
901
+ else (
902
+ "fetch_playwright"
903
+ if svc == "playwright"
904
+ else ("fetch" if svc == "local-fetch" else svc)
905
+ )
906
+ )
907
+ p = sub.add_parser(svc, help=f"透传到 scripts/{target}.py")
908
+ p.add_argument("service_args", nargs=argparse.REMAINDER)
909
+
910
+ return parser
911
+
912
+
913
+ def main(argv: list[str] | None = None) -> int:
914
+ argv = list(sys.argv[1:] if argv is None else argv)
915
+ if argv and argv[0] in {
916
+ "context7",
917
+ "exa",
918
+ "tavily",
919
+ "openai-chat",
920
+ "local-fetch",
921
+ "playwright",
922
+ }:
923
+ service = argv[0]
924
+ rest = argv[1:]
925
+ if service == "context7":
926
+ return context7.main(rest)
927
+ if service == "exa":
928
+ return exa.main(rest)
929
+ if service == "tavily":
930
+ return tavily.main(rest)
931
+ if service == "openai-chat":
932
+ return openai_chat.main(rest)
933
+ if service == "local-fetch":
934
+ return local_fetch.main(rest)
935
+ if service == "playwright":
936
+ return fetch_playwright.main(rest)
937
+
938
+ parser = build_parser()
939
+ args = parser.parse_args(argv)
940
+ if not hasattr(args, "func"):
941
+ parser.print_help()
942
+ return 2
943
+ try:
944
+ _print_json(args.func(args))
945
+ return 0
946
+ except FallbackFailure as exc:
947
+ _print_json({"ok": False, "error": str(exc), "attempts": exc.attempts})
948
+ return 1
949
+ except Exception as exc: # noqa: BLE001 - CLI 入口需要结构化错误
950
+ _print_json(
951
+ {
952
+ "ok": False,
953
+ "error": str(exc),
954
+ "status": getattr(exc, "status", None),
955
+ "details": getattr(exc, "details", None),
956
+ }
957
+ )
958
+ return 1
959
+
960
+
961
+ if __name__ == "__main__":
962
+ sys.exit(main())