Python爬虫零基础入门【第三章:Requests 静态爬取入门·第4节】列表页→详情页:两段式采集(90%项目都这样)!

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

全文目录:

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

  我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

  📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》

订阅后更新会优先推送,按目录学习更高效~

📌 上期回顾

上期内容《稳定性第一课:超时、重试、退避(指数退避)!》,我们重点学习了爬取的核心技能:编写了生产级的 HttpClient、实现了智能重试和限速机制。现在,你已经掌握了稳定、礼貌的请求能力

从本节开始,我们将这些技能组合起来,解决真实的业务场景。而最经典、最常见的场景就是:列表页→详情页的两段式采集

这个模式适用于 90% 的爬虫项目:新闻网站、电商平台、招聘信息、房产数据...几乎所有结构化数据的采集都是这个套路!

🎯 本节目标

通过本节学习,你将能够:

  1. 理解"列表→详情"模式的设计思想
  2. 从列表页提取详情页 URL
  3. 设计数据流转管道(采集→解析→存储)
  4. 处理翻页逻辑(页码、offset、cursor)
  5. 实现去重机制(避免重复采集)
  6. 交付验收:完成一个新闻采集器(列表+详情完整流程)

一、理解"列表→详情"模式

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:分析目标网站

选择一个新闻或资讯网站,完成:

  1. 找到列表页和详情页的 URL 规律
  2. 分析列表页结构,写出提取 URL 的选择器
  3. 分析详情页结构,列出所有可提取字段
  4. 设计数据结构(
任务2:编写完整采集器

使用本节的 TwoStageSpider 模板,完成:

  1. 实现 ListParser 和 DetailParser
  2. 采集至少 3 页列表(约 50 条数据)
  3. 保存为 JSON 文件
  4. 生成采集统计报告
任务3:优化去重策略

为你的采集器添加:

  1. URL 去重(持久化到文件)
  2. 内容去重(基于标题或正文)
  3. 增量采集(只采集新数据)
  4. 测试并统计去重效果

验收方式:在留言区提交:

  • 目标网站分析报告(含截图和选择器)
  • 完整采集器代码
  • 采集结果的 JSON 文件(前 5 条)
  • 统计报告和学习心得

🔮 下期预告

下一节《数据存储:CSV、JSON、SQLite 三剑客》,我们将学习:

  • 结构化数据的三种存储方式
  • pandas 读写 CSV 的最佳实践
  • JSON Lines 格式(适合增量追加)
  • SQLite 数据库的使用和查询
  • 选择存储方案的决策树

预习建议

复习 pandas 的基本用法,了解 SQLite 是什么。思考:什么场景用 CSV,什么场景用数据库?


💬 两段式采集是爬虫的核心模式!掌握它,通吃 90% 场景!

记住:好的架构让代码自己说话。职责分离、可复用、易维护------这是专业工程师的标准。

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。

相关推荐
笨鸟先飞,勤能补拙4 小时前
AI 赋能网络安全:技术全景、成熟度评估与实战案例
人工智能·python·安全·web安全·网络安全·sqlite·github
长和信泰光伏储能4 小时前
京津冀光伏发电:绿色能源的未来之路
python·能源
浦信仿真大讲堂5 小时前
从重复操作到自动化闭环:如何让 CST 与 Python 真正协同起来
python·自动化·cst·仿真软件·达索软件
Gu Gu Study5 小时前
ScoutLoop开放域深度研究引擎(agent的初步设计想法)
人工智能·python
卷无止境6 小时前
写代码这件事,到底该讲究点什么?
后端·python
卷无止境6 小时前
循环复杂度到底在算什么,Python 代码怎么才能写得让人一看就懂
后端·python
lpfasd1236 小时前
MediaCrawler 项目深度分析
chrome·python·chrome devtools
Dxy12393102167 小时前
Python项目打包成EXE完整教程(PyInstaller实战避坑)
开发语言·python
bamb007 小时前
一个项目带你入门AI应用开发01
python
0566467 小时前
Python康复训练——常用标准库
开发语言·python·学习