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

全文目录:
-
-
- [🌟 开篇语](#🌟 开篇语)
- [📌 上期回顾](#📌 上期回顾)
- [🎯 本节目标](#🎯 本节目标)
- 一、理解"列表→详情"模式
-
- [1.1 为什么需要两段式?](#1.1 为什么需要两段式?)
- [1.2 数据流程图](#1.2 数据流程图)
- 二、第一步:采集列表页
-
- [2.1 分析列表页结构](#2.1 分析列表页结构)
- [2.2 提取 URL 列表](#2.2 提取 URL 列表)
- [2.3 提取额外的列表字段(可选)](#2.3 提取额外的列表字段(可选))
- 三、第二步:采集详情页
-
- [3.1 设计详情页解析器](#3.1 设计详情页解析器)
- 四、完整的两段式采集流程
-
- [4.1 流程编排器](#4.1 流程编排器)
- [4.2 列表页解析器示例](#4.2 列表页解析器示例)
- 五、处理分页逻辑
-
- [5.1 三种常见分页方式](#5.1 三种常见分页方式)
- [5.2 自动识别总页数](#5.2 自动识别总页数)
- 六、去重机制
-
- [6.1 基于 URL 的去重](#6.1 基于 URL 的去重)
- [6.2 基于内容的去重](#6.2 基于内容的去重)
- 七、完整示例:新闻采集器
- 八、本节小结
- 九、课后作业(必做,验收进入下一节)
- [🔮 下期预告](#🔮 下期预告)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
-
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
📌 上期回顾
上期内容《稳定性第一课:超时、重试、退避(指数退避)!》,我们重点学习了爬取的核心技能:编写了生产级的 HttpClient、实现了智能重试和限速机制。现在,你已经掌握了稳定、礼貌的请求能力
从本节开始,我们将这些技能组合起来,解决真实的业务场景。而最经典、最常见的场景就是:列表页→详情页的两段式采集
这个模式适用于 90% 的爬虫项目:新闻网站、电商平台、招聘信息、房产数据...几乎所有结构化数据的采集都是这个套路!
🎯 本节目标
通过本节学习,你将能够:
- 理解"列表→详情"模式的设计思想
- 从列表页提取详情页 URL
- 设计数据流转管道(采集→解析→存储)
- 处理翻页逻辑(页码、offset、cursor)
- 实现去重机制(避免重复采集)
- 交付验收:完成一个新闻采集器(列表+详情完整流程)
一、理解"列表→详情"模式
1.1 为什么需要两段式?
典型网站结构:
json
首页 / 分类页(列表页)
├─ 新闻标点击] → 详情页1(完整内容)
├─ 新闻标题2 → [点击] → 详情页2
├─ 新闻标题3 → [点击] → 详情页3
...
└─ [下一页]
为什么不能只采集列表页?
❌ 列表页通常只有:
- 标题
- 摘要(截断的)
- 发布时间
- 缩略图
✅ 详情页才有完整数据:
- 完整正文
- 作者信息
- 标签/分类
- 评论数/阅读量
- 相关推荐
1.2 数据流程图
json
┌─────────────┐
│ 列表页URL │
└──────┬──────┘
│
▼
┌─────────────┐
│ 请求列表页 │ ← HttpClient + 限速
└──────┬──────┘
│
▼
┌─────────────┐
│ 解析列表页 │ ← BeautifulSoup/lxml
│ 提取URL列表 │
└──────┬──────┘
│
▼
┌─────────────┐
│ 遍历URL │
└──────┬──────┘
│
▼
┌─────────────┐
│ 请求详情页 │ ← HttpClient + 限速
└──────┬──────┘
│
▼
┌─────────────┐
│ 解析详情页 │ ← 提取完整字段
│ 提取数据 │
└──────┬──────┘
│
▼
┌─────────────┐
│ 清洗数据 │ ← DataCleaner
└──────┬──────┘
│
▼
┌─────────────┐
│ 保存数据 │ ← CSV/JSON/数据库
└─────────────┘
二、第一步:采集列表页
2.1 分析列表页结构
示例:新闻列表页
html
<div class="news-list">
<div class="news-item">
<h3><a href="/news/12345">新闻标题A</a></h3>
<p class="summary">这是摘要...</p>
<span class="time">2025-01-21</span>
</div>
<div class="news-item">
<h3><a href="/news/12346">新闻标题B</a></h3>
<p class="summary">这是摘要...</p>
<span class="time">2025-01-20</span>
</div>
...
</div>
需要提取的信息:
- ✅ 详情页 URL(必需)
- ✅ 标题(可选,详情页会重复采集)
- ✅ 发布时间(可选,用于判断是否需要采集)
2.2 提取 URL 列表
python
from bs4 import BeautifulSoup
from urllib.parse import urljoin
def extract_detail_urls(html, base_url, selector='div.news-item h3 a'):
"""
从列表页提取详情页 URL
Args:
html: 列表页 HTML
base_url: 基础 URL(用于拼接相对路径)
selector: CSS 选择器
Returns:
list: URL 列表
"""
soup = BeautifulSoup(html, 'lxml')
urls = []
# 提取所有链接
links = soup.select(selector)
for link in links:
href = link.get('href')
if href:
# 拼接完整 URL(处理相对路径)
full_url = urljoin(base_url, href)
urls.append(full_url)
print(f"📋 从列表页提取到 {len(urls)} 个URL")
return urls
# 使用示例
base_url = "https://example.com/news/"
html = fetch_page(base_url + "?page=1")
urls = extract_detail_urls(html, base_url)
print(urls)
# ['https://example.com/news/12345', 'https://example.com/news/12346', ...]
2.3 提取额外的列表字段(可选)
python
def extract_list_items(html):
"""
提取列表页的完整信息
Returns:
list: 列表项信息字典
"""
soup = BeautifulSoup(html, 'lxml')
items = []
news_items = soup.select('div.news-item')
for item in news_items:
# 提取标题和链接
title_elem = item.select_one('h3 a')
if not title_elem:
continue
# 提取各字段
item_data = {
'url': urljoin(base_url, title_elem.get('href')),
'title': title_elem.text.strip(),
'summary': item.select_one('p.summary').text.strip() if item.select_one('p.summary') else '',
'publish_time': item.select_one('span.time').text.strip() if item.select_one('span.time') else '',
}
items.append(item_data)
print(f"📋 提取到 {len(items)} 条列表信息")
return items
三、第二步:采集详情页
3.1 设计详情页解析器
python
class NewsDetailParser:
"""新闻详情页解析器"""
def __init__(self):
"""初始化选择器配置"""
self.selectors = {
'title': 'h1.article-title',
'author': 'span.author',
'publish_time': 'span.publish-time',
'content': 'div.article-content',
'tags': 'div.tags a',
}
def parse(self, html, url):
"""
解析详情页
Args:
html: 详情页 HTML
url: 详情页 URL
Returns:
dict: 解析后的数据
"""
soup = BeautifulSoup(html, 'lxml')
data = {
'url': url,
'crawl_time': datetime.now().isoformat(),
}
# 提取标题
title_elem = soup.select_one(self.selectors['title'])
data['title'] = title_elem.text.strip() if title_elem else ''
# 提取作者
author_elem = soup.select_one(self.selectors['author'])
data['author'] = author_elem.text.strip() if author_elem else ''
# 提取发布时间
time_elem = soup.select_one(self.selectors['publish_time'])
data['publish_time'] = time_elem.text.strip() if time_elem else ''
# 提取正文
content_elem = soup.select_one(self.selectors['content'])
if content_elem:
# 移除脚本和样式
for script in content_elem.select('script, style'):
script.decompose()
data['content'] = content_elem.get_text(separator='\n', strip=True)
else:
data['content'] = ''
# 提取标签
tag_elems = soup.select(self.selectors['tags'])
data['tags'] = [tag.text.strip() for tag in tag_elems]
return data
# 使用示例
parser = NewsDetailParser()
html = fetch_page("https://example.com/news/12345")
data = parser.parse(html, "https://example.com/news/12345")
print(data)
四、完整的两段式采集流程
4.1 流程编排器
python
"""
两段式采集器
文件名: two_stage_spider.py
"""
import time
from typing import List, Dict, Optional
from datetime import datetime
import json
class TwoStageSpider:
"""两段式爬虫(列表→详情)"""
def __init__(self,
http_client,
list_parser,
detail_parser,
save_dir='data/output'):
"""
初始化
Args:
http_client: HTTP 客户端(带限速)
list_parser: 列表页解析器
detail_parser: 详情页解析器
save_dir: 数据保存目录
"""
self.client = http_client
self.list_parser = list_parser
self.detail_parser = detail_parser
self.save_dir = save_dir
# 去重集合
self.crawled_urls = set()
# 统计
self.stats = {
'list_pages': 0,
'detail_pages': 0,
'success': 0,
'failed': 0,
'duplicates': 0,
}
os.makedirs(save_dir, exist_ok=True)
def crawl_list_page(self, list_url: str) -> List[str]:
"""
采集列表页,提取详情页 URL
Args:
list_url: 列表页 URL
Returns:
list: 详情页 URL 列表
"""
print(f"\n{'='*60}")
print(f"📋 采集列表页: {list_url}")
print(f"{'='*60}")
response = self.client.get(list_url)
if not response:
print(f"❌ 列表页请求失败")
return []
self.stats['list_pages'] += 1
# 解析列表页
detail_urls = self.list_parser.parse(response.text, list_url)
print(f"✅ 提取到 {len(detail_urls)} 个详情页 URL")
return detail_urls
def crawl_detail_page(self, detail_url: str) -> Optional[Dict]:
"""
采集详情页
Args:
detail_url: 详情页 URL
Returns:
dict: 解析后的数据,失败返回 None
"""
# 去重检查
if detail_url in self.crawled_urls:
print(f"⏭️ 跳过重复: {detail_url}")
self.stats['duplicates'] += 1
return None
print(f"\n📄 采集详情页: {detail_url}")
response = self.client.get(detail_url)
if not response:
print(f"❌ 详情页请求失败")
self.stats['failed'] += 1
return None
self.stats['detail_pages'] += 1
self.crawled_urls.add(detail_url)
# 解析详情页
try:
data = self.detail_parser.parse(response.text, detail_url)
print(f"✅ 解析成功: {data.get('title', 'N/A')[:30]}")
self.stats['success'] += 1
return data
except Exception as e:
print(f"❌ 解析失败: {e}")
self.stats['failed'] += 1
return None
def crawl_list_and_details(self, list_url: str) -> List[Dict]:
"""
完整流程:采集列表页 → 采集所有详情页
Args:
list_url: 列表页 URL
Returns:
list: 所有详情页的数据
"""
# 第一步:采集列表页
detail_urls = self.crawl_list_page(list_url)
if not detail_urls:
return []
# 第二步:采集所有详情页
all_data = []
for i, url in enumerate(detail_urls, 1):
print(f"\n[{i}/{len(detail_urls)}]")
data = self.crawl_detail_page(url)
if data:
all_data.append(data)
# 礼貌延迟(可选,HttpClient 已有限速)
time.sleep(0.5)
return all_data
def crawl_multiple_pages(self,
list_url_template: str,
page_range: range) -> List[Dict]:
"""
采集多页列表
Args:
list_url_template: 列表页 URL 模板
例如:"https://example.com/news?page={page}"
page_range: 页码范围
例如:range(1, 6) 表示第 1-5 页
Returns:
list: 所有数据
"""
all_data = []
for page in page_range:
list_url = list_url_template.format(page=page)
page_data = self.crawl_list_and_details(list_url)
all_data.extend(page_data)
print(f"\n📊 第 {page} 页完成,累计 {len(all_data)} 条数据")
# 页间延迟
time.sleep(2)
return all_data
def save_data(self, data: List[Dict], filename: str = None):
"""
保存数据到 JSON 文件
Args:
data: 数据列表
filename: 文件名(可选)
"""
if not filename:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"news_{timestamp}.json"
filepath = os.path.join(self.save_dir, filename)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"\n💾 数据已保存: {filepath}")
print(f"📊 共 {len(data)} 条记录")
def print_stats(self):
"""打印统计信息"""
print("\n" + "=" * 60)
print("📊 采集统计")
print("=" * 60)
print(f"列表页数: {self.stats['list_pages']}")
print(f"详情页数: {self.stats['detail_pages']}")
print(f"成功: {self.stats['success']}")
print(f"失败: {self.stats['failed']}")
print(f"重复跳过: {self.stats['duplicates']}")
total = self.stats['success'] + self.stats['failed']
if total > 0:
success_rate = self.stats['success'] / total * 100
print(f"成功率: {success_rate:.2f}%")
print("=" * 60 + "\n")
4.2 列表页解析器示例
python
class NewsListParser:
"""新闻列表页解析器"""
def parse(self, html: str, base_url: str) -> List[str]:
"""
解析列表页,提取详情页 URL
Args:
html: 列表页 HTML
base_url: 基础 URL
Returns:
list: 详情页 URL 列表
"""
from bs4 import BeautifulSoup
from urllib.parse import urljoin
soup = BeautifulSoup(html, 'lxml')
urls = []
# 根据实际网站调整选择器
links = soup.select('div.news-item h3 a')
for link in links:
href = link.get('href')
if href:
full_url = urljoin(base_url, href)
urls.append(full_url)
return urls
五、处理分页逻辑
5.1 三种常见分页方式
方式1:基于页码
python
# URL 模板
list_url_template = "https://example.com/news?page={page}"
# 采集第 1-10 页
spider.crawl_multiple_pages(list_url_template, range(1, 11))
方式2:基于偏移量
python
# URL 模板
list_url_template = "https://example.com/news?offset={offset}&limit=20"
# 生成 offset
page_size = 20
total = 200 # 总记录数
for offset in range(0, total, page_size):
url = list_url_template.format(offset=offset)
spider.crawl_list_and_details(url)
方式3:下一页链接
python
def crawl_with_next_link(spider, start_url):
"""跟随"下一页"链接采集"""
current_url = start_url
while current_url:
# 采集当前页
data = spider.crawl_list_and_details(current_url)
# 查找"下一页"链接
response = spider.client.get(current_url)
soup = BeautifulSoup(response.text, 'lxml')
next_link = soup.select_one('a.next-page')
if next_link:
current_url = urljoin(current_url, next_link.get('href'))
else:
print("✅ 已到最后一页")
break
5.2 自动识别总页数
python
def get_total_pages(html):
"""
从列表页提取总页数
常见位置:
- 分页组件中的最后一页链接
- "共 XX 页" 的文本
"""
soup = BeautifulSoup(html, 'lxml')
# 方法1:从分页链接提取
page_links = soup.select('div.pagination a')
if page_links:
last_link = page_links[-2] # 倒数第二个(最后一个通常是"下一页")
try:
return int(last_link.text)
except ValueError:
pass
# 方法2:从文本提取
import re
pagination_text = soup.select_one('div.pagination').text
match = re.search(r'共\s*(\d+)\s*页', pagination_text)
if match:
return int(match.group(1))
return None
# 使用
first_page_html = fetch_page("https://example.com/news?page=1")
total_pages = get_total_pages(first_page_html)
print(f"总共 {total_pages} 页")
if total_pages:
spider.crawl_multiple_pages(list_url_template, range(1, total_pages + 1))
六、去重机制
6.1 基于 URL 的去重
python
class URLDeduplicator:
"""URL 去重器"""
def __init__(self, persistence_file=None):
"""
初始化
Args:
persistence_file: 持久化文件路径(可选)
"""
self.seen_urls = set()
self.persistence_file = persistence_file
# 加载已采集的 URL
if persistence_file and os.path.exists(persistence_file):
self.load()
def is_duplicate(self, url: str) -> bool:
"""检查 URL 是否重复"""
return url in self.seen_urls
def add(self, url: str):
"""添加 URL 到已见集合"""
self.seen_urls.add(url)
def save(self):
"""保存到文件"""
if self.persistence_file:
with open(self.persistence_file, 'w') as f:
json.dump(list(self.seen_urls), f, indent=2)
def load(self):
"""从文件加载"""
with open(self.persistence_file, 'r') as f:
urls = json.load(f)
self.seen_urls = set(urls)
print(f"📂 加载了 {len(self.seen_urls)} 个已采集 URL")
# 使用
deduplicator = URLDeduplicator(persistence_file='crawled_urls.json')
for url in detail_urls:
if not deduplicator.is_duplicate(url):
data = spider.crawl_detail_page(url)
deduplicator.add(url)
# 保存去重记录
deduplicator.save()
6.2 基于内容的去重
python
import hashlib
def get_content_hash(content: str) -> str:
"""计算内容的 MD5 哈希"""
return hashlib.md5(content.encode()).hexdigest()
class ContentDeduplicator:
"""内容去重器"""
def __init__(self):
self.seen_hashes = set()
def is_duplicate(self, content: str) -> bool:
"""检查内容是否重复"""
content_hash = get_content_hash(content)
if content_hash in self.seen_hashes:
return True
self.seen_hashes.add(content_hash)
return False
# 使用
content_dedup = ContentDeduplicator()
for url in detail_urls:
data = spider.crawl_detail_page(url)
if not content_dedup.is_duplicate(data['content']):
# 保存数据...
pass
else:
print(f"⏭️ 内容重复,跳过: {data['title']}")
七、完整示例:新闻采集器
python
"""
示例:新闻网站采集器
"""
if __name__ == "__main__":
# 1. 创建 HTTP 客户端
from http_client import HttpClientV2
client = HttpClientV2(rate_limit=5) # 每秒 5 个请求
# 2. 创建解析器
list_parser = NewsListParser()
detail_parser = NewsDetailParser()
# 3. 创建爬虫
spider = TwoStageSpider(
http_client=client,
list_parser=list_parser,
detail_parser=detail_parser,
save_dir='data/news'
)
# 4. 采集数据
print("🕷️ 开始采集...")
# 方式1:单页测试
data = spider.crawl_list_and_details("https://example.com/news?page=1")
# 方式2:多页采集
# list_url_template = "https://example.com/news?page={page}"
# data = spider.crawl_multiple_pages(list_url_template, range(1, 6))
# 5. 保存数据
if data:
spider.save_data(data)
# 6. 打印统计
spider.print_stats()
# 7. 关闭客户端
client.close()
八、本节小结
本节我们学习了两段式采集的完整流程:
✅ 模式理解 :列表页→详情页是最经典的爬虫模式
✅ 列表解析 :提取详情页 URL 和基础信息
✅ 详情解析 :提取完整的结构化数据
✅ 流程编排 :设计可复用的 TwoStageSpider 类
✅ 分页处理 :支持页码、偏移量、下一页链接
✅ 去重机制:基于 URL 和内容的去重策略
核心原则:
- 职责分离:列表解析、详情解析、数据保存各司其职
- 可复用性:编写通用类,适配不同网站
- 鲁棒性:异常处理、去重、统计贯穿始终
- 可维护性:选择器配置化,便于网站改版后调整
九、课后作业(必做,验收进入下一节)
任务1:分析目标网站
选择一个新闻或资讯网站,完成:
- 找到列表页和详情页的 URL 规律
- 分析列表页结构,写出提取 URL 的选择器
- 分析详情页结构,列出所有可提取字段
- 设计数据结构(
任务2:编写完整采集器
使用本节的 TwoStageSpider 模板,完成:
- 实现 ListParser 和 DetailParser
- 采集至少 3 页列表(约 50 条数据)
- 保存为 JSON 文件
- 生成采集统计报告
任务3:优化去重策略
为你的采集器添加:
- URL 去重(持久化到文件)
- 内容去重(基于标题或正文)
- 增量采集(只采集新数据)
- 测试并统计去重效果
验收方式:在留言区提交:
- 目标网站分析报告(含截图和选择器)
- 完整采集器代码
- 采集结果的 JSON 文件(前 5 条)
- 统计报告和学习心得
🔮 下期预告
下一节《数据存储:CSV、JSON、SQLite 三剑客》,我们将学习:
- 结构化数据的三种存储方式
- pandas 读写 CSV 的最佳实践
- JSON Lines 格式(适合增量追加)
- SQLite 数据库的使用和查询
- 选择存储方案的决策树
预习建议 :
复习 pandas 的基本用法,了解 SQLite 是什么。思考:什么场景用 CSV,什么场景用数据库?
💬 两段式采集是爬虫的核心模式!掌握它,通吃 90% 场景!
记住:好的架构让代码自己说话。职责分离、可复用、易维护------这是专业工程师的标准。
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

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