argo-search 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +410 -0
- package/backends/domain_profiles.json +364 -0
- package/backends/engine_registry.yaml +505 -0
- package/backends/quota_profiles.json +395 -0
- package/bin/argo.js +54 -0
- package/config.yaml +554 -0
- package/package.json +43 -0
- package/scripts/adaptive.py +179 -0
- package/scripts/benchmark.py +124 -0
- package/scripts/cache.py +374 -0
- package/scripts/clarify.py +689 -0
- package/scripts/config.py +262 -0
- package/scripts/crawl.py +73 -0
- package/scripts/engines.py +386 -0
- package/scripts/evidence.py +381 -0
- package/scripts/extract.py +69 -0
- package/scripts/fetch.py +118 -0
- package/scripts/health_check.py +437 -0
- package/scripts/health_probe.py +218 -0
- package/scripts/mcp_diag.py +81 -0
- package/scripts/mcp_server.py +488 -0
- package/scripts/query_rewriter.py +278 -0
- package/scripts/quota.py +196 -0
- package/scripts/research.py +499 -0
- package/scripts/route.py +341 -0
- package/scripts/search.py +508 -0
- package/scripts/search_types.py +72 -0
- package/scripts/tfidf_router.py +312 -0
- package/sub-skills/local-search/SKILL.md +104 -0
- package/sub-skills/local-search/config.yaml +328 -0
- package/sub-skills/local-search/engine_registry.py +298 -0
- package/sub-skills/local-search/health_check.py +347 -0
- package/sub-skills/local-search/local_search_adapter.py +56 -0
- package/sub-skills/local-search/parse_maps.yaml +184 -0
- package/sub-skills/local-search/search_v3.py +558 -0
- package/sub-skills/local-search/smart_router.py +215 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
evidence.py — 来源可信度评估工具(wigolo evidence_score 理念移植)
|
|
4
|
+
|
|
5
|
+
核心能力:
|
|
6
|
+
1. 权威性评分:基于域名白名单/黑名单 + 来源类型分级
|
|
7
|
+
2. 时效性评分:基于内容时间戳 + 搜索时间
|
|
8
|
+
3. 交叉验证:多个来源是否佐证同一结论
|
|
9
|
+
4. 综合可信度:加权计算 + 透明分解
|
|
10
|
+
|
|
11
|
+
用法:
|
|
12
|
+
python3 evidence.py --urls "https://example.com" "https://example2.com"
|
|
13
|
+
python3 evidence.py --search-result '{"results": [...]}' --query "茅台股价"
|
|
14
|
+
echo '{"results": [...]}' | python3 evidence.py --stdin --query "查询词"
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from typing import Any
|
|
26
|
+
from urllib.parse import urlparse
|
|
27
|
+
|
|
28
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
29
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── 权威性评分 ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
# 域名权威性分级(越高越好)
|
|
35
|
+
AUTHORITY_TIERS = {
|
|
36
|
+
# Tier 1: 官方/权威(0.95-1.0)
|
|
37
|
+
"gov.cn": 1.0, "gov": 0.95, "edu.cn": 0.95, "edu": 0.9,
|
|
38
|
+
"ac.cn": 0.95, # 中国科学院
|
|
39
|
+
"nature.com": 0.95, "science.org": 0.95, "ieee.org": 0.9,
|
|
40
|
+
"acm.org": 0.9, "springer.com": 0.9, "elsevier.com": 0.9,
|
|
41
|
+
"arxiv.org": 0.9, "pubmed.ncbi.nlm.nih.gov": 0.95,
|
|
42
|
+
"scholar.google.com": 0.85, "ncbi.nlm.nih.gov": 0.95,
|
|
43
|
+
"nvd.nist.gov": 0.95, # 国家漏洞数据库
|
|
44
|
+
"cve.mitre.org": 0.95,
|
|
45
|
+
|
|
46
|
+
# Tier 2: 专业媒体/平台(0.75-0.9)
|
|
47
|
+
"zhihu.com": 0.85, "github.com": 0.85, "stackoverflow.com": 0.85,
|
|
48
|
+
"medium.com": 0.75, "dev.to": 0.75,
|
|
49
|
+
"reuters.com": 0.9, "bloomberg.com": 0.9, "wsj.com": 0.9,
|
|
50
|
+
"财新": 0.9, "caixin.com": 0.9,
|
|
51
|
+
"36kr.com": 0.8, "infoq.cn": 0.8, "juejin.cn": 0.75,
|
|
52
|
+
"eastmoney.com": 0.85, "xueqiu.com": 0.8,
|
|
53
|
+
"docs.python.org": 0.9, "react.dev": 0.9, "nextjs.org": 0.9,
|
|
54
|
+
|
|
55
|
+
# Tier 3: 通用可信(0.6-0.75)
|
|
56
|
+
"wikipedia.org": 0.75, "baike.baidu.com": 0.7,
|
|
57
|
+
"维基百科": 0.75, "百度百科": 0.7,
|
|
58
|
+
"linkedin.com": 0.65, "twitter.com": 0.55, "x.com": 0.55,
|
|
59
|
+
"reddit.com": 0.65, "hackernews": 0.7,
|
|
60
|
+
|
|
61
|
+
# Tier 4: 内容农场/低质(0.2-0.4)
|
|
62
|
+
"sohu.com": 0.4, "163.com": 0.45, "sina.com.cn": 0.5,
|
|
63
|
+
"baijiahao.baidu.com": 0.35, "zhuanlan.zhihu.com": 0.7,
|
|
64
|
+
"toutiao.com": 0.4, "weixin.qq.com": 0.5,
|
|
65
|
+
|
|
66
|
+
# Tier 5: 已知低质(0.1-0.2)
|
|
67
|
+
"content-farm": 0.1, "seo-spam": 0.1,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# 来源类型映射
|
|
71
|
+
SOURCE_TYPE_MAP = {
|
|
72
|
+
"eastmoney": ("金融数据", 0.9),
|
|
73
|
+
"zhihu": ("社区观点", 0.8),
|
|
74
|
+
"arxiv": ("学术预印本", 0.85),
|
|
75
|
+
"semantic_scholar": ("学术索引", 0.9),
|
|
76
|
+
"openalex": ("学术索引", 0.85),
|
|
77
|
+
"crossref": ("学术元数据", 0.9),
|
|
78
|
+
"github": ("代码仓库", 0.85),
|
|
79
|
+
"byted": ("中文搜索", 0.7),
|
|
80
|
+
"bocha": ("中文搜索", 0.7),
|
|
81
|
+
"tavily": ("AI搜索", 0.75),
|
|
82
|
+
"felo": ("AI搜索", 0.7),
|
|
83
|
+
"duckduckgo": ("通用搜索", 0.65),
|
|
84
|
+
"wikipedia": ("百科", 0.75),
|
|
85
|
+
"anysearch": ("垂直搜索", 0.75),
|
|
86
|
+
"wigolo": ("本地搜索", 0.7),
|
|
87
|
+
"metaso": ("AI搜索", 0.75),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def score_authority(url: str, source: str = "") -> dict[str, Any]:
|
|
92
|
+
"""评估 URL 的权威性。"""
|
|
93
|
+
if not url:
|
|
94
|
+
return {"score": 0.3, "reason": "无 URL", "tier": "unknown"}
|
|
95
|
+
|
|
96
|
+
parsed = urlparse(url)
|
|
97
|
+
domain = parsed.netloc.lower().strip("www.")
|
|
98
|
+
path = parsed.path.lower()
|
|
99
|
+
|
|
100
|
+
# 精确匹配域名
|
|
101
|
+
best_score = 0.5
|
|
102
|
+
best_reason = "通用域名"
|
|
103
|
+
|
|
104
|
+
for pattern, score in AUTHORITY_TIERS.items():
|
|
105
|
+
if domain == pattern or domain.endswith("." + pattern):
|
|
106
|
+
if score > best_score:
|
|
107
|
+
best_score = score
|
|
108
|
+
best_reason = f"域名匹配:{pattern}"
|
|
109
|
+
|
|
110
|
+
# 路径特征加分
|
|
111
|
+
if "/docs/" in path or "/documentation/" in path:
|
|
112
|
+
best_score = min(best_score + 0.05, 1.0)
|
|
113
|
+
best_reason += "(文档路径)"
|
|
114
|
+
if "/paper/" in path or "/arxiv/" in path or "/abs/" in path:
|
|
115
|
+
best_score = min(best_score + 0.05, 1.0)
|
|
116
|
+
best_reason += "(论文路径)"
|
|
117
|
+
if "/issues/" in path or "/pull/" in path:
|
|
118
|
+
best_score = min(best_score + 0.03, 1.0)
|
|
119
|
+
best_reason += "(Issue/PR路径)"
|
|
120
|
+
|
|
121
|
+
# 来源类型加分
|
|
122
|
+
if source and source in SOURCE_TYPE_MAP:
|
|
123
|
+
type_name, type_score = SOURCE_TYPE_MAP[source]
|
|
124
|
+
if type_score > best_score:
|
|
125
|
+
best_score = type_score
|
|
126
|
+
best_reason = f"来源类型:{type_name}"
|
|
127
|
+
|
|
128
|
+
tier = "high" if best_score >= 0.8 else "medium" if best_score >= 0.6 else "low" if best_score >= 0.4 else "very_low"
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"score": round(best_score, 2),
|
|
132
|
+
"reason": best_reason,
|
|
133
|
+
"tier": tier,
|
|
134
|
+
"domain": domain,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── 时效性评分 ────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def score_freshness(result: dict[str, Any], query_time: float = None) -> dict[str, Any]:
|
|
141
|
+
"""评估结果的时效性。"""
|
|
142
|
+
|
|
143
|
+
def _extract_year_from_url(url: str):
|
|
144
|
+
"""从 URL 路径提取年份。"""
|
|
145
|
+
patterns = [
|
|
146
|
+
r"/(20\d{2})[/-](\d{1,2})[/-](\d{1,2})",
|
|
147
|
+
r"/(20\d{2})[/-](\d{1,2})",
|
|
148
|
+
r"/(20\d{2})/",
|
|
149
|
+
]
|
|
150
|
+
for p in patterns:
|
|
151
|
+
m = re.search(p, url)
|
|
152
|
+
if m:
|
|
153
|
+
return int(m.group(1))
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
if query_time is None:
|
|
157
|
+
query_time = time.time()
|
|
158
|
+
|
|
159
|
+
snippet = result.get("snippet", "") or ""
|
|
160
|
+
title = result.get("title", "") or ""
|
|
161
|
+
url = result.get("url", "") or ""
|
|
162
|
+
combined = f"{title} {snippet}"
|
|
163
|
+
|
|
164
|
+
# 提取时间信息
|
|
165
|
+
# 年份
|
|
166
|
+
year_match = re.search(r"20(\d{2})", combined)
|
|
167
|
+
if year_match:
|
|
168
|
+
year = 2000 + int(year_match.group(1))
|
|
169
|
+
from datetime import datetime
|
|
170
|
+
age_years = datetime.now().year - year
|
|
171
|
+
if age_years < 0:
|
|
172
|
+
score = 0.4
|
|
173
|
+
reason = f"{year}年(未来年份)"
|
|
174
|
+
elif age_years == 0:
|
|
175
|
+
score = 0.8
|
|
176
|
+
reason = f"{year}年(今年)"
|
|
177
|
+
elif age_years == 1:
|
|
178
|
+
score = 0.9
|
|
179
|
+
reason = f"{year}年(去年)"
|
|
180
|
+
elif age_years <= 2:
|
|
181
|
+
score = 0.7
|
|
182
|
+
reason = f"{year}年({age_years}年前)"
|
|
183
|
+
elif age_years <= 5:
|
|
184
|
+
score = 0.5
|
|
185
|
+
reason = f"{year}年({age_years}年前)"
|
|
186
|
+
else:
|
|
187
|
+
score = 0.3
|
|
188
|
+
reason = f"{year}年({age_years}年前,较旧)"
|
|
189
|
+
else:
|
|
190
|
+
# 无时间信息,尝试从 URL 提取
|
|
191
|
+
url_year = _extract_year_from_url(url)
|
|
192
|
+
if url_year:
|
|
193
|
+
from datetime import datetime
|
|
194
|
+
age_years = datetime.now().year - url_year
|
|
195
|
+
if age_years <= 1:
|
|
196
|
+
score = 0.8
|
|
197
|
+
reason = f"URL含{url_year}年"
|
|
198
|
+
elif age_years <= 3:
|
|
199
|
+
score = 0.6
|
|
200
|
+
reason = f"URL含{url_year}年({age_years}年前)"
|
|
201
|
+
else:
|
|
202
|
+
score = 0.4
|
|
203
|
+
reason = f"URL含{url_year}年(较旧)"
|
|
204
|
+
else:
|
|
205
|
+
score = 0.5
|
|
206
|
+
reason = "无明确时间标记"
|
|
207
|
+
|
|
208
|
+
# 时效性关键词加分
|
|
209
|
+
if re.search(r"(最新|latest|recent|breaking|just|刚刚|今日|today)", combined, re.I):
|
|
210
|
+
score = min(score + 0.1, 1.0)
|
|
211
|
+
reason += "(含时效关键词)"
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
"score": round(score, 2),
|
|
215
|
+
"reason": reason,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── 交叉验证 ──────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def cross_validate(results: list[dict[str, Any]], query: str) -> dict[str, Any]:
|
|
222
|
+
"""多来源交叉验证。"""
|
|
223
|
+
if len(results) < 2:
|
|
224
|
+
return {
|
|
225
|
+
"corroboration_level": "insufficient",
|
|
226
|
+
"score": 0.3,
|
|
227
|
+
"detail": f"仅 {len(results)} 个结果,无法交叉验证",
|
|
228
|
+
"agreement_count": 0,
|
|
229
|
+
"total_sources": len(results),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# 检查 URL 重叠(不同来源指向同一页面 = 强佐证)
|
|
233
|
+
urls = [r.get("url", "") for r in results if r.get("url")]
|
|
234
|
+
unique_urls = set(urls)
|
|
235
|
+
url_overlap = 1 - (len(unique_urls) / max(len(urls), 1))
|
|
236
|
+
|
|
237
|
+
# 检查域名多样性
|
|
238
|
+
domains = set()
|
|
239
|
+
for r in results:
|
|
240
|
+
url = r.get("url", "")
|
|
241
|
+
if url:
|
|
242
|
+
domain = urlparse(url).netloc.lower().strip("www.")
|
|
243
|
+
domains.add(domain)
|
|
244
|
+
|
|
245
|
+
# 检查标题/内容相似度(简单关键词匹配)
|
|
246
|
+
query_words = set(query.lower().split())
|
|
247
|
+
content_matches = 0
|
|
248
|
+
for r in results:
|
|
249
|
+
text = f"{r.get('title', '')} {r.get('snippet', '')}".lower()
|
|
250
|
+
if any(w in text for w in query_words if len(w) > 1):
|
|
251
|
+
content_matches += 1
|
|
252
|
+
|
|
253
|
+
match_ratio = content_matches / max(len(results), 1)
|
|
254
|
+
|
|
255
|
+
# 计算佐证等级
|
|
256
|
+
if match_ratio >= 0.8 and len(domains) >= 3:
|
|
257
|
+
level = "strong"
|
|
258
|
+
score = 0.9
|
|
259
|
+
elif match_ratio >= 0.6 and len(domains) >= 2:
|
|
260
|
+
level = "moderate"
|
|
261
|
+
score = 0.7
|
|
262
|
+
elif match_ratio >= 0.4:
|
|
263
|
+
level = "weak"
|
|
264
|
+
score = 0.5
|
|
265
|
+
else:
|
|
266
|
+
level = "minimal"
|
|
267
|
+
score = 0.3
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"corroboration_level": level,
|
|
271
|
+
"score": round(score, 2),
|
|
272
|
+
"detail": f"{content_matches}/{len(results)} 个结果与查询相关,{len(domains)} 个独立域名",
|
|
273
|
+
"agreement_count": content_matches,
|
|
274
|
+
"total_sources": len(results),
|
|
275
|
+
"unique_domains": len(domains),
|
|
276
|
+
"url_overlap": round(url_overlap, 2),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── 综合可信度 ────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def compute_credibility(results: list[dict[str, Any]], query: str) -> dict[str, Any]:
|
|
283
|
+
"""计算每个结果的综合可信度评分。"""
|
|
284
|
+
query_time = time.time()
|
|
285
|
+
scored_results = []
|
|
286
|
+
|
|
287
|
+
for r in results:
|
|
288
|
+
url = r.get("url", "")
|
|
289
|
+
source = r.get("source", "")
|
|
290
|
+
|
|
291
|
+
auth = score_authority(url, source)
|
|
292
|
+
fresh = score_freshness(r, query_time)
|
|
293
|
+
|
|
294
|
+
# 综合公式:权威性 0.5 + 时效性 0.3 + 原始分数 0.2
|
|
295
|
+
original_score = r.get("score", 0.5) or 0.5
|
|
296
|
+
credibility = (
|
|
297
|
+
auth["score"] * 0.5 +
|
|
298
|
+
fresh["score"] * 0.3 +
|
|
299
|
+
original_score * 0.2
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
scored_results.append({
|
|
303
|
+
"title": r.get("title", ""),
|
|
304
|
+
"url": url,
|
|
305
|
+
"source": source,
|
|
306
|
+
"snippet": (r.get("snippet", "") or "")[:150],
|
|
307
|
+
"credibility": {
|
|
308
|
+
"final": round(credibility, 3),
|
|
309
|
+
"authority": auth,
|
|
310
|
+
"freshness": fresh,
|
|
311
|
+
"original_score": original_score,
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
# 交叉验证
|
|
316
|
+
cross = cross_validate(results, query)
|
|
317
|
+
|
|
318
|
+
# 排序
|
|
319
|
+
scored_results.sort(key=lambda x: x["credibility"]["final"], reverse=True)
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
"query": query,
|
|
323
|
+
"results": scored_results,
|
|
324
|
+
"cross_validation": cross,
|
|
325
|
+
"summary": {
|
|
326
|
+
"total": len(scored_results),
|
|
327
|
+
"high_credibility": sum(1 for r in scored_results if r["credibility"]["final"] >= 0.7),
|
|
328
|
+
"medium_credibility": sum(1 for r in scored_results if 0.5 <= r["credibility"]["final"] < 0.7),
|
|
329
|
+
"low_credibility": sum(1 for r in scored_results if r["credibility"]["final"] < 0.5),
|
|
330
|
+
"best_source": scored_results[0] if scored_results else None,
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
def main():
|
|
338
|
+
parser = argparse.ArgumentParser(description="来源可信度评估工具")
|
|
339
|
+
parser.add_argument("query", nargs="?", default="", help="搜索查询词")
|
|
340
|
+
parser.add_argument("--stdin", action="store_true", help="从 stdin 读取 JSON 搜索结果")
|
|
341
|
+
parser.add_argument("--json", action="store_true", help="JSON 输出")
|
|
342
|
+
args = parser.parse_args()
|
|
343
|
+
|
|
344
|
+
if args.stdin:
|
|
345
|
+
data = json.load(sys.stdin)
|
|
346
|
+
results = data.get("results", [])
|
|
347
|
+
else:
|
|
348
|
+
# 从搜索结果文件读取
|
|
349
|
+
results = []
|
|
350
|
+
|
|
351
|
+
if not results and args.query:
|
|
352
|
+
# 无输入结果时,输出提示
|
|
353
|
+
print("需要提供搜索结果进行评估。用法:")
|
|
354
|
+
print(' echo \'{"results": [...]}\' | python3 evidence.py --stdin --query "查询词"')
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
report = compute_credibility(results, args.query)
|
|
358
|
+
|
|
359
|
+
if args.json:
|
|
360
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
361
|
+
else:
|
|
362
|
+
print(f"\n来源可信度评估:{report['query']}")
|
|
363
|
+
print(f"{'='*50}")
|
|
364
|
+
for r in report["results"]:
|
|
365
|
+
c = r["credibility"]
|
|
366
|
+
level = "🟢" if c["final"] >= 0.7 else "🟡" if c["final"] >= 0.5 else "🔴"
|
|
367
|
+
print(f"{level} [{c['final']:.2f}] {r['title'][:50]}")
|
|
368
|
+
print(f" 权威:{c['authority']['score']:.2f} ({c['authority']['reason']})")
|
|
369
|
+
print(f" 时效:{c['freshness']['score']:.2f} ({c['freshness']['reason']})")
|
|
370
|
+
print()
|
|
371
|
+
|
|
372
|
+
cv = report["cross_validation"]
|
|
373
|
+
print(f"交叉验证:{cv['corroboration_level']} (score={cv['score']:.2f})")
|
|
374
|
+
print(f" {cv['detail']}")
|
|
375
|
+
|
|
376
|
+
s = report["summary"]
|
|
377
|
+
print(f"\n总结:🟢高={s['high_credibility']} 🟡中={s['medium_credibility']} 🔴低={s['low_credibility']}")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
if __name__ == "__main__":
|
|
381
|
+
main()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""extract.py — 结构化数据提取"""
|
|
3
|
+
import json, re, sys
|
|
4
|
+
from html.parser import HTMLParser
|
|
5
|
+
from fetch import fetch_page
|
|
6
|
+
|
|
7
|
+
class TableExtractor(HTMLParser):
|
|
8
|
+
def __init__(self):
|
|
9
|
+
super().__init__()
|
|
10
|
+
self.tables = []
|
|
11
|
+
self._in_table = False
|
|
12
|
+
self._rows = []
|
|
13
|
+
self._row = []
|
|
14
|
+
self._cell = []
|
|
15
|
+
self._in_cell = False
|
|
16
|
+
def handle_starttag(self, tag, attrs):
|
|
17
|
+
if tag == 'table': self._in_table = True; self._rows = []
|
|
18
|
+
elif tag in ('td','th') and self._in_table: self._in_cell = True; self._cell = []
|
|
19
|
+
elif tag == 'tr' and self._in_table: self._row = []
|
|
20
|
+
def handle_endtag(self, tag):
|
|
21
|
+
if tag in ('td','th') and self._in_cell:
|
|
22
|
+
self._in_cell = False; self._row.append(''.join(self._cell).strip())
|
|
23
|
+
elif tag == 'tr' and self._in_table and self._row:
|
|
24
|
+
self._rows.append(self._row)
|
|
25
|
+
elif tag == 'table' and self._in_table:
|
|
26
|
+
self._in_table = False
|
|
27
|
+
if self._rows:
|
|
28
|
+
headers = self._rows[0]
|
|
29
|
+
table = [{headers[i]: row[i] if i < len(row) else '' for i in range(len(headers))} for row in self._rows[1:]]
|
|
30
|
+
self.tables.append(table)
|
|
31
|
+
def handle_data(self, data):
|
|
32
|
+
if self._in_cell: self._cell.append(data)
|
|
33
|
+
|
|
34
|
+
def extract_tables(html):
|
|
35
|
+
ext = TableExtractor(); ext.feed(html); return ext.tables
|
|
36
|
+
|
|
37
|
+
def extract_metadata(html):
|
|
38
|
+
meta = {}
|
|
39
|
+
title_m = re.search(r'<title>(.*?)</title>', html, re.I|re.S)
|
|
40
|
+
if title_m: meta['title'] = title_m.group(1).strip()
|
|
41
|
+
for pattern in [r'<meta\s+name=["\']description["\']\s+content=["\'](.*?)["\']', r'<meta\s+content=["\'](.*?)["\']\s+name=["\']description["\']']:
|
|
42
|
+
m = re.search(pattern, html, re.I)
|
|
43
|
+
if m: meta['description'] = m.group(1).strip(); break
|
|
44
|
+
for prop in ['og:title','og:description','og:image']:
|
|
45
|
+
m = re.search(rf'property=["\']{prop}["\']\s+content=["\'](.*?)["\']', html, re.I)
|
|
46
|
+
if m: meta[prop] = m.group(1).strip()
|
|
47
|
+
return meta
|
|
48
|
+
|
|
49
|
+
def extract_jsonld(html):
|
|
50
|
+
results = []
|
|
51
|
+
for m in re.finditer(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.S|re.I):
|
|
52
|
+
try: results.append(json.loads(m.group(1)))
|
|
53
|
+
except: pass
|
|
54
|
+
return results
|
|
55
|
+
|
|
56
|
+
if __name__ == '__main__':
|
|
57
|
+
import argparse
|
|
58
|
+
p = argparse.ArgumentParser()
|
|
59
|
+
p.add_argument('--url', required=True)
|
|
60
|
+
p.add_argument('--mode', default='all', choices=['tables','metadata','jsonld','all'])
|
|
61
|
+
args = p.parse_args()
|
|
62
|
+
result = fetch_page(args.url, 50000)
|
|
63
|
+
if not result['success']: print(json.dumps({'error': result.get('error')})); sys.exit(1)
|
|
64
|
+
html = result['content']
|
|
65
|
+
output = {}
|
|
66
|
+
if args.mode in ('tables','all'): output['tables'] = extract_tables(html)
|
|
67
|
+
if args.mode in ('metadata','all'): output['metadata'] = extract_metadata(html)
|
|
68
|
+
if args.mode in ('jsonld','all'): output['jsonld'] = extract_jsonld(html)
|
|
69
|
+
print(json.dumps(output, ensure_ascii=False, indent=2)[:3000])
|
package/scripts/fetch.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
fetch.py — 轻量页面抓取(纯标准库)
|
|
4
|
+
|
|
5
|
+
从 URL 抓取页面并提取正文文本,用于 research 深度研究。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import urllib.request
|
|
12
|
+
from html.parser import HTMLParser
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContentExtractor(HTMLParser):
|
|
18
|
+
"""从 HTML 中提取正文文本。"""
|
|
19
|
+
|
|
20
|
+
_skip_tags = {"script", "style", "nav", "header", "footer", "aside", "noscript"}
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super().__init__()
|
|
24
|
+
self._in_skip = 0
|
|
25
|
+
self._blocks: list[tuple[float, str]] = []
|
|
26
|
+
self._current_text: list[str] = []
|
|
27
|
+
|
|
28
|
+
def handle_starttag(self, tag, attrs):
|
|
29
|
+
if tag in self._skip_tags:
|
|
30
|
+
self._in_skip += 1
|
|
31
|
+
|
|
32
|
+
def handle_endtag(self, tag):
|
|
33
|
+
if tag in self._skip_tags:
|
|
34
|
+
self._in_skip = max(0, self._in_skip - 1)
|
|
35
|
+
if tag in ("p", "div", "article", "section", "li", "h1", "h2", "h3", "td"):
|
|
36
|
+
text = "".join(self._current_text).strip()
|
|
37
|
+
if len(text) > 20:
|
|
38
|
+
# 文本密度 = 非空字符 / 总字符
|
|
39
|
+
density = len(text.replace(" ", "")) / max(len(text), 1)
|
|
40
|
+
self._blocks.append((density, text))
|
|
41
|
+
self._current_text = []
|
|
42
|
+
|
|
43
|
+
def handle_data(self, data):
|
|
44
|
+
if self._in_skip == 0:
|
|
45
|
+
self._current_text.append(data)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fetch_page(url: str, max_chars: int = 3000, timeout: int = 8,
|
|
49
|
+
raw: bool = False) -> dict[str, Any]:
|
|
50
|
+
"""抓取页面并提取正文。
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
url: 目标 URL
|
|
54
|
+
max_chars: 最大返回字符数
|
|
55
|
+
timeout: 超时秒数
|
|
56
|
+
raw: True 时返回原始 HTML(用于 extract/crawl_sitemap 等需要 HTML 的场景)
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
req = urllib.request.Request(url, headers={
|
|
60
|
+
"User-Agent": "unified-search/2.5 (+local-research)",
|
|
61
|
+
"Accept": "text/html,application/xhtml+xml,application/xml",
|
|
62
|
+
})
|
|
63
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
64
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
65
|
+
if "pdf" in content_type:
|
|
66
|
+
return {"url": url, "content": "", "html": "", "length": 0, "success": False, "error": "PDF not supported"}
|
|
67
|
+
raw_bytes = resp.read(max(max_chars * 5, 500000))
|
|
68
|
+
# 编码检测
|
|
69
|
+
charset = "utf-8"
|
|
70
|
+
if "charset=" in content_type:
|
|
71
|
+
charset = content_type.split("charset=")[-1].strip().split(";")[0]
|
|
72
|
+
html = raw_bytes.decode(charset, errors="replace")
|
|
73
|
+
|
|
74
|
+
# 正文提取
|
|
75
|
+
extractor = ContentExtractor()
|
|
76
|
+
extractor.feed(html)
|
|
77
|
+
extractor._blocks.sort(key=lambda x: x[0], reverse=True)
|
|
78
|
+
content = "\n\n".join(text for _, text in extractor._blocks[:8])
|
|
79
|
+
|
|
80
|
+
result = {
|
|
81
|
+
"url": url,
|
|
82
|
+
"content": content[:max_chars],
|
|
83
|
+
"length": len(content),
|
|
84
|
+
"success": True,
|
|
85
|
+
}
|
|
86
|
+
if raw:
|
|
87
|
+
result["html"] = html[:max(max_chars * 2, 100000)]
|
|
88
|
+
return result
|
|
89
|
+
except Exception as e:
|
|
90
|
+
return {"url": url, "content": "", "html": "", "length": 0, "success": False, "error": str(e)[:100]}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fetch_pages_parallel(urls: list[str], max_chars: int = 3000,
|
|
94
|
+
timeout: int = 8, max_workers: int = 3) -> list[dict[str, Any]]:
|
|
95
|
+
"""并行抓取多个页面。"""
|
|
96
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
97
|
+
|
|
98
|
+
results = []
|
|
99
|
+
with ThreadPoolExecutor(max_workers=min(len(urls), max_workers)) as ex:
|
|
100
|
+
futures = {ex.submit(fetch_page, url, max_chars, timeout): url for url in urls}
|
|
101
|
+
for fut in as_completed(futures, timeout=timeout + 3):
|
|
102
|
+
try:
|
|
103
|
+
results.append(fut.result())
|
|
104
|
+
except Exception:
|
|
105
|
+
results.append({"url": futures[fut], "content": "", "length": 0, "success": False})
|
|
106
|
+
return results
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# CLI 测试
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
import sys
|
|
112
|
+
url = sys.argv[1] if len(sys.argv) > 1 else "https://docs.python.org/3/"
|
|
113
|
+
result = fetch_page(url, max_chars=500)
|
|
114
|
+
print(f"成功: {result['success']}, 长度: {result['length']}")
|
|
115
|
+
if result["success"]:
|
|
116
|
+
print(result["content"][:300])
|
|
117
|
+
else:
|
|
118
|
+
print(f"错误: {result.get('error', 'unknown')}")
|