🔥本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!!

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- 上期回顾
- 为什么搜索页采集这么重要?
- 搜索采集的三大难点
- 实战思路:五步系统化解法
- 代码实战:完整搜索采集系统
- 代码实现逻辑说明
- 实战案例演示
-
- [案例1: 学术论文搜索](#案例1: 学术论文搜索)
- [案例2: 竞品监控](#案例2: 竞品监控)
- 进阶技巧
-
- [技巧1: 关键词自动扩展](#技巧1: 关键词自动扩展)
- [技巧2: 增量搜索(只搜新增)](#技巧2: 增量搜索(只搜新增))
- [技巧3: 搜索结果质量评分](#技巧3: 搜索结果质量评分)
- 反爬对抗策略
-
- [策略1: User-Agent轮换](#策略1: User-Agent轮换)
- [策略2: 检测并处理验证码](#策略2: 检测并处理验证码)
- [策略3: IP代理池(](#策略3: IP代理池()
- 常见问题与解决方案
-
- [Q1: 如何判断搜索结果已经翻到底了?](#Q1: 如何判断搜索结果已经翻到底了?)
- [Q2: 同一篇文章被多个关键词找到,怎么合并found_by?](#Q2: 同一篇文章被多个关键词找到,怎么合并found_by?)
- [Q3: 搜索词包含特殊字符怎么办?](#Q3: 搜索词包含特殊字符怎么办?)
- [Q4: 如何避免重复搜索已完成的关键词?](#Q4: 如何避免重复搜索已完成的关键词?)
- 总结与最佳实践
- [下期预告 🔮](#下期预告 🔮)
- [练习作业 ✍️](#练习作业 ✍️)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
上期回顾
上一讲《表格型页面采集:多列、多行、跨页(通用表格解析)!》,我们征服了表格型页面,学会了处理合并单元格、跨页拼接这些"硬骨头"。不过表格数据虽然规整,但覆盖面有限------你只能拿到网站"摆出来"的那些数据。
今天要讲的搜索页采集 就不一样了,它能让你主动出击:输入关键词,把散落在网站各个角落的信息一网打尽。
比如做竞品分析,你可能想知道"有多少篇文章提到了竞品名称";做舆情监控,你想追踪"某个事件的讨论热度"。这些场景,搜索采集就是你的杀手锏💪
但别高兴太早,搜索页采集的坑可不少:关键词太多怎么管理?同一条数据被多个关键词命中怎么去重?频繁搜索被封IP怎么办?
今天咱们就来一个个破解。
为什么搜索页采集这么重要?
先聊聊应用场景,你肯定遇到过:
- 市场调研: 搜索"人工智能+医疗"相关的论文、新闻、专利
- 竞品分析: 监控竞品在各大平台的曝光情况
- 舆情监控: 追踪特定话题的讨论量和情感倾向
- 内容聚合: 从多个站点搜索同一主题,汇总成资料库
- 数据补全: 已有部分数据,通过搜索补全缺失字段
这些需求的共同点:你不知道目标数据的确切位置,只知道关键特征(关键词)。
传统的"列表页→详情页"采集模式就不够用了,你得用搜索功能主动"探测"数据。
搜索采集的三大难点
难点1:关键词管理
假设你要搜索50个关键词,每个关键词可能有几百条结果。怎么:
- 有序执行:确保每个关键词都被搜索过
- 断点续爬:中途挂了能接着跑
- 失败重试:某个关键词搜索失败能单独重来
难点2:结果去重
同一篇文章可能被多个关键词命中。比如搜"Python爬虫"和"网络爬虫"都能找到同一篇教程。你得:
- 识别重复:用什么字段判断"这是同一条数据"?
- 保留来源:记录这条数据是被哪些关键词找到的
- 合并策略:同一条数据多次出现,保留第一次?最后一次?还是合并信息?
难点3:反爬对抗
搜索功能通常是网站的"核心业务",反爬比普通页面严格得多:
- 频率限制:每秒请求不能超过X次
- 验证码:频繁搜索触发人机验证
- IP封禁:短时间大量请求直接封IP
你得设计一套反爬友好策略,既要效率高,又不能把网站"搞炸"。
实战思路:五步系统化解法
第一步:关键词队列设计
用 JSON 文件管理关键词,每个关键词记录状态:
json
{
"keywords": [
{"keyword": "Python爬虫", "status": "pending", "result_count": 0},
{"keyword": "数据采集", "status": "completed", "result_count": 156},
{"keyword": "Web Scraping", "status": "failed", "retry_count": 2}
]
}
字段说明:
status:pending(待处理) /running(进行中) /completed(已完成) /failed(失败)result_count: 采集到的结果数量retry_count: 失败重试次数
第二步:搜索请求封装
分析搜索页的请求参数,通常是:
json
GET /search?q=Python爬虫&page=1&size=20
关键参数:
q/keyword/query: 搜索词page/p: 页码size/pageSize: 每页条数
有些网站还会有:
sort: 排序方式(时间/相关度)date: 时间范围category: 分类筛选
第三步:结果解析与去重键设计
每条搜索结果提取这些字段:
python
{
"title": "Python爬虫入门教程",
"url": "https://example.com/article/123",
"author": "张三",
"publish_date": "2024-01-15",
"found_by": ["Python爬虫", "网络爬虫"], # 被哪些关键词找到
"dedup_key": "md5(url)" # 去重键
}
去重键选择原则:
- 唯一性强: URL通常是最好的选择
- 稳定性高: 不要用"标题",因为可能有标点差异
- 可计算 : 如果URL有动态参数,用
md5(normalize_url(url))
第四步:限速与退避策略
python
# 固定延迟(简单粗暴)
time.sleep(2)
# 随机延迟(更像人)
time.sleep(random.uniform(1.5, 3.0))
# 动态调整(检测到429自动降速)
if response.status_code == 429:
current_delay *= 2 # 翻倍延迟
第五步:失败处理与死信队列
python
if retry_count < max_retries:
# 加入重试队列
retry_queue.append(keyword)
else:
# 加入死信队列(人工介入)
dead_letter_queue.append(keyword)
代码实战:完整搜索采集系统
版本A:关键词队列管理器
python
# keyword_manager.py
import json
from typing import List, Dict, Optional
from pathlib import Path
from datetime import datetime
class KeywordManager:
"""关键词队列管理器"""
def __init__(self, keywords_file: str = "keywords.json"):
self.keywords_file = Path(keywords_file)
self.keywords = self._load_keywords()
def _load_keywords(self) -> List[Dict]:
"""加载关键词列表"""
if not self.keywords_file.exists():
return []
with open(self.keywords_file, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("keywords", [])
def save_keywords(self):
"""保存关键词状态"""
with open(self.keywords_file, "w", encoding="utf-8") as f:
json.dump({"keywords": self.keywords}, f, ensure_ascii=False, indent=2)
def add_keywords(self, keywords: List[str]):
"""批量添加关键词"""
for kw in keywords:
if not any(k["keyword"] == kw for k in self.keywords):
self.keywords.append({
"keyword": kw,
"status": "pending",
"result_count": 0,
"retry_count": 0,
"created_at": datetime.now().isoformat()
})
self.save_keywords()
print(f"✅ 已添加 {len(keywords)} 个关键词")
def get_pending_keywords(self) -> List[Dict]:
"""获取待处理的关键词"""
return [k for k in self.keywords if k["status"] == "pending"]
def get_failed_keywords(self) -> List[Dict]:
"""获取失败的关键词"""
return [k for k in self.keywords if k["status"] == "failed"]
def update_status(self, keyword: str, status: str, result_count: int = 0):
"""更新关键词状态"""
for kw in self.keywords:
if kw["keyword"] == keyword:
kw["status"] = status
kw["result_count"] = result_count
kw["updated_at"] = datetime.now().isoformat()
break
self.save_keywords()
def increment_retry(self, keyword: str):
"""增加重试次数"""
for kw in self.keywords:
if kw["keyword"] == keyword:
kw["retry_count"] = kw.get("retry_count", 0) + 1
break
self.save_keywords()
def get_statistics(self) -> Dict:
"""统计信息"""
total = len(self.keywords)
pending = sum(1 for k in self.keywords if k["status"] == "pending")
completed = sum(1 for k in self.keywords if k["status"] == "completed")
failed = sum(1 for k in self.keywords if k["status"] == "failed")
total_results = sum(k.get("result_count", 0) for k in self.keywords)
return {
"total": total,
"pending": pending,
"completed": completed,
"failed": failed,
"total_results": total_results
}
# 使用示例
if __name__ == "__main__":
manager = KeywordManager()
# 添加关键词
keywords = ["Python爬虫", "数据采集", "Web Scraping", "网络爬虫"]
manager.add_keywords(keywords)
# 查看统计
stats = manager.get_statistics()
print(f"📊 统计: {stats}")
版本B:搜索采集器(带去重)
python
# search_spider.py
import requests
from bs4 import BeautifulSoup
import time
import random
import hashlib
import json
from typing import List, Dict, Optional, Set
from pathlib import Path
from datetime import datetime
class SearchSpider:
"""搜索页采集器"""
def __init__(
self,
base_search_url: str,
output_dir: str = "./search_output",
delay_range: tuple = (1.5, 3.0),
max_pages_per_keyword: int = 10
):
self.base_search_url = base_search_url
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.delay_range = delay_range
self.max_pages_per_keyword = max_pages_per_keyword
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
# 去重集合(用URL的MD5)
self.seen_keys: Set[str] = set()
# 统计信息
self.stats = {
"total_requests": 0,
"total_results": 0,
"duplicates": 0,
"errors": 0
}
def search_keyword(self, keyword: str, max_pages: int = None) -> List[Dict]:
"""搜索单个关键词"""
if max_pages is None:
max_pages = self.max_pages_per_keyword
all_results = []
for page in range(1, max_pages + 1):
print(f" 📄 搜索 '{keyword}' 第 {page}/{max_pages} 页...")
results = self._fetch_search_page(keyword, page)
if not results:
print(f" ⚠️ 第 {page} 页无结果,停止翻页")
break
# 去重并标记来源
unique_results = []
for item in results:
dedup_key = self._generate_dedup_key(item)
if dedup_key in self.seen_keys:
self.stats["duplicates"] += 1
# 更新已存在项的found_by
self._update_found_by(item["url"], keyword)
else:
self.seen_keys.add(dedup_key)
item["found_by"] = [keyword]
item["dedup_key"] = dedup_key
unique_results.append(item)
all_results.extend(unique_results)
print(f" ✅ 第 {page} 页获得 {len(results)} 条,去重后 {len(unique_results)} 条")
# 礼貌延迟
self._smart_delay()
return all_results
def _fetch_search_page(self, keyword: str, page: int) -> List[Dict]:
"""请求搜索页"""
params = {
"q": keyword, # 根据实际网站调整参数名
"page": page,
"size": 20
}
try:
resp = self.session.get(
self.base_search_url,
params=params,
timeout=15
)
resp.raise_for_status()
self.stats["total_requests"] += 1
return self._parse_search_results(resp.text, keyword)
except requests.RequestException as e:
print(f" ❌ 请求失败: {e}")
self.stats["errors"] += 1
return []
def _parse_search_results(self, html: str, keyword: str) -> List[Dict]:
"""解析搜索结果(需根据实际网站调整)"""
soup = BeautifulSoup(html, "lxml")
# 示例选择器,实际需要根据网站调整
result_items = soup.select(".search-result-item")
results = []
for item in result_items:
try:
title_elem = item.select_one(".title a")
author_elem = item.select_one(".author")
date_elem = item.select_one(".date")
result = {
"title": title_elem.get_text(strip=True) if title_elem else "",
"url": title_elem["href"] if title_elem else "",
"author": author_elem.get_text(strip=True) if author_elem else "",
"publish_date": date_elem.get_text(strip=True) if date_elem else "",
"search_keyword": keyword,
"crawled_at": datetime.now().isoformat()
}
if result["url"]: # 必须有URL才算有效
results.append(result)
except Exception as e:
print(f" ⚠️ 解析单条结果失败: {e}")
continue
self.stats["total_results"] += len(results)
return results
def _generate_dedup_key(self, item: Dict) -> str:
"""生成去重键"""
url = item.get("url", "")
# 对URL做规范化处理
normalized_url = self._normalize_url(url)
return hashlib.md5(normalized_url.encode()).hexdigest()
def _normalize_url(self, url: str) -> str:
"""URL规范化(去除无关参数)"""
from urllib.parse import urlparse, parse_qs, urlencode
parsed = urlparse(url)
# 移除常见的追踪参数
params = parse_qs(parsed.query)
params.pop("utm_source", None)
params.pop("utm_medium", None)
params.pop("from", None)
new_query = urlencode(params, doseq=True)
return parsed._replace(query=new_query).geturl()
def _update_found_by(self, url: str, keyword: str):
"""更新已存在项的found_by字段"""
# 这里简化处理,实际需要回写到已保存的数据
pass
def _smart_delay(self):
"""智能延迟"""
delay = random.uniform(*self.delay_range)
# 如果错误率升高,增加延迟
if self.stats["total_requests"] > 0:
error_rate = self.stats["errors"] / self.stats["total_requests"]
if error_rate > 0.1: # 错误率超过10%
delay *= 2
print(f" ⚠️ 错误率较高,延迟增加到 {delay:.2f} 秒")
time.sleep(delay)
def save_results(self, results: List[Dict], filename: str = "search_results.jsonl"):
"""保存结果"""
output_path = self.output_dir / filename
with open(output_path, "a", encoding="utf-8") as f:
for item in results:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
print(f"💾 已保存 {len(results)} 条结果到 {output_path}")
def print_statistics(self):
"""打印统计信息"""
print("\n" + "="*70)
print("📊 采集统计")
print("="*70)
print(f"总请求数: {self.stats['total_requests']}")
print(f"总结果数: {self.stats['total_results']}")
print(f"去重数: {self.stats['duplicates']}")
print(f"错误数: {self.stats['errors']}")
if self.stats["total_requests"] > 0:
success_rate = (1 - self.stats["errors"]/self.stats["total_requests"]) * 100
print(f"成功率: {success_rate:.2f}%")
print("="*70 + "\n")
# 使用示例
if __name__ == "__main__":
spider = SearchSpider(
base_search_url="https://example.com/search",
delay_range=(2.0, 4.0),
max_pages_per_keyword=5
)
keyword = "Python爬虫"
results = spider.search_keyword(keyword)
spider.save_results(results)
spider.print_statistics()
版本C:完整工程化系统
整合关键词管理 + 搜索采集 + 失败重试
python
# complete_search_system.py
import requests
from bs4 import BeautifulSoup
import time
import random
import hashlib
import json
from typing import List, Dict, Optional, Set
from pathlib import Path
from datetime import datetime
from keyword_manager import KeywordManager
class CompleteSearchSystem:
"""完整搜索采集系统"""
def __init__(
self,
base_search_url: str,
keywords_file: str = "keywords.json",
output_dir: str = "./complete_search_output",
max_pages_per_keyword: int = 10,
max_retry: int = 3,
delay_range: tuple = (2.0, 4.0)
):
self.base_search_url = base_search_url
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.max_pages_per_keyword = max_pages_per_keyword
self.max_retry = max_retry
self.delay_range = delay_range
# 关键词管理器
self.kw_manager = KeywordManager(keywords_file)
# HTTP会话
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "text/html,application/json",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"
})
# 去重存储
self.dedup_storage_file = self.output_dir / "dedup_keys.json"
self.seen_keys = self._load_dedup_keys()
# 全局结果存储
self.all_results = []
# 统计
self.stats = {
"start_time": None,
"end_time": None,
"keywords_processed": 0,
"keywords_failed": 0,
"total_results": 0,
"unique_results": 0,
"duplicates": 0,
"total_requests": 0,
"errors": 0
}
def _load_dedup_keys(self) -> Set[str]:
"""加载去重键"""
if self.dedup_storage_file.exists():
with open(self.dedup_storage_file, "r") as f:
return set(json.load(f))
return set()
def _save_dedup_keys(self):
"""保存去重键"""
with open(self.dedup_storage_file, "w") as f:
json.dump(list(self.seen_keys), f)
def run(self):
"""运行完整流程"""
self.stats["start_time"] = datetime.now()
print("🚀 搜索采集系统启动")
print(f"📋 关键词统计: {self.kw_manager.get_statistics()}")
# 获取待处理关键词
pending_keywords = self.kw_manager.get_pending_keywords()
if not pending_keywords:
print("⚠️ 没有待处理的关键词")
return
print(f"📌 共 {len(pending_keywords)} 个关键词待处理\n")
# 逐个处理
for idx, kw_info in enumerate(pending_keywords, 1):
keyword = kw_info["keyword"]
print(f"\n[{idx}/{len(pending_keywords)}] 🔍 正在搜索: {keyword}")
try:
results = self._search_with_retry(keyword)
if results:
self.all_results.extend(results)
self.kw_manager.update_status(keyword, "completed", len(results))
self.stats["keywords_processed"] += 1
print(f" ✅ 成功采集 {len(results)} 条")
else:
self.kw_manager.update_status(keyword, "completed", 0)
print(f" ⚠️ 无结果")
except Exception as e:
print(f" ❌ 采集失败: {e}")
self.kw_manager.update_status(keyword, "failed")
self.stats["keywords_failed"] += 1
# 保存结果
self._save_all_results()
# 保存去重键
self._save_dedup_keys()
# 统计
self.stats["end_time"] = datetime.now()
self._print_final_report()
def _search_with_retry(self, keyword: str) -> List[Dict]:
"""带重试的搜索"""
for attempt in range(self.max_retry):
try:
results = self._search_keyword(keyword)
return results
except Exception as e:
if attempt < self.max_retry - 1:
wait_time = (2 ** attempt) * random.uniform(1, 2)
print(f" ⚠️ 重试 {attempt+1}/{self.max_retry},等待 {wait_time:.1f} 秒...")
time.sleep(wait_time)
self.kw_manager.increment_retry(keyword)
else:
raise
def _search_keyword(self, keyword: str) -> List[Dict]:
"""搜索单个关键词"""
results = []
for page in range(1, self.max_pages_per_keyword + 1):
page_results = self._fetch_search_page(keyword, page)
if not page_results:
break
# 去重处理
for item in page_results:
dedup_key = self._generate_dedup_key(item)
if dedup_key in self.seen_keys:
self.stats["duplicates"] += 1
else:
self.seen_keys.add(dedup_key)
item["found_by"] = [keyword]
item["dedup_key"] = dedup_key
results.append(item)
self.stats["unique_results"] += 1
# 智能延迟
self._adaptive_delay()
return results
def _fetch_search_page(self, keyword: str, page: int) -> List[Dict]:
"""请求搜索页"""
params = {" = self.session.get(
self.base_search_url,
params=params,
timeout=15
)
resp.raise_for_status()
self.stats["total_requests"] += 1
# 检测是否被限流
if resp.status_code == 429:
raise Exception("触发限流(429)")
return self._parse_results(resp.text, keyword)
except requests.RequestException as e:
self.stats["errors"] += 1
raise
def _parse_results(self, html: str, keyword: str) -> List[Dict]:
"""解析结果"""
soup = BeautifulSoup(html, "lxml")
items = soup.select(".search-item") # 根据实际调整
results = []
for item in items:
try:
title = item.select_one(".title").get_text(strip=True)
url = item.select_one("a")["href"]
results.append({
"title": title,
"url": url,
"search_keyword": keyword,
"crawled_at": datetime.now().isoformat()
})
except:
continue
self.stats["total_results"] += len(results)
return results
def _generate_dedup_key(self, item: Dict) -> str:
"""生成去重键"""
url = item.get("url", "")
return hashlib.md5(url.encode()).hexdigest()
def _adaptive_delay(self):
"""自适应延迟"""
base_delay = random.uniform(*self.delay_range)
# 根据错误率调整
if self.stats["total_requests"] > 10:
error_rate = self.stats["errors"] / self.stats["total_requests"]
if error_rate > 0.2:
base_delay *= 3
print(f" ⚠️ 错误率高,延迟增至 {base_delay:.1f}秒")
time.sleep(base_delay)
def _save_all_results(self):
"""保存所有结果"""
output_file = self.output_dir / "all_results.jsonl"
with open(output_file, "w", encoding="utf-8") as f:
for item in self.all_results:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
print(f"\n💾 已保存 {len(self.all_results)} 条结果到 {output_file}")
def _print_final_report(self):
"""打印最终报告"""
elapsed = (self.stats["end_time"] - self.stats["start_time"]).total_seconds()
report = f"""
{'='*70}
📊 搜索采集最终报告
{'='*70}
开始时间: {self.stats['start_time'].strftime('%Y-%m-%d %H:%M:%S')}
结束时间: {self.stats['end_time'].strftime('%Y-%m-%d %H:%M:%S')}
总耗时: {elapsed/60:.2f} 分钟
关键词统计:
- 成功处理: {self.stats['keywords_processed']}
- 失败: {self.stats['keywords_failed']}
结果统计:
- 总结果数: {self.stats['total_results']}
- 去重后唯一结果: {self.stats['unique_results']}
- 重复数: {self.stats['duplicates']}
请求统计:
- 总请求数: {self.stats['total_requests']}
- 错误数: {self.stats['errors']}
- 成功率: {(1-self.stats['errors']/max(self.stats['total_requests'],1))*100:.2f}%
{'='*70}
"""
print(report)
# 保存报告
with open(self.output_dir / "final_report.txt", "w", encoding="utf-8") as f:
f.write(report)
# ============ 主程序 ============
def main():
"""主程序"""
# 1. 初始化系统
system = CompleteSearchSystem(
base_search_url="https://example.com/search",
keywords_file="keywords.json",
output_dir="./search_results",
max_pages_per_keyword=5,
max_retry=3,
delay_range=(2.0, 4.0)
)
# 2. 添加关键词(首次运行)
keywords = [
"Python爬虫",
"数据采集",
"Web Scraping",
"网络爬虫",
"BeautifulSoup",
"Selenium自动化",
"爬虫框架",
"反爬虫技术"
]
system.kw_manager.add_keywords(keywords)
# 3. 运行采集
system.run()
if __name__ == "__main__":
main()
代码实现逻辑说明
整体架构
这个搜索采集系统采用三层架构:
- 数据层 (
KeywordManager): 管理关键词状态,持久化到JSON - 采集层 (
SearchSpider/CompleteSearchSystem): 执行搜索请求、解析结果 - 去重层: 基于MD5哈希的去重机制,支持跨关键词去重
核心设计点
1. 关键词状态机
json
pending → running → completed
↓ ↓
failed (retry) → dead_letter
每个关键词的状态流转清晰,便于断点续爬。
2. 去重键设计
为什么用URL的MD5?
python
dedup_key = hashlib.md5(normalized_url.encode()).hexdigest()
- 唯一性: URL是最稳定的唯一标识
- 性能: MD5计算快,32字符定长,适合做集合查找
- 规范化 : 先去除追踪参数(
utm_*),避免同一页面因参数不同被重复采集
为什么不用标题?
- 标题可能有标点差异("Python爬虫" vs "Python 爬虫")
- 同一内容可能有不同标题(转载时改标题)
3. 自适应延迟机制
python
def _adaptive_delay(self):
base_delay = random.uniform(2.0, 4.0)
if error_rate > 0.2: # 错误率超过20%
base_delay *= 3 # 延迟翻3倍
原理:
- 正常情况:随机2-4秒延迟(模拟人类行为)
- 错误率升高:说明可能触发限流,自动降速
- 遇到429:立即翻倍延迟并重试
4. 失败重试策略
python
for attempt in range(max_retry):
try:
return search_keyword(keyword)
except:
wait_time = (2 ** attempt) * random.uniform(1, 2) # 指数退避
time.sleep(wait_time)
退避时间:
- 第1次失败:等2秒
- 第2次失败:等4秒
- 第3次失败:等8秒
这样避免在服务器压力大时"雪上加霜"。
5. found_by字段设计
python
item["found_by"] = ["Python爬虫", "网络爬虫"]
用途:
- 记录这条数据被哪些关键词找到
- 后续分析时可以看"哪个关键词覆盖面最广"
- 支持多源聚合(同一条数据来自不同搜索)
实战案例演示
案例1: 学术论文搜索
假设我们要搜索AI领域的论文:
python
# academic_search.py
from complete_search_system import CompleteSearchSystem
# 1. 准备关键词
keywords = [
"深度学习 医疗影像",
"强化学习 机器人",
"自然语言处理 情感分析",
"计算机视觉 目标检测",
"生成对抗网络 图像生成"
]
# 2. 初始化系统
system = CompleteSearchSystem(
base_search_url="https://scholar.example.com/search",
keywords_file="academic_keywords.json",
output_dir="./academic_papers",
max_pages_per_keyword=10, # 每个关键词搜10页
delay_range=(3.0, 6.0) # 学术网站反爬严,延迟加长
)
system.kw_manager.add_keywords(keywords)
system.run()
输出示例:
json
🚀 搜索采集系统启动
📋 关键词统计: {'total': 5, 'pending': 5, 'completed': 0, 'failed': 0}
📌 共 5 个关键词待处理
[1/5] 🔍 正在搜索: 深度学习 医疗影像
✅ 成功采集 87 条
[2/5] 🔍 正在搜索: 强化学习 机器人
✅ 成功采集 56 条
...
💾 已保存 342 条结果到 ./academic_papers/all_results.jsonl
======================================================================
📊 搜索采集最终报告
======================================================================
总耗时: 12.35 分钟
关键词统计:
- 成功处理: 5
- 失败: 0
结果统计:
- 总结果数: 412
- 去重后唯一结果: 342
- 重复数: 70
请求统计:
- 总请求数: 45
- 错误数: 2
- 成功率: 95.56%
======================================================================
案例2: 竞品监控
监控竞品在各平台的曝光:
python
# competitor_monitor.py
keywords = [
"竞品A 评测",
"竞品A 使用体验",
"竞品A vs 我们产品",
"竞品A 优缺点"
]
system = CompleteSearchSystem(
base_search_url="https://search.platform.com/q",
keywords_file="competitor_keywords.json",
output_dir="./competitor_data",
max_pages_per_keyword=20, # 尽可能搜全
delay_range=(1.5, 3.0)
)
system.kw_manager.add_keywords(keywords)
system.run()
# 后续分析
import json
from collections import Counter
with open("./competitor_data/all_results.jsonl", "r") as f:
results = [json.loads(line) for line in f]
# 统计哪个关键词找到的最多
keyword_counts = Counter()
for item in results:
for kw in item.get("found_by", []):
keyword_counts[kw] += 1
print("关键词覆盖排名:")
for kw, count in keyword_counts.most_common():
print(f" {kw}: {count} 条")
进阶技巧
技巧1: 关键词自动扩展
python
def expand_keywords(base_keywords: List[str]) -> List[str]:
"""关键词自动扩展"""
modifiers = ["教程", "入门", "实战", "进阶", "最新", "2024"]
expanded = []
for base_kw in base_keywords:
expanded.append(base_kw) # 原关键词
for modifier in modifiers:
expanded.append(f"{base_kw} {modifier}")
return expanded
# 使用
base = ["Python爬虫", "数据采集"]
all_keywords = expand_keywords(base)
# 结果: ["Python爬虫", "Python爬虫 教程", "Python爬虫 入门", ...]
技巧2: 增量搜索(只搜新增)
python
class IncrementalSearchSystem(CompleteSearchSystem):
"""增量搜索系统"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.last_search_time = self._load_last_search_time()
def _load_last_search_time(self):
"""加载上次搜索时间"""
state_file = self.output_dir / "last_search_time.json"
if state_file.exists():
with open(state_file) as f:
return json.load(f).get("last_time")
return None
def _parse_results(self, html: str, keyword: str) -> List[Dict]:
"""只保留新发布的内容"""
all_results = super()._parse_results(html, keyword)
if not self.last_search_time:
return all_results
# 过滤出发布时间晚于上次搜索的
new_results = []
for item in all_results:
pub_time = item.get("publish_date")
if pub_time and pub_time > self.last_search_time:
new_results.append(item)
return new_results
def run(self):
"""运行并更新时间戳"""
super().run()
# 保存本次搜索时间
current_time = datetime.now().isoformat()
with open(self.output_dir / "last_search_time.json", "w") as f:
json.dump({"last_time": current_time}, f)
技巧3: 搜索结果质量评分
python
def score_result(item: Dict) -> float:
"""给搜索结果打分"""
score = 0.0
# 标题相关性(简化版)
if item.get("search_keyword", "") in item.get("title", ""):
score += 10
# 发布时间(越新越好)
pub_date = item.get("publish_date", "")
if "2024" in pub_date or "2025" in pub_date or "2026" in pub_date:
score += 5
# 作者权威性(有作者信息加分)
if item.get("author"):
score += 3
# 被多个关键词找到(说明相关性强)
score += len(item.get("found_by", [])) * 2
return score
# 使用
results.sort(key=score_result, reverse=True) # 按得分降序
top_10 = results[:10] # 取前10个最相关的
反爬对抗策略
策略1: User-Agent轮换
python
import random
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
]
def rotate_user_agent(self):
"""轮换User-Agent"""
self.session.headers["User-Agent"] = random.choice(USER_AGENTS)
策略2: 检测并处理验证码
python
def _fetch_search_page(self, keyword: str, page: int):
"""请求搜索页(带验证码检测)"""
resp = self.session.get(...)
# 检测是否出现验证码
if "captcha" in resp.url.lower() or "验证" in resp.text:
print("⚠️ 触发验证码,暂停60秒...")
time.sleep(60)
raise Exception("需要人工处理验证码")
return self._parse_results(resp.text, keyword)
策略3: IP代理池(
python
class ProxySearchSystem(CompleteSearchSystem):
"""带代理的搜索系统"""
def __init__(self, *args, proxy_list=None, **kwargs):
super().__init__(*args, **kwargs)
self.proxy_list = proxy_list or []
self.current_proxy_idx = 0
def _get_next_proxy(self):
"""获取下一个代理"""
if not self.proxy_list:
return None
proxy = self.proxy_list[self.current_proxy_idx]
self.current_proxy_idx = (self.current_proxy_idx + 1) % len(self.proxy_list)
return {"http": proxy, "https": proxy}
def _fetch_search_page(self, keyword: str, page: int):
"""使用代理请求"""
proxies = self._get_next_proxy()
resp = self.session.get(..., proxies=proxies, timeout=15)
...
注意: 代理使用需谨慎,确保代理来源合法合规。
常见问题与解决方案
Q1: 如何判断搜索结果已经翻到底了?
A: 三种判断方法:
python
# 方法1: 当前页无结果
if not page_results:
break
# 方法2: 返回的结果数少于预期
if len(page_results) < expected_size: # 比如预期20条,只返回5条
print("可能已到末页")
break
# 方法3: 出现"没有更多结果"的提示
if "没有更多" in html or "no more results" in html.lower():
break
Q2: 同一篇文章被多个关键词找到,怎么合并found_by?
A: 维护一个全局字典:
python
class MergedResultsManager:
"""合并结果管理器"""
def __init__(self):
self.results_dict = {} # key: dedup_key, value: item
def add_result(self, item: Dict):
"""添加结果(自动合并found_by)"""
dedup_key = item["dedup_key"]
if dedup_key in self.results_dict:
# 合并found_by
existing = self.results_dict[dedup_key]
existing["found_by"].extend(item["found_by"])
existing["found_by"] = list(set(existing["found_by"])) # 去重
else:
self.results_dict[dedup_key] = item
def get_all_results(self) -> List[Dict]:
return list(self.results_dict.values())
Q3: 搜索词包含特殊字符怎么办?
A: URL编码处理:
python
from urllib.parse import quote
keyword = "Python爬虫 & 数据分析"
encoded_keyword = quote(keyword)
# 结果: Python%E7%88%AC%E8%99%AB%20%26%20%E6%95%B0%E6%8D%AE%E5%88%86%E6%9E%90
params = {"q": encoded_keyword}
Q4: 如何避免重复搜索已完成的关键词?
A: 利用状态管理:
python
# 只获取pending状态的关键词
pending_keywords = self.kw_manager.get_pending_keywords()
# 如果要重新跑某些关键词,先重置状态
def reset_keyword(keyword: str):
for kw in self.keywords:
if kw["keyword"] == keyword:
kw["status"] = "pending"
kw["retry_count"] = 0
self.save_keywords()
总结与最佳实践
核心思想 : 搜索采集=关键词管理 + 批量搜索 + 智能去重 + 反爬对抗
操作步骤:
- 准备关键词: 手动准备或自动扩展
- 状态管理: 用JSON记录每个关键词的处理状态
- 去重设计: URL机延迟 + 自适应调整
- 失败处理: 重试 + 死信队列
注意事项:
- 礼貌采集: 延迟2-4秒是底线,学术网站建议5-10秒
- 去重键选择: 优先用URL,其次用标题+作者的组合hash
- 增量更新: 定期跑增量,不要每次全量重跑
- 监控错误率: 超过20%就要手动介入检查
什么时候用搜索采集?
- ✅ 需要主动探索数据(不知道具体URL)
- ✅ 多源聚合(从不同站点搜同一主题)
- ✅ 监控追踪(定期搜索关键词看变化)
什么时候不适合?
- ❌ 搜索功能需要登录(Cookie管理复杂)
- ❌ 搜索结果是动态渲染的(需配合Playwright)
- ❌ 反爬极严(验证码+IP限制+设备指纹)
下期预告 🔮
下一讲我们要搞定一个"高级但实用"的场景:多源聚合采集。
很多时候,你需要从3个、5个甚至10个不同网站采集同一类数据,然后合并到一张表里。问题来了:
- 每个网站的字段名不一样怎么办?
- 同一字段格式不同(日期、金额)怎么统一?
- 如何对比各源的数据质量?
- 冲突数据(同ID不同值)怎么处理?
我会教你设计一个通用的多源聚合框架,包括:
- Schema统一设计
- 字段映射表
- 归一化清洗
- 质量对比报告
学会之后,你就能像"数据工程师"一样,轻松整合多源数据😎
练习作业 ✍️
- 必做: 选择一个真实网站(建议:知乎、CSDN、博客园),用本讲代码搜索5个关键词,每个至少采集50条
- 必做: 分析去重效果,统计"被多个关键词找到"的数据占比
- 选做: 实现关键词自动扩展功能,给定3个基础词,扩展成30个
验收标准:
- 去重率 > 10%(说明关键词有重叠)
- 错误率 < 5%
- 成功采集至少200条唯一结果
- 生成完整的统计报告
小提示 : 搜索采集最容易踩的坑是"贪心"------想一次搜几百个关键词。建议小步快跑:先跑10个关键词验证流程,再扩大规模。万一中途被封,损失也小💡
下一讲见,咱们搞定多源聚合!
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。