在真实世界的爬虫项目中,90%以上的采集场景都遵循着"先列表后详情"的采集逻辑。这种两段式采集模式之所以成为行业标准方案,背后有着深刻的工程考量。
列表页通常承载着大量条目摘要,每个条目包含基础字段(如标题、发布时间)和详情页链接。而详情页则包含完整的字段信息。这种架构设计源于现代网站的三大特性:
从爬虫工程角度,两段式采集带来三个显著优势:
提示:在电商爬虫中,列表页可能包含价格、销量等核心字段,而详情页则包含商品描述、参数等扩展信息。根据业务需求合理分配字段采集策略能显著提升效率。
现代网页的列表页通常采用三种技术方案实现:
通过Chrome开发者工具可快速判断类型:
以某新闻网站为例,其列表页采用典型的服务端渲染:
html复制<div class="news-list">
<div class="news-item">
<a href="/news/123" class="title">某重大科技突破</a>
<span class="date">2023-07-15</span>
</div>
<!-- 更多条目... -->
</div>
URL提取是列表页采集的核心环节,常见方法包括:
python复制from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
links = [a['href'] for a in soup.select('.news-item > a.title')]
python复制from lxml import etree
tree = etree.HTML(html)
links = tree.xpath('//div[@class="news-item"]/a[@class="title"]/@href')
python复制import re
links = re.findall(r'<a class="title" href="(.*?)"', html)
避坑指南:相对路径转绝对路径是新手常犯的错误。建议使用urllib.parse的urljoin:
python复制from urllib.parse import urljoin
base_url = 'https://example.com'
absolute_links = [urljoin(base_url, rel_link) for rel_link in links]
除URL外,列表页通常包含有价值的元数据,提前采集可减少详情页请求量:
python复制items = []
for item in soup.select('.news-item'):
items.append({
'title': item.select_one('.title').text.strip(),
'date': item.select_one('.date').text.strip(),
'summary': item.select_one('.summary').text.strip()[:100],
'url': urljoin(base_url, item.select_one('a')['href'])
})
关键技巧:
.strip()清除空白字符专业的爬虫工程会采用解析器模式(Parser Pattern)实现详情页采集,核心优势在于:
基础实现框架:
python复制class DetailParser:
def __init__(self, html):
self.soup = BeautifulSoup(html, 'lxml')
def parse_title(self):
raise NotImplementedError
def parse_content(self):
raise NotImplementedError
def parse_all(self):
return {
'title': self.parse_title(),
'content': self.parse_content(),
# 其他字段...
}
class NewsParser(DetailParser):
def parse_title(self):
return self.soup.select_one('h1.article-title').text.strip()
def parse_content(self):
return '\n'.join(
p.text.strip()
for p in self.soup.select('.article-content p')
)
真实环境中详情页采集需要处理各种异常情况:
python复制def safe_extract(selector, default=''):
element = self.soup.select_one(selector)
return element.text.strip() if element else default
python复制from tenacity import retry, stop_after_attempt, wait_exponential
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1))
def fetch_detail_page(url):
# 实现带有异常处理的请求逻辑
生产级采集系统应采用模块化设计,典型架构包含:
code复制ListCollector → URLQueue → DetailCollector → DataPipeline → Storage
基础实现示例:
python复制class TwoPhaseCrawler:
def __init__(self, start_url):
self.start_url = start_url
self.url_queue = set()
self.seen_urls = set()
def crawl_list_page(self, url):
# 实现列表页采集逻辑
pass
def crawl_detail_page(self, url):
# 实现详情页采集逻辑
pass
def run(self):
list_data = self.crawl_list_page(self.start_url)
for item in list_data:
if item['url'] not in self.seen_urls:
detail_data = self.crawl_detail_page(item['url'])
self.seen_urls.add(item['url'])
yield {**item, **detail_data}
现代网站的分页机制主要有三类:
page=1形式参数python复制def generate_page_urls(base_url, total_pages):
return [f"{base_url}?page={i}" for i in range(1, total_pages+1)]
python复制from selenium.webdriver.common.keys import Keys
driver.get(start_url)
for _ in range(scroll_times):
driver.find_element_by_tag_name('body').send_keys(Keys.END)
time.sleep(2)
智能页数探测方案:
python复制def detect_total_pages(sample_page_html):
# 尝试从分页控件提取
# 尝试从"共X页"文本提取
# 尝试二分法探测末页
# 默认返回安全值(如10页)
当采集规模扩大时,需要更专业的去重方案:
python复制from pybloom_live import ScalableBloomFilter
bf = ScalableBloomFilter(initial_capacity=100000)
if url not in bf:
bf.add(url)
# 处理新URL
python复制import redis
r = redis.Redis()
if r.sadd('unique_urls', url_hash):
# 处理新URL
python复制import aiohttp
import asyncio
async def fetch_all(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
return await asyncio.gather(*tasks)
python复制from urllib3 import PoolManager
http = PoolManager(
num_pools=10,
maxsize=50,
block=True
)
以下是一个可投入生产环境的实现框架:
python复制import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
import json
from pathlib import Path
class NewsCrawler:
def __init__(self, base_url):
self.base_url = base_url
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...'
})
def get_list_page(self, page=1):
url = f"{self.base_url}/news?page={page}"
try:
resp = self.session.get(url, timeout=10)
resp.raise_for_status()
return resp.text
except Exception as e:
print(f"列表页获取失败: {e}")
return None
def parse_list_page(self, html):
soup = BeautifulSoup(html, 'lxml')
articles = []
for item in soup.select('.news-item'):
articles.append({
'title': item.select_one('.title').text.strip(),
'url': urljoin(self.base_url, item.select_one('a')['href']),
'list_data': { # 列表页特有字段
'pub_date': item.select_one('.date').text.strip(),
'comment_count': int(item.select_one('.comments').text)
}
})
return articles
def get_detail_page(self, url):
try:
resp = self.session.get(url, timeout=8)
resp.raise_for_status()
return resp.text
except Exception as e:
print(f"详情页获取失败: {url} - {e}")
return None
def parse_detail_page(self, html):
soup = BeautifulSoup(html, 'lxml')
return {
'content': '\n'.join(
p.text.strip()
for p in soup.select('.article-body p')
),
'author': soup.select_one('.author-name').text.strip(),
'detail_data': { # 详情页特有字段
'view_count': int(soup.select_one('.views').text),
'tags': [a.text for a in soup.select('.tags a')]
}
}
def save_to_json(self, data, filename):
Path('data').mkdir(exist_ok=True)
with open(f'data/{filename}.json', 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def run(self, max_pages=5):
all_news = []
for page in range(1, max_pages + 1):
print(f"正在处理第 {page} 页...")
list_html = self.get_list_page(page)
if not list_html:
continue
articles = self.parse_list_page(list_html)
for article in articles:
detail_html = self.get_detail_page(article['url'])
if not detail_html:
continue
detail_data = self.parse_detail_page(detail_html)
complete_data = {
**article,
**detail_data
}
all_news.append(complete_data)
self.save_to_json(complete_data, f"news_{len(all_news)}")
return all_news
if __name__ == '__main__':
crawler = NewsCrawler('https://news.example.com')
results = crawler.run(max_pages=3)
print(f"共采集 {len(results)} 条新闻数据")
关键改进点:
| 反爬技术 | 应对方案 | 实现要点 |
|---|---|---|
| User-Agent检测 | 轮换UA池 | 维护常见UA列表,随机选择 |
| IP频率限制 | 代理IP池 | 付费/免费IP源,质量检测 |
| 行为指纹 | 模拟人工操作 | 随机延迟、非规律点击 |
| 验证码 | OCR识别/打码平台 | 评估成本与成功率 |
| 数据混淆 | 动态解析算法 | 定期更新解析规则 |
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 列表页返回空结果 | 1. 选择器失效 2. 触发反爬 |
1. 更新选择器 2. 检查请求头 |
| 详情页HTTP 403 | IP被封禁 | 1. 更换代理IP 2. 降低频率 |
| 数据字段缺失 | 页面改版 | 1. 添加备用选择器 2. 设置默认值 |
| 编码混乱 | 响应头未指定编码 | 强制指定resp.encoding='utf-8' |
| 异步加载缺失 | 数据动态加载 | 1. 分析XHR接口 2. 使用Selenium |
当项目需要投入生产环境时,建议考虑以下扩展:
任务调度系统:
监控告警体系:
数据质量保障:
自动化测试:
容器化部署:
dockerfile复制FROM python:3.9
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "crawler.py"]
在真实项目中,我通常会建立这样的开发规范:
这种持续迭代的工程化方法,能确保爬虫系统长期稳定运行。对于刚入门的开发者,建议先从本文的完整示例开始,逐步添加上述高级功能。记住,好的爬虫系统不是一次写成的,而是通过不断解决实际问题演化而来的。