@dsh-plus/web-search-services 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +22 -7
- package/package.json +3 -2
- package/scripts/README.md +34 -0
- package/scripts/context7.py +259 -0
- package/scripts/env_loader.py +46 -0
- package/scripts/exa.py +251 -0
- package/scripts/fetch.py +938 -0
- package/scripts/fetch_playwright.py +492 -0
- package/scripts/openai_chat.py +570 -0
- package/scripts/search.py +962 -0
- package/scripts/tavily.py +609 -0
- package/src/config.ts +3 -2
- package/src/normalize.ts +16 -6
- package/src/provider.ts +13 -2
|
@@ -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())
|