@dsh-plus/web-search-services 0.1.19 → 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/lib/client.js +1203 -178
- package/lib/index.d.ts +10 -83
- package/lib/index.js +16 -649
- package/package.json +15 -15
- package/src/client/card.tsx +130 -72
- package/src/client/client.ts +19 -7
- package/src/client/draft.ts +29 -89
- package/src/client/i18n.ts +109 -52
- package/src/client/styles.ts +10 -7
- package/src/config.ts +24 -74
- package/src/index.ts +29 -14
- package/scripts/README.md +0 -34
- package/scripts/context7.py +0 -259
- package/scripts/env_loader.py +0 -46
- package/scripts/exa.py +0 -251
- package/scripts/fetch.py +0 -938
- package/scripts/fetch_playwright.py +0 -525
- package/scripts/openai_chat.py +0 -570
- package/scripts/search.py +0 -962
- package/scripts/tavily.py +0 -609
- package/src/args.ts +0 -30
- package/src/env-file.ts +0 -49
- package/src/normalize.ts +0 -173
- package/src/provider.ts +0 -188
- package/src/python.ts +0 -193
- package/src/runner.ts +0 -140
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())
|