@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.
- package/lib/index.js +12 -4
- 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/provider.ts +13 -2
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""可选 Playwright 渲染 fetch 后端。
|
|
3
|
+
|
|
4
|
+
该脚本通过 playwright-cli 在 headless Chromium 中打开页面,等待动态内容渲染,
|
|
5
|
+
抽取正文后复用 fetch.py 的清理和质量评估逻辑。它是本地 fetch 的增强层:
|
|
6
|
+
默认不被普通 fetch 启动,只在 search.py fetch 的自动 JS 判断或显式 --with-js /
|
|
7
|
+
--service playwright 时使用。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import atexit
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, Optional
|
|
25
|
+
|
|
26
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
if SCRIPT_DIR not in sys.path:
|
|
28
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
29
|
+
|
|
30
|
+
import fetch as local_fetch # noqa: E402
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
DEFAULT_SELECTOR = "main, article, [role='main'], .content, #content, .post, .entry-content"
|
|
34
|
+
_ACTIVE_PGIDS: set[int] = set()
|
|
35
|
+
_ACTIVE_BROWSER_PIDS: set[int] = set()
|
|
36
|
+
_TEMP_DIRS: set[str] = set()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PlaywrightUnavailable(RuntimeError):
|
|
40
|
+
"""Playwright CLI 不可用。"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, *, details: Any = None):
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.details = details
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PlaywrightError(RuntimeError):
|
|
48
|
+
"""Playwright fetch 失败。"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str, *, details: Any = None):
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.details = details
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _safe_rmtree(path: str) -> None:
|
|
56
|
+
"""只清理本脚本创建的 /tmp 临时目录,避免误删。"""
|
|
57
|
+
|
|
58
|
+
real = os.path.realpath(path)
|
|
59
|
+
if not real.startswith("/tmp/search-services-pw-"):
|
|
60
|
+
return
|
|
61
|
+
shutil.rmtree(real, ignore_errors=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cleanup() -> None:
|
|
65
|
+
for pgid in list(_ACTIVE_PGIDS):
|
|
66
|
+
try:
|
|
67
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
68
|
+
except ProcessLookupError:
|
|
69
|
+
pass
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
time.sleep(0.1)
|
|
73
|
+
for pgid in list(_ACTIVE_PGIDS):
|
|
74
|
+
try:
|
|
75
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
76
|
+
except ProcessLookupError:
|
|
77
|
+
pass
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
for pid in list(_ACTIVE_BROWSER_PIDS):
|
|
81
|
+
try:
|
|
82
|
+
os.kill(pid, signal.SIGTERM)
|
|
83
|
+
except ProcessLookupError:
|
|
84
|
+
pass
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
time.sleep(0.1)
|
|
88
|
+
for pid in list(_ACTIVE_BROWSER_PIDS):
|
|
89
|
+
try:
|
|
90
|
+
os.kill(pid, signal.SIGKILL)
|
|
91
|
+
except ProcessLookupError:
|
|
92
|
+
pass
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
for temp_dir in list(_TEMP_DIRS):
|
|
96
|
+
_safe_rmtree(temp_dir)
|
|
97
|
+
_TEMP_DIRS.discard(temp_dir)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _handle_signal(signum: int, _frame: Any) -> None:
|
|
101
|
+
_cleanup()
|
|
102
|
+
raise SystemExit(128 + signum)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
atexit.register(_cleanup)
|
|
106
|
+
for _sig in (signal.SIGINT, signal.SIGTERM):
|
|
107
|
+
try:
|
|
108
|
+
signal.signal(_sig, _handle_signal)
|
|
109
|
+
except (ValueError, OSError):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _playwright_command_base() -> list[str]:
|
|
114
|
+
configured = os.getenv("PLAYWRIGHT_CLI") or os.getenv("PWCLI")
|
|
115
|
+
if configured:
|
|
116
|
+
path = os.path.expanduser(configured)
|
|
117
|
+
if os.path.exists(path) or shutil.which(path):
|
|
118
|
+
return [path]
|
|
119
|
+
raise PlaywrightUnavailable(f"PLAYWRIGHT_CLI/PWCLI 指向的命令不存在: {configured}")
|
|
120
|
+
|
|
121
|
+
global_cli = shutil.which("playwright-cli")
|
|
122
|
+
if global_cli:
|
|
123
|
+
return [global_cli]
|
|
124
|
+
|
|
125
|
+
codex_home = os.getenv("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
|
|
126
|
+
wrapper = os.path.join(codex_home, "skills", "playwright-headless", "scripts", "playwright_cli.sh")
|
|
127
|
+
if os.path.exists(wrapper):
|
|
128
|
+
return [wrapper]
|
|
129
|
+
|
|
130
|
+
npx = shutil.which("npx")
|
|
131
|
+
if npx:
|
|
132
|
+
return [npx, "--yes", "--package", "@playwright/cli", "playwright-cli"]
|
|
133
|
+
|
|
134
|
+
raise PlaywrightUnavailable("缺少 npx/playwright-cli,无法使用 Playwright 渲染 fetch")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _playwright_retries(value: Optional[int]) -> int:
|
|
138
|
+
retries = int(os.getenv("PLAYWRIGHT_RETRIES") or "0") if value is None else max(0, value)
|
|
139
|
+
return min(retries, int(os.getenv("PLAYWRIGHT_MAX_RETRIES") or "1"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _write_proxy_config(temp_dir: str, proxy: Optional[str]) -> Optional[str]:
|
|
143
|
+
proxy_url = local_fetch.normalize_proxy_url(proxy)
|
|
144
|
+
if not proxy_url:
|
|
145
|
+
return None
|
|
146
|
+
config_dir = Path(temp_dir) / ".playwright"
|
|
147
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
config_path = config_dir / "cli.config.json"
|
|
149
|
+
config_path.write_text(json.dumps({
|
|
150
|
+
"browser": {
|
|
151
|
+
"launchOptions": {
|
|
152
|
+
"headless": True,
|
|
153
|
+
"proxy": {"server": proxy_url},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}, ensure_ascii=False), encoding="utf-8")
|
|
157
|
+
return str(config_path)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _open_command(base: list[str], session: str, url: str, config_path: Optional[str]) -> list[str]:
|
|
161
|
+
cmd = base + ["--session", session, "open", url]
|
|
162
|
+
if config_path:
|
|
163
|
+
cmd.extend(["--config", config_path])
|
|
164
|
+
return cmd
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run_checked_with_retries(cmd: list[str], *, timeout: float, cwd: str, retries: int, retry_delay: float = 0.75) -> str:
|
|
168
|
+
last_error: Optional[PlaywrightError] = None
|
|
169
|
+
for attempt in range(retries + 1):
|
|
170
|
+
try:
|
|
171
|
+
return _run_checked(cmd, timeout=timeout, cwd=cwd)
|
|
172
|
+
except PlaywrightError as exc:
|
|
173
|
+
last_error = exc
|
|
174
|
+
if attempt >= retries:
|
|
175
|
+
break
|
|
176
|
+
time.sleep(min(3.0, retry_delay * (1.6 ** attempt)))
|
|
177
|
+
if last_error is not None:
|
|
178
|
+
raise last_error
|
|
179
|
+
raise PlaywrightError("Playwright 命令失败")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _run_command(cmd: list[str], *, timeout: float, cwd: str) -> tuple[int, str, str]:
|
|
183
|
+
proc = subprocess.Popen(
|
|
184
|
+
cmd,
|
|
185
|
+
stdout=subprocess.PIPE,
|
|
186
|
+
stderr=subprocess.PIPE,
|
|
187
|
+
text=True,
|
|
188
|
+
cwd=cwd,
|
|
189
|
+
start_new_session=True,
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
pgid = os.getpgid(proc.pid)
|
|
193
|
+
_ACTIVE_PGIDS.add(pgid)
|
|
194
|
+
except OSError:
|
|
195
|
+
pgid = None
|
|
196
|
+
try:
|
|
197
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
198
|
+
return proc.returncode, stdout, stderr
|
|
199
|
+
except subprocess.TimeoutExpired as exc:
|
|
200
|
+
if pgid is not None:
|
|
201
|
+
try:
|
|
202
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
203
|
+
except ProcessLookupError:
|
|
204
|
+
pass
|
|
205
|
+
except OSError:
|
|
206
|
+
pass
|
|
207
|
+
time.sleep(0.2)
|
|
208
|
+
try:
|
|
209
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
210
|
+
except ProcessLookupError:
|
|
211
|
+
pass
|
|
212
|
+
except OSError:
|
|
213
|
+
pass
|
|
214
|
+
try:
|
|
215
|
+
proc.wait(timeout=2)
|
|
216
|
+
except subprocess.TimeoutExpired:
|
|
217
|
+
pass
|
|
218
|
+
raise PlaywrightError(f"Playwright 命令超时: {' '.join(cmd[-3:])}", details={"timeout": timeout}) from exc
|
|
219
|
+
finally:
|
|
220
|
+
if pgid is not None:
|
|
221
|
+
_ACTIVE_PGIDS.discard(pgid)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _run_checked(cmd: list[str], *, timeout: float, cwd: str) -> str:
|
|
225
|
+
code, stdout, stderr = _run_command(cmd, timeout=timeout, cwd=cwd)
|
|
226
|
+
if code != 0:
|
|
227
|
+
raise PlaywrightError(
|
|
228
|
+
f"Playwright 命令失败,退出码 {code}",
|
|
229
|
+
details={"cmdTail": cmd[-4:], "stdout": stdout[-2000:], "stderr": stderr[-2000:]},
|
|
230
|
+
)
|
|
231
|
+
return stdout
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _extract_browser_pid(output: str) -> Optional[int]:
|
|
235
|
+
match = re.search(r"opened with pid\s+(\d+)", output)
|
|
236
|
+
if not match:
|
|
237
|
+
return None
|
|
238
|
+
try:
|
|
239
|
+
return int(match.group(1))
|
|
240
|
+
except ValueError:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _extract_cli_result(output: str) -> Any:
|
|
245
|
+
match = re.search(r"### Result\s*\n(?P<value>.*?)(?:\n### |\Z)", output, re.S)
|
|
246
|
+
if not match:
|
|
247
|
+
raise PlaywrightError("无法解析 playwright-cli eval 结果", details={"stdout": output[-2000:]})
|
|
248
|
+
raw = match.group("value").strip()
|
|
249
|
+
try:
|
|
250
|
+
parsed = json.loads(raw)
|
|
251
|
+
except json.JSONDecodeError as exc:
|
|
252
|
+
raise PlaywrightError("playwright-cli eval 返回的结果不是 JSON", details={"raw": raw[:1000]}) from exc
|
|
253
|
+
if isinstance(parsed, str):
|
|
254
|
+
try:
|
|
255
|
+
return json.loads(parsed)
|
|
256
|
+
except json.JSONDecodeError:
|
|
257
|
+
return parsed
|
|
258
|
+
return parsed
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _build_eval_script(*, wait_ms: int, selector: Optional[str], max_chars: int) -> str:
|
|
262
|
+
selector_json = json.dumps(selector or "")
|
|
263
|
+
default_selector_json = json.dumps(DEFAULT_SELECTOR)
|
|
264
|
+
wait_ms = max(0, int(wait_ms))
|
|
265
|
+
max_chars = max(1, int(max_chars))
|
|
266
|
+
return f"""async () => {{
|
|
267
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
268
|
+
await sleep({wait_ms});
|
|
269
|
+
const explicitSelector = {selector_json};
|
|
270
|
+
const defaultSelector = {default_selector_json};
|
|
271
|
+
const root = (explicitSelector ? document.querySelector(explicitSelector) : null)
|
|
272
|
+
|| document.querySelector(defaultSelector)
|
|
273
|
+
|| document.body
|
|
274
|
+
|| document.documentElement;
|
|
275
|
+
const meta = document.querySelector('meta[name="description"], meta[property="og:description"]');
|
|
276
|
+
const text = root ? (root.innerText || root.textContent || '') : '';
|
|
277
|
+
return JSON.stringify({{
|
|
278
|
+
url: location.href,
|
|
279
|
+
title: document.title || '',
|
|
280
|
+
description: meta ? (meta.getAttribute('content') || '') : '',
|
|
281
|
+
text: text.slice(0, {max_chars * 2})
|
|
282
|
+
}});
|
|
283
|
+
}}"""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _clean_rendered_text(text: str, *, max_chars: int, markdownish: bool = True) -> tuple[str, bool]:
|
|
287
|
+
cleaned = local_fetch.normalize_text(text or "")
|
|
288
|
+
cleaned = local_fetch.filter_noise_lines(cleaned)
|
|
289
|
+
if markdownish:
|
|
290
|
+
cleaned = local_fetch.to_markdownish(cleaned)
|
|
291
|
+
truncated = len(cleaned) > max_chars
|
|
292
|
+
if truncated:
|
|
293
|
+
cleaned = cleaned[:max_chars].rstrip()
|
|
294
|
+
return cleaned, truncated
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def fetch_one(
|
|
298
|
+
url: str,
|
|
299
|
+
*,
|
|
300
|
+
timeout: Optional[float] = None,
|
|
301
|
+
wait_ms: Optional[int] = None,
|
|
302
|
+
selector: Optional[str] = None,
|
|
303
|
+
max_chars: int = 20000,
|
|
304
|
+
markdownish: bool = True,
|
|
305
|
+
session: Optional[str] = None,
|
|
306
|
+
proxy: Optional[str] = None,
|
|
307
|
+
retries: Optional[int] = None,
|
|
308
|
+
) -> Dict[str, Any]:
|
|
309
|
+
timeout = timeout or float(os.getenv("PLAYWRIGHT_TIMEOUT") or os.getenv("SEARCH_TIMEOUT") or "30")
|
|
310
|
+
wait_ms = int(os.getenv("PLAYWRIGHT_WAIT_MS") or "1000") if wait_ms is None else wait_ms
|
|
311
|
+
session = session or f"search-fetch-{os.getpid()}-{int(time.time() * 1000)}"
|
|
312
|
+
temp_dir = tempfile.mkdtemp(prefix="search-services-pw-")
|
|
313
|
+
_TEMP_DIRS.add(temp_dir)
|
|
314
|
+
base = _playwright_command_base()
|
|
315
|
+
config_path = _write_proxy_config(temp_dir, proxy)
|
|
316
|
+
proxy_url = local_fetch.normalize_proxy_url(proxy)
|
|
317
|
+
max_retries = _playwright_retries(retries)
|
|
318
|
+
browser_pid: Optional[int] = None
|
|
319
|
+
opened = False
|
|
320
|
+
try:
|
|
321
|
+
open_cmd = _open_command(base, session, url, config_path)
|
|
322
|
+
open_out = _run_checked_with_retries(open_cmd, timeout=timeout, cwd=temp_dir, retries=max_retries)
|
|
323
|
+
opened = True
|
|
324
|
+
browser_pid = _extract_browser_pid(open_out)
|
|
325
|
+
if browser_pid is not None:
|
|
326
|
+
_ACTIVE_BROWSER_PIDS.add(browser_pid)
|
|
327
|
+
|
|
328
|
+
eval_script = _build_eval_script(wait_ms=wait_ms, selector=selector, max_chars=max_chars)
|
|
329
|
+
eval_timeout = timeout + max(1.0, wait_ms / 1000.0) + 2.0
|
|
330
|
+
eval_cmd = base + ["--session", session, "eval", eval_script]
|
|
331
|
+
eval_out = _run_checked(eval_cmd, timeout=eval_timeout, cwd=temp_dir)
|
|
332
|
+
payload = _extract_cli_result(eval_out)
|
|
333
|
+
if isinstance(payload, str):
|
|
334
|
+
payload = {"url": url, "title": "", "description": "", "text": payload}
|
|
335
|
+
text, truncated = _clean_rendered_text(payload.get("text") or "", max_chars=max_chars, markdownish=markdownish)
|
|
336
|
+
result = {
|
|
337
|
+
"ok": True,
|
|
338
|
+
"url": payload.get("url") or url,
|
|
339
|
+
"status": 200,
|
|
340
|
+
"contentType": "text/html; rendered=playwright",
|
|
341
|
+
"title": local_fetch.normalize_inline(payload.get("title") or ""),
|
|
342
|
+
"description": local_fetch.normalize_inline(payload.get("description") or ""),
|
|
343
|
+
"text": text,
|
|
344
|
+
"truncated": truncated,
|
|
345
|
+
"characters": len(text),
|
|
346
|
+
"rendered": True,
|
|
347
|
+
"waitMs": wait_ms,
|
|
348
|
+
"selector": selector,
|
|
349
|
+
"proxy": proxy_url,
|
|
350
|
+
}
|
|
351
|
+
return result
|
|
352
|
+
except (PlaywrightUnavailable, PlaywrightError) as exc:
|
|
353
|
+
return {
|
|
354
|
+
"ok": False,
|
|
355
|
+
"url": url,
|
|
356
|
+
"service": "playwright",
|
|
357
|
+
"rendered": True,
|
|
358
|
+
"error": str(exc),
|
|
359
|
+
"details": getattr(exc, "details", None),
|
|
360
|
+
}
|
|
361
|
+
finally:
|
|
362
|
+
if opened:
|
|
363
|
+
try:
|
|
364
|
+
close_cmd = base + ["--session", session, "close"]
|
|
365
|
+
_run_command(close_cmd, timeout=min(8.0, max(2.0, timeout / 3)), cwd=temp_dir)
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
if browser_pid is not None:
|
|
369
|
+
try:
|
|
370
|
+
os.kill(browser_pid, 0)
|
|
371
|
+
except ProcessLookupError:
|
|
372
|
+
_ACTIVE_BROWSER_PIDS.discard(browser_pid)
|
|
373
|
+
except OSError:
|
|
374
|
+
_ACTIVE_BROWSER_PIDS.discard(browser_pid)
|
|
375
|
+
else:
|
|
376
|
+
try:
|
|
377
|
+
os.kill(browser_pid, signal.SIGTERM)
|
|
378
|
+
time.sleep(0.2)
|
|
379
|
+
os.kill(browser_pid, signal.SIGKILL)
|
|
380
|
+
except ProcessLookupError:
|
|
381
|
+
pass
|
|
382
|
+
except OSError:
|
|
383
|
+
pass
|
|
384
|
+
_ACTIVE_BROWSER_PIDS.discard(browser_pid)
|
|
385
|
+
_safe_rmtree(temp_dir)
|
|
386
|
+
_TEMP_DIRS.discard(temp_dir)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def fetch_urls(
|
|
390
|
+
urls: list[str],
|
|
391
|
+
*,
|
|
392
|
+
timeout: Optional[float] = None,
|
|
393
|
+
wait_ms: Optional[int] = None,
|
|
394
|
+
selector: Optional[str] = None,
|
|
395
|
+
max_chars: int = 20000,
|
|
396
|
+
markdownish: bool = True,
|
|
397
|
+
min_quality_score: Optional[float] = None,
|
|
398
|
+
proxy: Optional[str] = None,
|
|
399
|
+
retries: Optional[int] = None,
|
|
400
|
+
) -> Dict[str, Any]:
|
|
401
|
+
threshold = local_fetch.DEFAULT_QUALITY_THRESHOLD if min_quality_score is None else min_quality_score
|
|
402
|
+
results = [
|
|
403
|
+
fetch_one(
|
|
404
|
+
url,
|
|
405
|
+
timeout=timeout,
|
|
406
|
+
wait_ms=wait_ms,
|
|
407
|
+
selector=selector,
|
|
408
|
+
max_chars=max_chars,
|
|
409
|
+
markdownish=markdownish,
|
|
410
|
+
proxy=proxy,
|
|
411
|
+
retries=retries,
|
|
412
|
+
)
|
|
413
|
+
for url in urls
|
|
414
|
+
]
|
|
415
|
+
failures: list[Dict[str, Any]] = []
|
|
416
|
+
for item in results:
|
|
417
|
+
quality = local_fetch.assess_quality(item, threshold=threshold)
|
|
418
|
+
item["quality"] = quality
|
|
419
|
+
if min_quality_score is not None and (not item.get("ok") or quality["score"] < threshold):
|
|
420
|
+
failures.append({
|
|
421
|
+
"url": item.get("url"),
|
|
422
|
+
"status": item.get("status"),
|
|
423
|
+
"score": quality["score"],
|
|
424
|
+
"threshold": threshold,
|
|
425
|
+
"reason": quality["reason"],
|
|
426
|
+
"flags": quality.get("flags"),
|
|
427
|
+
"suggestions": quality.get("suggestions"),
|
|
428
|
+
"error": item.get("error"),
|
|
429
|
+
})
|
|
430
|
+
result = {
|
|
431
|
+
"ok": any(item.get("ok") for item in results),
|
|
432
|
+
"allOk": all(item.get("ok") for item in results) if results else False,
|
|
433
|
+
"service": "playwright",
|
|
434
|
+
"data": {"results": results},
|
|
435
|
+
}
|
|
436
|
+
if min_quality_score is not None:
|
|
437
|
+
result["qualityThreshold"] = threshold
|
|
438
|
+
result["qualityPass"] = not failures
|
|
439
|
+
if failures:
|
|
440
|
+
result["qualityFailures"] = failures
|
|
441
|
+
raise local_fetch.LowQualityError(failures, result)
|
|
442
|
+
return result
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def split_csv(value: str) -> list[str]:
|
|
446
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
450
|
+
parser = argparse.ArgumentParser(description="Playwright 渲染 fetch:用于动态页面/JS 页面,本地可选增强后端")
|
|
451
|
+
parser.add_argument("--urls", required=True, help="逗号分隔 URL")
|
|
452
|
+
parser.add_argument("--timeout", type=float, help="每个 Playwright 命令的超时秒数")
|
|
453
|
+
parser.add_argument("--wait-ms", type=int, help="页面打开后额外等待毫秒;默认 PLAYWRIGHT_WAIT_MS 或 1000")
|
|
454
|
+
parser.add_argument("--selector", help="优先抽取的 CSS selector;未提供时自动选择 main/article/content/body")
|
|
455
|
+
parser.add_argument("--proxy", help="Playwright 浏览器代理地址,如 127.0.0.1:7890 或 http://127.0.0.1:7890")
|
|
456
|
+
parser.add_argument("--retries", type=int, help="Playwright open 失败重试次数;默认 PLAYWRIGHT_RETRIES 或 0,上限 1")
|
|
457
|
+
parser.add_argument("--max-characters", type=int, default=20000)
|
|
458
|
+
parser.add_argument("--plain", action="store_true", help="不做标题式 markdown 整理")
|
|
459
|
+
parser.add_argument("--min-quality", type=float, help="质量评分阈值(0-1);低于阈值时退出码为 1")
|
|
460
|
+
parser.add_argument("--format", choices=["json", "markdown", "text"], default="json")
|
|
461
|
+
args = parser.parse_args(argv)
|
|
462
|
+
|
|
463
|
+
exit_code = 0
|
|
464
|
+
try:
|
|
465
|
+
result = fetch_urls(
|
|
466
|
+
split_csv(args.urls),
|
|
467
|
+
timeout=args.timeout,
|
|
468
|
+
wait_ms=args.wait_ms,
|
|
469
|
+
selector=args.selector,
|
|
470
|
+
max_chars=args.max_characters,
|
|
471
|
+
markdownish=not args.plain,
|
|
472
|
+
min_quality_score=args.min_quality,
|
|
473
|
+
proxy=args.proxy,
|
|
474
|
+
retries=args.retries,
|
|
475
|
+
)
|
|
476
|
+
except local_fetch.LowQualityError as exc:
|
|
477
|
+
result = exc.result
|
|
478
|
+
result["ok"] = False
|
|
479
|
+
result["error"] = str(exc)
|
|
480
|
+
exit_code = 1
|
|
481
|
+
|
|
482
|
+
if args.format == "json":
|
|
483
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
484
|
+
elif args.format == "markdown":
|
|
485
|
+
print(local_fetch.result_to_markdown(result))
|
|
486
|
+
else:
|
|
487
|
+
print("\n\n".join(item.get("text", "") for item in result.get("data", {}).get("results", [])))
|
|
488
|
+
return exit_code if exit_code else (0 if result.get("ok") else 1)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
if __name__ == "__main__":
|
|
492
|
+
sys.exit(main())
|