@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.
package/scripts/fetch.py DELETED
@@ -1,938 +0,0 @@
1
- #!/usr/bin/env python3
2
- """本地优先、无 API 消耗的 URL fetch 脚本。
3
-
4
- 用于已知 URL 的轻量获取、HTML 正文清理、杂乱信息过滤、基础格式整理和
5
- 质量评估。默认不调用任何付费搜索/提取服务,不需要 API Key。
6
-
7
- 当由 scripts/search.py fetch 调用时,可通过质量阈值让低质量结果触发
8
- Tavily / Exa fallback,以减少不必要的 API fetch 调用费用。
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import argparse
14
- import gzip
15
- import html
16
- import json
17
- import os
18
- import random
19
- import re
20
- import sys
21
- import time
22
- import urllib.error
23
- import urllib.parse
24
- import urllib.request
25
- import zlib
26
- from html.parser import HTMLParser
27
- from typing import Any, Dict, Optional
28
-
29
- try:
30
- from env_loader import load_env_file
31
-
32
- load_env_file()
33
- except Exception:
34
- # 本地 fetch 不应因为私有 env 文件异常而失效;显式环境变量仍可直接生效。
35
- pass
36
-
37
-
38
- # 默认使用常见浏览器 UA,减少部分站点因非浏览器 UA 直接拒绝本地抓取的概率。
39
- DEFAULT_USER_AGENT = (
40
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
41
- "(KHTML, like Gecko) Chrome/124.0 Safari/537.36 pi-search-services-skill/local-fetch"
42
- )
43
- DEFAULT_QUALITY_THRESHOLD = 0.30
44
- RETRY_STATUSES = {408, 425, 429, 500, 502, 503, 504}
45
- TEXT_CONTENT_TYPES = {
46
- "application/atom+xml",
47
- "application/javascript",
48
- "application/json",
49
- "application/ld+json",
50
- "application/rss+xml",
51
- "application/xhtml+xml",
52
- "application/xml",
53
- "application/yaml",
54
- "application/x-yaml",
55
- "text/csv",
56
- "text/html",
57
- "text/markdown",
58
- "text/plain",
59
- "text/xml",
60
- }
61
-
62
-
63
- class LowQualityError(RuntimeError):
64
- """本地 fetch 失败或质量不足,可由上层用于触发 API fallback。"""
65
-
66
- def __init__(self, failures: list[Dict[str, Any]], result: Dict[str, Any]):
67
- self.failures = failures
68
- self.result = result
69
- self.details = {"failures": failures, "qualityFailures": failures}
70
- summary = "; ".join(
71
- f"{item.get('url')}: {item.get('reason')} (score={item.get('score')})"
72
- for item in failures[:3]
73
- )
74
- if len(failures) > 3:
75
- summary += f"; ... 共 {len(failures)} 个失败/低质量 URL"
76
- super().__init__(f"本地 fetch 返回失败或质量不足:{summary}")
77
-
78
-
79
- class ReadableHTMLParser(HTMLParser):
80
- """将常见 HTML 转成可读文本,尽量丢弃脚本、样式、导航等噪声。"""
81
-
82
- BLOCK_TAGS = {
83
- "address", "article", "aside", "blockquote", "br", "dd", "details", "dialog",
84
- "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form",
85
- "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav",
86
- "ol", "p", "pre", "section", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul",
87
- }
88
- SKIP_TAGS = {"script", "style", "noscript", "svg", "canvas", "iframe", "template"}
89
- STRUCTURAL_NOISE_TAGS = {"nav", "footer", "header", "aside", "form"}
90
-
91
- def __init__(self, *, keep_links: bool = False, drop_structural_noise: bool = True) -> None:
92
- super().__init__(convert_charrefs=True)
93
- self.keep_links = keep_links
94
- self.drop_structural_noise = drop_structural_noise
95
- self.parts: list[str] = []
96
- self.title_parts: list[str] = []
97
- self.meta_description = ""
98
- self._skip_depth = 0
99
- self._title_depth = 0
100
- self._link_href: Optional[str] = None
101
-
102
- def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
103
- tag = tag.lower()
104
- attrs_dict = {k.lower(): v for k, v in attrs if k}
105
- if tag == "title":
106
- self._title_depth += 1
107
- if tag == "meta":
108
- name = (attrs_dict.get("name") or attrs_dict.get("property") or "").lower()
109
- if name in {"description", "og:description"} and attrs_dict.get("content") and not self.meta_description:
110
- self.meta_description = attrs_dict["content"] or ""
111
- if tag in self.SKIP_TAGS or (self.drop_structural_noise and tag in self.STRUCTURAL_NOISE_TAGS):
112
- self._skip_depth += 1
113
- return
114
- if self._skip_depth:
115
- return
116
- if tag in self.BLOCK_TAGS:
117
- self.parts.append("\n")
118
- if tag == "a" and self.keep_links:
119
- self._link_href = attrs_dict.get("href")
120
-
121
- def handle_endtag(self, tag: str) -> None:
122
- tag = tag.lower()
123
- if tag == "title" and self._title_depth:
124
- self._title_depth -= 1
125
- if tag in self.SKIP_TAGS or (self.drop_structural_noise and tag in self.STRUCTURAL_NOISE_TAGS):
126
- if self._skip_depth:
127
- self._skip_depth -= 1
128
- return
129
- if self._skip_depth:
130
- return
131
- if tag == "a" and self.keep_links and self._link_href:
132
- self.parts.append(f" ({self._link_href})")
133
- self._link_href = None
134
- if tag in self.BLOCK_TAGS:
135
- self.parts.append("\n")
136
-
137
- def handle_data(self, data: str) -> None:
138
- if self._title_depth:
139
- self.title_parts.append(data)
140
- return
141
- if self._skip_depth:
142
- return
143
- if data and data.strip():
144
- self.parts.append(data)
145
-
146
- @property
147
- def title(self) -> str:
148
- return normalize_inline(" ".join(self.title_parts))
149
-
150
- @property
151
- def text(self) -> str:
152
- return "".join(self.parts)
153
-
154
-
155
- def normalize_inline(value: str) -> str:
156
- return re.sub(r"\s+", " ", html.unescape(value or "")).strip()
157
-
158
-
159
- def normalize_text(value: str) -> str:
160
- value = html.unescape(value or "")
161
- value = value.replace("\r\n", "\n").replace("\r", "\n")
162
- # 保留段落边界,但压缩行内空白。
163
- lines = [re.sub(r"[\t \f\v]+", " ", line).strip() for line in value.split("\n")]
164
- text = "\n".join(lines)
165
- text = re.sub(r"\n{3,}", "\n\n", text)
166
- return text.strip()
167
-
168
-
169
- def filter_noise_lines(text: str, *, min_line_chars: int = 2) -> str:
170
- """过滤重复行和常见站点噪声。"""
171
-
172
- noise_patterns = [
173
- r"^(skip to|jump to)\b",
174
- r"^(sign in|log in|login|register|subscribe|advertisement)$",
175
- r"^(accept|reject|manage) (all )?(cookies|cookie settings)\b",
176
- r"^(privacy policy|terms of service|cookie policy)$",
177
- r"^(share|tweet|follow us|copyright)\b",
178
- r"^©\s?\d{4}",
179
- r"^\s*[·•|]+\s*$",
180
- ]
181
- compiled = [re.compile(p, re.I) for p in noise_patterns]
182
- seen: set[str] = set()
183
- kept: list[str] = []
184
- blank_pending = False
185
- for raw_line in text.split("\n"):
186
- line = raw_line.strip()
187
- if not line:
188
- if kept:
189
- blank_pending = True
190
- continue
191
- if len(line) < min_line_chars:
192
- continue
193
- compact = re.sub(r"\W+", "", line.lower())
194
- if compact and compact in seen:
195
- continue
196
- if any(p.search(line) for p in compiled):
197
- continue
198
- # 菜单/标签云常见特征:短词密集且无句子结构。
199
- words = line.split()
200
- if len(words) > 10 and sum(1 for word in words if len(word) <= 2) / max(len(words), 1) > 0.55:
201
- continue
202
- if blank_pending:
203
- kept.append("")
204
- blank_pending = False
205
- kept.append(line)
206
- if compact:
207
- seen.add(compact)
208
- return "\n".join(kept).strip()
209
-
210
-
211
- def to_markdownish(text: str) -> str:
212
- """做轻量格式整理,不试图完整还原网页结构。"""
213
-
214
- lines = text.split("\n")
215
- out: list[str] = []
216
- previous_blank = True
217
- for line in lines:
218
- stripped = line.strip()
219
- if not stripped:
220
- if out and out[-1] != "":
221
- out.append("")
222
- previous_blank = True
223
- continue
224
- words = stripped.split()
225
- # 常见标题行:较短、位于段落边界、无句末标点、不是按钮/导航式极短词。
226
- looks_heading = (
227
- previous_blank
228
- and 4 <= len(stripped) <= 90
229
- and len(words) <= 12
230
- and not re.search(r"[。.!?!?::,,;;]$", stripped)
231
- and not re.fullmatch(r"[\w\-_/ ]{1,20}", stripped, re.I)
232
- )
233
- if looks_heading:
234
- if out and out[-1] != "":
235
- out.append("")
236
- out.append(f"## {stripped}")
237
- else:
238
- out.append(stripped)
239
- previous_blank = False
240
- return "\n".join(out).strip()
241
-
242
-
243
- def _decompress_body(raw: bytes, content_encoding: str) -> bytes:
244
- encoding = (content_encoding or "").lower()
245
- if "gzip" in encoding:
246
- try:
247
- return gzip.decompress(raw)
248
- except OSError:
249
- return raw
250
- if "deflate" in encoding:
251
- try:
252
- return zlib.decompress(raw)
253
- except zlib.error:
254
- try:
255
- return zlib.decompress(raw, -zlib.MAX_WBITS)
256
- except zlib.error:
257
- return raw
258
- return raw
259
-
260
-
261
- def _detect_charset(raw: bytes, content_type: str) -> str:
262
- """从 HTTP 头部或 HTML meta 标签检测字符集。"""
263
-
264
- match = re.search(r"charset=([^;]+)", content_type or "", re.I)
265
- if match:
266
- return match.group(1).strip(' "\'').lower()
267
-
268
- head = raw[:8192]
269
- meta_match = re.search(rb"<meta[^>]+charset\s*=\s*['\"]?\s*([a-zA-Z0-9._-]+)", head, re.I)
270
- if meta_match:
271
- return meta_match.group(1).decode("ascii", "ignore").lower()
272
-
273
- equiv_match = re.search(rb"<meta[^>]+http-equiv\s*=\s*['\"]?content-type['\"]?[^>]+>", head, re.I)
274
- if equiv_match:
275
- content_match = re.search(rb"charset\s*=\s*([a-zA-Z0-9._-]+)", equiv_match.group(0), re.I)
276
- if content_match:
277
- return content_match.group(1).decode("ascii", "ignore").lower()
278
-
279
- return "utf-8"
280
-
281
-
282
- def decode_body(raw: bytes, headers: Any) -> str:
283
- raw = _decompress_body(raw, headers.get("content-encoding", ""))
284
- content_type = headers.get("content-type", "")
285
- charset = _detect_charset(raw, content_type)
286
- candidates = [charset, "utf-8", "gb18030", "big5", "shift_jis", "latin-1"]
287
- seen: set[str] = set()
288
- best_text = ""
289
- best_replacements: Optional[int] = None
290
- for encoding in candidates:
291
- if not encoding or encoding in seen:
292
- continue
293
- seen.add(encoding)
294
- try:
295
- text = raw.decode(encoding, "replace")
296
- except LookupError:
297
- continue
298
- replacements = text.count("\ufffd")
299
- if best_replacements is None or replacements < best_replacements:
300
- best_text = text
301
- best_replacements = replacements
302
- if replacements == 0:
303
- return text
304
- return best_text or raw.decode("utf-8", "replace")
305
-
306
-
307
- def _is_text_content_type(content_type: str) -> bool:
308
- mime = (content_type or "").split(";", 1)[0].strip().lower()
309
- return mime.startswith("text/") or mime in TEXT_CONTENT_TYPES or mime.endswith("+json") or mime.endswith("+xml")
310
-
311
-
312
- def _looks_like_text(raw: bytes) -> bool:
313
- if not raw:
314
- return True
315
- sample = raw[:4096]
316
- if b"\x00" in sample:
317
- return False
318
- decoded = sample.decode("utf-8", "replace")
319
- replacement_ratio = decoded.count("\ufffd") / max(len(decoded), 1)
320
- control_chars = sum(1 for ch in decoded if ord(ch) < 32 and ch not in "\n\r\t")
321
- return replacement_ratio < 0.20 and control_chars / max(len(decoded), 1) < 0.05
322
-
323
-
324
- def parse_header_list(values: Optional[list[str]]) -> Dict[str, str]:
325
- """解析 CLI 传入的 Header 列表,格式为 `Key: Value`。"""
326
-
327
- headers: Dict[str, str] = {}
328
- for raw in values or []:
329
- if ":" not in raw:
330
- raise ValueError(f"非法 header(缺少冒号): {raw}")
331
- key, value = raw.split(":", 1)
332
- key = key.strip()
333
- value = value.strip()
334
- if not key:
335
- raise ValueError(f"非法 header(key 为空): {raw}")
336
- if re.search(r"[\r\n]", key + value):
337
- raise ValueError("非法 header:不允许包含换行")
338
- headers[key] = value
339
- return headers
340
-
341
-
342
- def normalize_proxy_url(proxy: Optional[str]) -> Optional[str]:
343
- """规范化代理地址;支持 127.0.0.1:7890 这类简写。"""
344
-
345
- value = (proxy or os.getenv("FETCH_PROXY") or os.getenv("LOCAL_FETCH_PROXY") or "").strip()
346
- if not value:
347
- return None
348
- if "://" not in value:
349
- value = f"http://{value}"
350
- parsed = urllib.parse.urlparse(value)
351
- if parsed.scheme not in {"http", "https"}:
352
- raise ValueError(f"本地 fetch 仅支持 http/https 代理,不支持: {parsed.scheme}")
353
- if not parsed.hostname or not parsed.port:
354
- raise ValueError(f"非法代理地址: {proxy}")
355
- return value
356
-
357
-
358
- def _bounded_retry_settings(
359
- retries: Optional[int],
360
- retry_delay: Optional[float],
361
- retry_jitter: float,
362
- ) -> tuple[int, float, float, float]:
363
- """返回有上限的重试设置,避免单次 fetch 长时间卡死。"""
364
-
365
- env_retries = int(os.getenv("FETCH_RETRIES") or "1")
366
- max_retries = env_retries if retries is None else max(0, retries)
367
- max_retries = min(max_retries, int(os.getenv("FETCH_MAX_RETRIES") or "4"))
368
- delay = float(os.getenv("FETCH_RETRY_DELAY") or "0.75") if retry_delay is None else max(0.0, retry_delay)
369
- delay = min(delay, float(os.getenv("FETCH_RETRY_DELAY_CAP") or "10"))
370
- jitter = min(max(0.0, retry_jitter), float(os.getenv("FETCH_RETRY_JITTER_CAP") or "5"))
371
- max_delay = float(os.getenv("FETCH_RETRY_MAX_DELAY") or "8")
372
- return max_retries, delay, jitter, max_delay
373
-
374
-
375
- def _retry_sleep_seconds(delay: float, jitter: float, max_delay: float, attempt: int) -> float:
376
- return min(max_delay, delay * (1.6 ** attempt) + random.uniform(0, jitter))
377
-
378
-
379
- def _extract_with_bs4(
380
- body: str,
381
- *,
382
- keep_links: bool = False,
383
- drop_structural_noise: bool = True,
384
- ) -> Optional[tuple[str, str, str]]:
385
- """优先用 bs4/lxml 做更稳的正文抽取;不可用时返回 None。"""
386
-
387
- try:
388
- from bs4 import BeautifulSoup # type: ignore
389
- except Exception:
390
- return None
391
-
392
- try:
393
- soup = BeautifulSoup(body, "lxml")
394
- except Exception:
395
- soup = BeautifulSoup(body, "html.parser")
396
-
397
- for tag in soup(["script", "style", "noscript", "svg", "canvas", "iframe", "template"]):
398
- tag.decompose()
399
- if drop_structural_noise:
400
- for selector in ["nav", "footer", "header", "aside", "form"]:
401
- for tag in soup.select(selector):
402
- tag.decompose()
403
- if keep_links:
404
- for tag in soup.find_all("a"):
405
- href = tag.get("href")
406
- if href and tag.get_text(strip=True):
407
- tag.append(f" ({href})")
408
-
409
- title = normalize_inline(soup.title.get_text(" ") if soup.title else "")
410
- meta = soup.find("meta", attrs={"name": "description"}) or soup.find("meta", attrs={"property": "og:description"})
411
- description = normalize_inline(meta.get("content", "") if meta else "")
412
- root = None
413
- for selector in ["main", "article", "[role='main']", ".content", "#content", ".post", ".entry-content"]:
414
- root = soup.select_one(selector)
415
- if root:
416
- break
417
- if root is None:
418
- root = soup.body or soup
419
- text = root.get_text("\n")
420
- return title, description, text
421
-
422
-
423
- def _extract_html_text(
424
- body: str,
425
- *,
426
- keep_links: bool = False,
427
- drop_structural_noise: bool = True,
428
- extract_mode: str = "auto",
429
- ) -> tuple[str, str, str]:
430
- if extract_mode in {"auto", "bs4"}:
431
- extracted = _extract_with_bs4(body, keep_links=keep_links, drop_structural_noise=drop_structural_noise)
432
- if extracted is not None:
433
- return extracted
434
- if extract_mode == "bs4":
435
- # bs4 不可用时仍退回内置 parser,保持脚本可用。
436
- pass
437
- parser = ReadableHTMLParser(keep_links=keep_links, drop_structural_noise=drop_structural_noise)
438
- parser.feed(body)
439
- return parser.title, normalize_inline(parser.meta_description), parser.text
440
-
441
-
442
- def extract_readable_text(
443
- body: str,
444
- *,
445
- content_type: str,
446
- keep_links: bool = False,
447
- drop_structural_noise: bool = True,
448
- markdownish: bool = True,
449
- extract_mode: str = "auto",
450
- ) -> tuple[str, str, str]:
451
- """返回 title, description, cleaned_text。"""
452
-
453
- lower_type = content_type.lower()
454
- if "html" in lower_type or "<html" in body[:1000].lower() or "<!doctype html" in body[:1000].lower():
455
- title, description, raw_text = _extract_html_text(
456
- body,
457
- keep_links=keep_links,
458
- drop_structural_noise=drop_structural_noise,
459
- extract_mode=extract_mode,
460
- )
461
- text = normalize_text(raw_text)
462
- else:
463
- title = ""
464
- description = ""
465
- text = normalize_text(body)
466
- text = filter_noise_lines(text)
467
- if markdownish:
468
- text = to_markdownish(text)
469
- return title, description, text
470
-
471
-
472
- def assess_quality(result: Dict[str, Any], *, threshold: float = DEFAULT_QUALITY_THRESHOLD) -> Dict[str, Any]:
473
- """评估本地 fetch 结果质量,用于决定是否需要付费/API 或 Playwright fallback。"""
474
-
475
- reasons: list[str] = []
476
- suggestions: list[str] = []
477
- flags: Dict[str, bool] = {
478
- "requestFailed": False,
479
- "tooShort": False,
480
- "jsRequired": False,
481
- "blocked": False,
482
- "soft404": False,
483
- "encodingIssue": False,
484
- "lowSentenceDensity": False,
485
- "lowDiversity": False,
486
- }
487
- text = result.get("text") or ""
488
- title = result.get("title") or ""
489
- combined = f"{title}\n{text}".lower()
490
- characters = len(text)
491
- score = 0.55
492
-
493
- if not result.get("ok"):
494
- score -= 0.55
495
- flags["requestFailed"] = True
496
- reasons.append(result.get("error") or "请求失败")
497
- is_html = "html" in (result.get("contentType") or "").lower()
498
- is_rendered = bool(result.get("rendered"))
499
- if characters == 0:
500
- score -= 0.45
501
- flags["tooShort"] = True
502
- if result.get("ok") and is_html and not is_rendered:
503
- flags["jsRequired"] = True
504
- reasons.append("HTML 正文为空,可能依赖 JS 动态渲染")
505
- else:
506
- reasons.append("正文为空")
507
- elif characters < 80:
508
- score -= 0.35
509
- flags["tooShort"] = True
510
- if result.get("ok") and is_html and not is_rendered:
511
- flags["jsRequired"] = True
512
- reasons.append("HTML 正文过短,可能依赖 JS 动态渲染")
513
- else:
514
- reasons.append("正文过短")
515
- elif characters < 120:
516
- score -= 0.20 if title else 0.28
517
- flags["tooShort"] = True
518
- if result.get("ok") and is_html and not is_rendered:
519
- flags["jsRequired"] = True
520
- reasons.append("HTML 正文偏短,可能依赖 JS 动态渲染")
521
- else:
522
- reasons.append("正文偏短")
523
- elif characters < 400:
524
- score -= 0.12
525
- reasons.append("正文偏短")
526
- elif characters > 1000:
527
- score += 0.08
528
-
529
- if title:
530
- score += 0.04
531
- elif "html" in (result.get("contentType") or "").lower():
532
- score -= 0.04
533
- reasons.append("缺少标题")
534
-
535
- js_patterns = [
536
- r"please enable (javascript|js)",
537
- r"browser is not supported",
538
- r"javascript (is )?(disabled|required)",
539
- r"enable javascript",
540
- ]
541
- blocked_patterns = [
542
- r"enable cookies",
543
- r"checking (your )?browser",
544
- r"checking if the site connection is secure",
545
- r"just a moment",
546
- r"cloudflare",
547
- r"captcha",
548
- r"access denied",
549
- r"verify you are human",
550
- r"unusual traffic",
551
- r"bot detection",
552
- r"login required",
553
- ]
554
- if any(re.search(pattern, combined, re.I) for pattern in js_patterns):
555
- score -= 0.45
556
- flags["jsRequired"] = True
557
- reasons.append("疑似 JS 动态渲染页面")
558
- if any(re.search(pattern, combined, re.I) for pattern in blocked_patterns):
559
- score -= 0.45
560
- flags["blocked"] = True
561
- reasons.append("疑似验证码/反爬/登录拦截页")
562
-
563
- error_patterns = [
564
- r"\b404\b",
565
- r"page not found",
566
- r"not found",
567
- r"\b403\b",
568
- r"forbidden",
569
- r"unauthorized",
570
- r"internal server error",
571
- r"service unavailable",
572
- ]
573
- if any(re.search(pattern, combined, re.I) for pattern in error_patterns):
574
- score -= 0.30
575
- flags["soft404"] = True
576
- reasons.append("疑似错误页或软 404")
577
-
578
- if result.get("truncated") and characters < 300:
579
- score -= 0.10
580
- reasons.append("内容被截断且有效正文偏少")
581
-
582
- if text:
583
- replacement_ratio = text.count("\ufffd") / max(characters, 1)
584
- if replacement_ratio > 0.01:
585
- score -= 0.25
586
- flags["encodingIssue"] = True
587
- reasons.append("疑似字符编码错误")
588
- sentence_marks = len(re.findall(r"[。.!?!?]", text))
589
- line_count = max(len([line for line in text.split("\n") if line.strip()]), 1)
590
- if characters > 400 and sentence_marks / line_count < 0.15:
591
- score -= 0.10
592
- flags["lowSentenceDensity"] = True
593
- reasons.append("正文句子密度偏低")
594
- unique_ratio = len(set(text)) / max(characters, 1)
595
- if characters > 300 and unique_ratio < 0.04:
596
- score -= 0.20
597
- flags["lowDiversity"] = True
598
- reasons.append("字符多样性异常低")
599
-
600
- if flags["jsRequired"]:
601
- suggestions.append("try --with-js 或保持默认自动 Playwright 以渲染动态页面")
602
- if flags["blocked"]:
603
- suggestions.append("可尝试 --with-js、--referer、--header 或降低请求频率;若仍失败再 fallback 到 Tavily/Exa")
604
- if flags["encodingIssue"]:
605
- suggestions.append("可检查页面 charset 或尝试 --extract-mode parser/bs4")
606
- if flags["tooShort"] and not flags["jsRequired"]:
607
- suggestions.append("可提高 --max-characters 或启用 Playwright/服务端 fetch fallback")
608
-
609
- score = max(0.0, min(1.0, round(score, 3)))
610
- return {
611
- "score": score,
612
- "threshold": threshold,
613
- "pass": score >= threshold,
614
- "reason": ";".join(reasons) if reasons else "质量可接受",
615
- "reasons": reasons,
616
- "flags": flags,
617
- "suggestions": suggestions,
618
- }
619
-
620
-
621
- def _failure_result(url: str, *, error: str, status: Optional[int] = None, content_type: str = "", attempts: Optional[list[Dict[str, Any]]] = None) -> Dict[str, Any]:
622
- result: Dict[str, Any] = {"ok": False, "url": url, "error": error}
623
- if status is not None:
624
- result["status"] = status
625
- if content_type:
626
- result["contentType"] = content_type
627
- if attempts:
628
- result["attempts"] = attempts
629
- return result
630
-
631
-
632
- def fetch_one(
633
- url: str,
634
- *,
635
- timeout: Optional[float] = None,
636
- max_chars: int = 20000,
637
- keep_links: bool = False,
638
- drop_structural_noise: bool = True,
639
- markdownish: bool = True,
640
- user_agent: Optional[str] = None,
641
- retries: Optional[int] = None,
642
- retry_delay: Optional[float] = None,
643
- retry_jitter: float = 0.0,
644
- retry_on_any: bool = False,
645
- referer: Optional[str] = None,
646
- accept_language: Optional[str] = None,
647
- extra_headers: Optional[Dict[str, str]] = None,
648
- extract_mode: str = "auto",
649
- proxy: Optional[str] = None,
650
- ) -> Dict[str, Any]:
651
- headers = {
652
- "User-Agent": user_agent or os.getenv("FETCH_USER_AGENT") or DEFAULT_USER_AGENT,
653
- "Accept": "text/html,application/xhtml+xml,application/xml,application/json,text/plain;q=0.9,*/*;q=0.8",
654
- "Accept-Encoding": "gzip, deflate",
655
- "Accept-Language": accept_language or os.getenv("FETCH_ACCEPT_LANGUAGE") or "zh-CN,zh;q=0.9,en;q=0.8",
656
- }
657
- if referer:
658
- headers["Referer"] = referer
659
- if extra_headers:
660
- headers.update(extra_headers)
661
- req = urllib.request.Request(url, headers=headers)
662
- timeout = timeout or float(os.getenv("SEARCH_TIMEOUT") or os.getenv("FETCH_TIMEOUT") or "30")
663
- max_retries, retry_delay, retry_jitter, retry_max_delay = _bounded_retry_settings(retries, retry_delay, retry_jitter)
664
- proxy_url = normalize_proxy_url(proxy)
665
- opener = urllib.request.build_opener(
666
- urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url} if proxy_url else {})
667
- )
668
- attempts: list[Dict[str, Any]] = []
669
-
670
- for attempt in range(max_retries + 1):
671
- try:
672
- with opener.open(req, timeout=timeout) as resp:
673
- content_type = resp.headers.get("content-type", "")
674
- raw = resp.read(max_chars * 4 + 65536)
675
- body_probe = raw[:4096].lower()
676
- looks_html = b"<html" in body_probe or b"<!doctype html" in body_probe
677
- if not (_is_text_content_type(content_type) or looks_html or _looks_like_text(raw)):
678
- return _failure_result(
679
- resp.geturl(),
680
- status=resp.status,
681
- content_type=content_type,
682
- error="非文本内容,跳过正文清理",
683
- attempts=attempts or None,
684
- )
685
- body = decode_body(raw, resp.headers)
686
- title, description, cleaned = extract_readable_text(
687
- body,
688
- content_type=content_type,
689
- keep_links=keep_links,
690
- drop_structural_noise=drop_structural_noise,
691
- markdownish=markdownish,
692
- extract_mode=extract_mode,
693
- )
694
- truncated = len(cleaned) > max_chars
695
- if truncated:
696
- cleaned = cleaned[:max_chars].rstrip()
697
- return {
698
- "ok": True,
699
- "url": resp.geturl(),
700
- "status": resp.status,
701
- "contentType": content_type,
702
- "title": title,
703
- "description": description,
704
- "text": cleaned,
705
- "truncated": truncated,
706
- "characters": len(cleaned),
707
- "attempts": attempts or None,
708
- "proxy": proxy_url,
709
- }
710
- except urllib.error.HTTPError as exc:
711
- attempt_info = {"attempt": attempt + 1, "status": exc.code, "error": f"HTTP {exc.code}: {exc.reason}"}
712
- attempts.append(attempt_info)
713
- if (retry_on_any or exc.code in RETRY_STATUSES) and attempt < max_retries:
714
- time.sleep(_retry_sleep_seconds(retry_delay, retry_jitter, retry_max_delay, attempt))
715
- continue
716
- return _failure_result(url, status=exc.code, error=f"HTTP {exc.code}: {exc.reason}", attempts=attempts)
717
- except urllib.error.URLError as exc:
718
- attempt_info = {"attempt": attempt + 1, "error": str(exc.reason)}
719
- attempts.append(attempt_info)
720
- if attempt < max_retries:
721
- time.sleep(_retry_sleep_seconds(retry_delay, retry_jitter, retry_max_delay, attempt))
722
- continue
723
- return _failure_result(url, error=str(exc.reason), attempts=attempts)
724
- return _failure_result(url, error="未知 fetch 失败", attempts=attempts)
725
-
726
-
727
- def fetch_urls(
728
- urls: list[str],
729
- *,
730
- timeout: Optional[float] = None,
731
- max_chars: int = 20000,
732
- keep_links: bool = False,
733
- drop_structural_noise: bool = True,
734
- markdownish: bool = True,
735
- user_agent: Optional[str] = None,
736
- min_quality_score: Optional[float] = None,
737
- retries: Optional[int] = None,
738
- retry_delay: Optional[float] = None,
739
- retry_jitter: float = 0.0,
740
- retry_on_any: bool = False,
741
- referer: Optional[str] = None,
742
- accept_language: Optional[str] = None,
743
- extra_headers: Optional[Dict[str, str]] = None,
744
- extract_mode: str = "auto",
745
- proxy: Optional[str] = None,
746
- ) -> Dict[str, Any]:
747
- threshold = DEFAULT_QUALITY_THRESHOLD if min_quality_score is None else min_quality_score
748
- results = [
749
- fetch_one(
750
- url,
751
- timeout=timeout,
752
- max_chars=max_chars,
753
- keep_links=keep_links,
754
- drop_structural_noise=drop_structural_noise,
755
- markdownish=markdownish,
756
- user_agent=user_agent,
757
- retries=retries,
758
- retry_delay=retry_delay,
759
- retry_jitter=retry_jitter,
760
- retry_on_any=retry_on_any,
761
- referer=referer,
762
- accept_language=accept_language,
763
- extra_headers=extra_headers,
764
- extract_mode=extract_mode,
765
- proxy=proxy,
766
- )
767
- for url in urls
768
- ]
769
- failures: list[Dict[str, Any]] = []
770
- for item in results:
771
- quality = assess_quality(item, threshold=threshold)
772
- item["quality"] = quality
773
- if min_quality_score is not None and (not item.get("ok") or quality["score"] < threshold):
774
- failures.append({
775
- "url": item.get("url"),
776
- "status": item.get("status"),
777
- "score": quality["score"],
778
- "threshold": threshold,
779
- "reason": quality["reason"],
780
- "flags": quality.get("flags"),
781
- "suggestions": quality.get("suggestions"),
782
- "error": item.get("error"),
783
- })
784
-
785
- result = {
786
- "ok": any(item.get("ok") for item in results),
787
- "allOk": all(item.get("ok") for item in results) if results else False,
788
- "service": "local-fetch",
789
- "data": {"results": results},
790
- }
791
- if min_quality_score is not None:
792
- result["qualityThreshold"] = threshold
793
- result["qualityPass"] = not failures
794
- if failures:
795
- result["qualityFailures"] = failures
796
- raise LowQualityError(failures, result)
797
- return result
798
-
799
-
800
- def split_csv(value: str) -> list[str]:
801
- return [part.strip() for part in value.split(",") if part.strip()]
802
-
803
-
804
- def result_to_markdown(result: Dict[str, Any]) -> str:
805
- chunks: list[str] = []
806
- if result.get("error"):
807
- chunks.extend([f"> {result['error']}", ""])
808
- for item in result.get("data", {}).get("results", []):
809
- chunks.append(f"# {item.get('title') or item.get('url')}")
810
- chunks.append("")
811
- chunks.append(f"- URL: {item.get('url')}")
812
- if item.get("status"):
813
- chunks.append(f"- Status: {item.get('status')}")
814
- if item.get("contentType"):
815
- chunks.append(f"- Content-Type: {item.get('contentType')}")
816
- if item.get("description"):
817
- chunks.append(f"- Description: {item.get('description')}")
818
- if item.get("quality"):
819
- quality = item["quality"]
820
- chunks.append(f"- Quality: {quality.get('score')} / {quality.get('threshold')} ({quality.get('reason')})")
821
- if quality.get("suggestions"):
822
- chunks.append(f"- Suggestions: {'; '.join(quality.get('suggestions') or [])}")
823
- if item.get("error"):
824
- chunks.append(f"- Error: {item.get('error')}")
825
- chunks.append("")
826
- if item.get("text"):
827
- chunks.append(item["text"])
828
- chunks.append("")
829
- return "\n".join(chunks).strip()
830
-
831
-
832
- def result_to_summary(result: Dict[str, Any], *, max_snippet: int = 240) -> str:
833
- chunks: list[str] = []
834
- for item in result.get("data", {}).get("results", []):
835
- quality = item.get("quality") or {}
836
- text = re.sub(r"\s+", " ", item.get("text") or "").strip()
837
- if len(text) > max_snippet:
838
- text = text[:max_snippet].rstrip() + "…"
839
- chunks.append(f"- {item.get('title') or item.get('url')}")
840
- chunks.append(f" URL: {item.get('url')}")
841
- if item.get("status"):
842
- chunks.append(f" Status: {item.get('status')}")
843
- if quality:
844
- chunks.append(f" Quality: {quality.get('score')} / {quality.get('threshold')} ({quality.get('reason')})")
845
- if item.get("description"):
846
- chunks.append(f" Description: {item.get('description')}")
847
- if item.get("error"):
848
- chunks.append(f" Error: {item.get('error')}")
849
- if text:
850
- chunks.append(f" Snippet: {text}")
851
- return "\n".join(chunks).strip()
852
-
853
-
854
- def _sort_result_by_quality(result: Dict[str, Any]) -> None:
855
- results = result.get("data", {}).get("results")
856
- if isinstance(results, list):
857
- results.sort(key=lambda item: (item.get("quality") or {}).get("score", -1), reverse=True)
858
-
859
-
860
- def _write_output(path: str, content: str) -> None:
861
- with open(path, "w", encoding="utf-8") as fh:
862
- fh.write(content)
863
- if content and not content.endswith("\n"):
864
- fh.write("\n")
865
-
866
-
867
- def main(argv: Optional[list[str]] = None) -> int:
868
- parser = argparse.ArgumentParser(description="本地优先 fetch:抓取 URL、清理 HTML 噪声、质量评估、整理为 JSON/Markdown/Text")
869
- parser.add_argument("--urls", required=True, help="逗号分隔 URL")
870
- parser.add_argument("--timeout", type=float)
871
- parser.add_argument("--max-characters", type=int, default=20000)
872
- parser.add_argument("--keep-links", action="store_true", help="保留 a 标签链接")
873
- parser.add_argument("--keep-structural-noise", action="store_true", help="不丢弃 nav/header/footer/aside/form")
874
- parser.add_argument("--plain", action="store_true", help="不做标题式 markdown 整理")
875
- parser.add_argument("--user-agent")
876
- parser.add_argument("--accept-language", help="覆盖 Accept-Language")
877
- parser.add_argument("--referer", help="设置 Referer 请求头")
878
- parser.add_argument("--proxy", help="本地 fetch 代理地址,如 127.0.0.1:7890 或 http://127.0.0.1:7890;不影响 Tavily/Exa/Context7 API")
879
- parser.add_argument("--header", action="append", help="追加自定义请求头,格式:'Key: Value';可重复")
880
- parser.add_argument("--extract-mode", choices=["auto", "parser", "bs4"], default="auto", help="HTML 正文抽取模式;auto 优先 bs4/lxml,失败退回内置 parser")
881
- parser.add_argument("--retries", type=int, help="失败重试次数;默认读取 FETCH_RETRIES 或 1")
882
- parser.add_argument("--retry-delay", type=float, help="重试初始等待秒数;默认读取 FETCH_RETRY_DELAY 或 0.75")
883
- parser.add_argument("--retry-jitter", type=float, default=0.0, help="每次重试额外随机等待上限秒数")
884
- parser.add_argument("--retry-on-any", action="store_true", help="对任意 HTTP 错误码也重试,而不只重试 408/429/5xx")
885
- parser.add_argument("--min-quality", type=float, help="质量评分阈值(0-1);低于阈值时退出码为 1,供上层 fallback")
886
- parser.add_argument("--summary", action="store_true", help="输出摘要视图")
887
- parser.add_argument("--sort-by-quality", action="store_true", help="多 URL 时按质量评分降序输出")
888
- parser.add_argument("--output-file", help="把结果写入文件;stdout 只输出简短状态")
889
- parser.add_argument("--format", choices=["json", "markdown", "text"], default="json")
890
- args = parser.parse_args(argv)
891
-
892
- exit_code = 0
893
- try:
894
- result = fetch_urls(
895
- split_csv(args.urls),
896
- timeout=args.timeout,
897
- max_chars=args.max_characters,
898
- keep_links=args.keep_links,
899
- drop_structural_noise=not args.keep_structural_noise,
900
- markdownish=not args.plain,
901
- user_agent=args.user_agent,
902
- min_quality_score=args.min_quality,
903
- retries=args.retries,
904
- retry_delay=args.retry_delay,
905
- retry_jitter=args.retry_jitter,
906
- retry_on_any=args.retry_on_any,
907
- referer=args.referer,
908
- accept_language=args.accept_language,
909
- extra_headers=parse_header_list(args.header),
910
- extract_mode=args.extract_mode,
911
- proxy=args.proxy,
912
- )
913
- except LowQualityError as exc:
914
- result = exc.result
915
- result["ok"] = False
916
- result["error"] = str(exc)
917
- exit_code = 1
918
-
919
- if args.sort_by_quality:
920
- _sort_result_by_quality(result)
921
-
922
- if args.format == "json":
923
- output = json.dumps(result, ensure_ascii=False, indent=2)
924
- elif args.format == "markdown":
925
- output = result_to_summary(result) if args.summary else result_to_markdown(result)
926
- else:
927
- output = result_to_summary(result) if args.summary else "\n\n".join(item.get("text", "") for item in result.get("data", {}).get("results", []))
928
-
929
- if args.output_file:
930
- _write_output(args.output_file, output)
931
- print(json.dumps({"ok": result.get("ok"), "outputFile": args.output_file, "service": result.get("service")}, ensure_ascii=False))
932
- else:
933
- print(output)
934
- return exit_code if exit_code else (0 if result.get("ok") else 1)
935
-
936
-
937
- if __name__ == "__main__":
938
- sys.exit(main())