@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,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())