-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcrawler.py
More file actions
379 lines (307 loc) · 12.7 KB
/
Copy pathcrawler.py
File metadata and controls
379 lines (307 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
"""
模块 3: 爬虫/搜索 API (Crawler & Search)
封装搜索 API 调用 + 网页内容抓取。
支持多种搜索后端 (SerpAPI / Bing / 自建) 和统一的搜索结果格式。
"""
from __future__ import annotations
import asyncio
import re
from dataclasses import dataclass, field
from typing import Optional
from urllib.parse import quote_plus
import httpx
from bs4 import BeautifulSoup
from config import ModuleConfig
# ── 统一数据结构 ────────────────────────────────────────────────
@dataclass
class SearchResult:
"""单条搜索结果"""
title: str
url: str
snippet: str
source: str = "" # 来源域名
content: str = "" # 抓取到的详情页正文 (可选)
fetch_error: str = "" # 抓取失败原因
@property
def display(self) -> str:
"""格式化输出"""
parts = [f"📄 {self.title}", f" URL: {self.url}", f" 摘要: {self.snippet}"]
if self.content:
parts.append(f" 正文: {self.content[:300]}...")
if self.fetch_error:
parts.append(f" ⚠️ 抓取失败: {self.fetch_error}")
return "\n".join(parts)
@dataclass
class SearchResponse:
"""搜索响应"""
query: str
results: list[SearchResult] = field(default_factory=list)
total_results: int = 0
search_time: float = 0.0
error: str = ""
@property
def context_text(self) -> str:
"""将搜索结果拼接为 LLM 可用的上下文字符串"""
parts = []
for i, r in enumerate(self.results, 1):
part = f"[{i}] {r.title}\n来源: {r.url}\n摘要: {r.snippet}"
if r.content:
part += f"\n详细内容:\n{r.content[:1000]}"
parts.append(part)
return "\n\n---\n\n".join(parts)
# ── HTML 清洗 ───────────────────────────────────────────────────
def _clean_html(html: str, url: str = "") -> str:
"""从 HTML 中提取正文,去除导航、广告、脚本等噪音"""
try:
soup = BeautifulSoup(html, "html.parser")
except Exception:
return ""
# 移除噪音标签
for tag in soup.find_all(["script", "style", "nav", "footer", "header",
"aside", "noscript", "iframe", "form"]):
tag.decompose()
# 移除常见的广告/侧边栏 class
noise_classes = [
"sidebar", "advertisement", "ad-", "banner", "popup",
"nav-", "menu", "footer", "header-", "comment",
"social", "share", "related", "recommend",
]
for tag in soup.find_all(class_=re.compile("|".join(noise_classes))):
tag.decompose()
# 优先从常见内容区域提取
content_selectors = [
"article", "main", '[role="main"]',
".post-content", ".article-content", ".entry-content",
"#content", "#article", ".content",
".post-body", ".markdown-body",
]
content_tag = None
for selector in content_selectors:
content_tag = soup.select_one(selector)
if content_tag:
break
if content_tag is None:
content_tag = soup.body or soup
text = content_tag.get_text(separator="\n", strip=True)
# 压缩多余空行
text = re.sub(r"\n{3,}", "\n\n", text)
text = re.sub(r"[ \t]{3,}", " ", text)
# 截断过长内容 (节省 token)
if len(text) > 3000:
text = text[:3000] + "\n...[内容截断]"
return text
# ── 搜索 API 适配器 ─────────────────────────────────────────────
async def _search_serpapi(query: str, config: ModuleConfig) -> SearchResponse:
"""SerpAPI 搜索 (底层用 Google)"""
params = {
"q": query,
"api_key": config.search.api_key,
"engine": "google",
"num": config.search.max_results,
"hl": "zh-CN",
}
async with httpx.AsyncClient(timeout=config.search.fetch_timeout + 5) as client:
resp = await client.get(config.search.base_url, params=params)
resp.raise_for_status()
data = resp.json()
results = []
for item in data.get("organic_results", [])[: config.search.max_results]:
results.append(SearchResult(
title=item.get("title", ""),
url=item.get("link", ""),
snippet=item.get("snippet", ""),
source=item.get("source", ""),
))
return SearchResponse(
query=query,
results=results,
total_results=data.get("search_information", {}).get("total_results", 0),
)
async def _search_bing(query: str, config: ModuleConfig) -> SearchResponse:
"""Bing Web Search API"""
async with httpx.AsyncClient(timeout=config.search.fetch_timeout + 5) as client:
resp = await client.get(
f"{config.search.base_url}/v7.0/search",
headers={"Ocp-Apim-Subscription-Key": config.search.api_key},
params={
"q": query,
"count": config.search.max_results,
"mkt": "zh-CN",
"textFormat": "Raw",
},
)
resp.raise_for_status()
data = resp.json()
results = []
for item in data.get("webPages", {}).get("value", [])[: config.search.max_results]:
results.append(SearchResult(
title=item.get("name", ""),
url=item.get("url", ""),
snippet=item.get("snippet", ""),
))
return SearchResponse(
query=query,
results=results,
total_results=data.get("webPages", {}).get("totalEstimatedMatches", 0),
)
async def _search_bing_free(query: str, config: ModuleConfig) -> SearchResponse:
"""Bing 直接抓取 — 免 API Key,零成本。冷门实体名自动回退。"""
import re
from bs4 import BeautifulSoup
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept-Language": "zh-CN,zh;q=0.9",
}
async def _do_search(q):
url = f"{config.search.base_url}?q={q}&setlang=zh-Hans&count={config.search.max_results}"
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
results = []
for li in soup.select("li.b_algo"):
h2 = li.find("h2")
a = h2.find("a") if h2 else li.find("a")
p = li.select_one(".b_caption p, .b_lineclamp2")
if a:
results.append(SearchResult(
title=a.get_text(strip=True),
url=a.get("href", ""),
snippet=p.get_text(strip=True) if p else "",
))
return SearchResponse(query=q, results=results[: config.search.max_results])
response = await _do_search(query)
# 冷门实体名检测: 标题不含核心词 → 回退搜裸名或双词
entities = re.findall(r'[\u4e00-\u9fff]{2,4}', query)
if entities and len(response.results) > 3:
main = entities[0] # 取第一个中文词 (通常是主语实体名,而非修饰词)
hit = sum(1 for r in response.results if main in r.title)
if hit <= len(response.results) * 0.3:
# 回退策略:先用双词(实体+修饰),不行再用裸名
fallback_queries = []
if len(entities) > 1:
fallback_queries.append(f"{main} {entities[1]}")
fallback_queries.append(main)
for fbq in fallback_queries:
if fbq == query:
continue
if config.verbose:
print(f"[Crawler] 实体 '{main}' 匹配率 {hit}/{len(response.results)},回退搜: {fbq}")
fallback = await _do_search(fbq)
if fallback.results:
response = fallback
response.query = query
break
return response
# ── 网页抓取 ────────────────────────────────────────────────────
async def _fetch_page_content(url: str, config: ModuleConfig) -> str:
"""抓取单个网页的正文内容"""
try:
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
async with httpx.AsyncClient(
timeout=config.search.fetch_timeout,
follow_redirects=True,
headers=headers,
) as client:
resp = await client.get(url)
resp.raise_for_status()
return _clean_html(resp.text, url)
except Exception as e:
return f""
async def _search_360(query: str, config: ModuleConfig) -> SearchResponse:
"""360 搜索直接抓取 — 免费,中文分词优秀"""
from urllib.parse import quote
url = f"https://www.so.com/s?q={quote(query)}&pn=1"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept-Language": "zh-CN,zh;q=0.9",
}
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
results = []
for item in soup.select(".result, .res-list"):
a = item.select_one("h3 a")
p = item.select_one(".res-desc, .res-rich, .res-summary")
if a:
results.append(SearchResult(
title=a.get_text(strip=True),
url=a.get("href", ""),
snippet=p.get_text(strip=True) if p else "",
))
return SearchResponse(
query=query,
results=results[: config.search.max_results],
)
async def _enrich_results(
response: SearchResponse,
config: ModuleConfig,
) -> SearchResponse:
"""并发抓取搜索结果详情页内容 (只抓前 N 条)"""
top_n = config.search.fetch_content
if not top_n or not response.results:
return response
targets = response.results[:top_n]
if config.verbose:
print(f"[Crawler] 开始并发抓取前 {len(targets)} 个页面...")
async def fetch_one(result: SearchResult) -> SearchResult:
if not result.url:
return result
try:
result.content = await _fetch_page_content(result.url, config)
except Exception as e:
result.fetch_error = str(e)[:200]
return result
tasks = [fetch_one(r) for r in targets]
enriched = await asyncio.gather(*tasks)
# 替换回原列表
response.results[:top_n] = enriched
if config.verbose:
fetched = sum(1 for r in enriched if r.content)
print(f"[Crawler] 成功抓取 {fetched}/{len(targets)} 个页面")
return response
# ── 对外接口 ────────────────────────────────────────────────────
SEARCH_PROVIDERS = {
"360": _search_360,
"bing_free": _search_bing_free,
"serpapi": _search_serpapi,
"bing": _search_bing,
}
async def search_and_fetch(
query: str,
config: ModuleConfig,
) -> SearchResponse:
"""
执行搜索并可选抓取详情页内容。
Args:
query: 搜索查询词 (应该是 rewrite 后的)
config: 模块配置
"""
provider = config.search.provider
search_fn = SEARCH_PROVIDERS.get(provider)
if search_fn is None:
return SearchResponse(
query=query,
error=f"不支持的搜索后端: {provider}. 可选: {list(SEARCH_PROVIDERS.keys())}",
)
if config.verbose:
print(f"[Crawler] 使用 {provider} 搜索: {query}")
try:
response = await search_fn(query, config)
except Exception as e:
return SearchResponse(query=query, error=f"搜索失败: {e}")
if config.verbose:
print(f"[Crawler] 搜索完成, {len(response.results)} 条结果 ({response.total_results} total)")
# 可选: 抓取详情页
if config.search.fetch_content and response.results:
response = await _enrich_results(response, config)
return response