@dsh-plus/web-search-services 0.1.20 → 0.2.0

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