Python爬虫零基础入门【第九章:实战项目教学·第1节】通用新闻采集器:从零打造可复用的静态站模板!

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

全文目录:

      • [🌟 开篇语](#🌟 开篇语)
      • [📚 章节导读](#📚 章节导读)
      • [🎯 本篇目标](#🎯 本篇目标)
      • [💡 设计思路:站点适配器模式](#💡 设计思路:站点适配器模式)
      • [🗄️ 项目结构](#🗄️ 项目结构)
      • [🛠️ 核心代码实现](#🛠️ 核心代码实现)
        • [模块 1:站点配置文件](#模块 1:站点配置文件)
        • [模块 2:适配器基类](#模块 2:适配器基类)
        • [模块 3:具体站点适配器](#模块 3:具体站点适配器)
        • [模块 4:通用爬虫引擎](#模块 4:通用爬虫引擎)
        • [模块 5:主程序](#模块 5:主程序)
      • [📊 运行效果](#📊 运行效果)
      • [🚀 快速适配新站点](#🚀 快速适配新站点)
      • [📝 小结](#📝 小结)
      • [🎯 下期预告](#🎯 下期预告)
      • [🌟 文末](#🌟 文末)
        • [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
        • [✅ 互动征集](#✅ 互动征集)

🌟 开篇语

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

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

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

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

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

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

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

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

📚 章节导读

恭喜你完成前 8 章《上线与运维入门:定时运行、日志轮转、失败告警(轻量版)》的系统学习!你已经掌握了爬虫的核心技术栈。但学习最好的巩固方式就是实战

第 9 章我们进入实战教学模式 :手把手带你做一个真正的项目------通用新闻采集器。这不是玩具代码,而是一个可以直接用于生产的模板项目

为什么选新闻站?因为它是最典型的"列表→详情"二段式采集场景,适配后可以套用到 80% 的静态网站。学会这个模板,你就能快速复制到其他场景。💡

🎯 本篇目标

看完这篇,你能做到:

  1. 理解通用模板设计思路(可配置、可扩展)
  2. 实现站点适配器模式(一套代码,多个站点)
  3. 集成所有前置技能(增量、去重、质量检查)
  4. 输出生产级代码(能直接商用)

验收标准:用这个模板适配 3 个不同的新闻站,每个采集 100+ 篇文章

💡 设计思路:站点适配器模式

核心理念
json 复制代码
通用框架(80%)+ 站点配置(20%)= 快速适配新站点

通用框架负责:

  • 任务调度(列表→详情)
  • 数据入库(去重、幂等)
  • 增量控制(时间/ID)
  • 质量检查(缺失率统计)
  • 日志告警

站点配置负责:

  • 选择器定义(CSS/XPath)
  • 字段映射(发布时间怎么提取)
  • 特殊逻辑(如需要登录、反爬处理)
架构图
json 复制代码
┌─────────────────────────────────────┐
│         main.py(入口)              │
└──────────────┬──────────────────────┘
               │
               ▼
┌──────────────────────────────────────┐
│    NewsSpider(通用爬虫引擎)         │
│  ┌────────────────────────────────┐  │
│  │ 列表采集 → 详情采集 → 数据入库  │  │
│  │ 增量判断 → 质量检查 → 异常重试  │  │
│  └────────────────────────────────┘  │
└──────────────┬───────────────────────┘
               │
               ▼
┌──────────────────────────────────────┐
│   SiteAdapter(站点适配器)           │
│                                       │
│  ├─ CnbetaAdapter(IT资讯)          │
│  ├─ ThePaperAdapter(澎湃新闻)      │
│  └─ CustomAdapter(自定义站点)      │
└──────────────────────────────────────┘

🗄️ 项目结构

json 复制代码
universal_news_spider/
├── config/
│   ├── sites/                    # 站点配置目录
│   │   ├── cnbeta.yaml          # IT资讯配置
│   │   ├── thepaper.yaml        # 澎湃新闻配置
│   │   └── template.yaml        # 配置模板
│   └── spider.yaml              # 爬虫全局配置
│
├── src/
│   ├── core/
│   │   ├── spider.py            # 通用爬虫引擎
│   │   ├── database.py          # 数据库管理
│   │   └── quality.py           # 质量检查
│   │
│   ├── adapters/
│   │   ├── base.py              # 适配器基类
│   │   ├── cnbeta.py            # IT资讯适配器
│   │   └── thepaper.py          # 澎湃新闻适配器
│   │
│   └── utils/
│       ├── parser.py            # 解析工具
│       ├── cleaner.py           # 清洗工具
│       └── logger.py            # 日志工具
│
├── data/                        # 数据目录
│   └── news.db
├── logs/                        # 日志目录
├── requirements.txt
├── main.py                      # 入口文件
└── README.md

🛠️ 核心代码实现

模块 1:站点配置文件
yaml 复制代码
# config/sites/cnbeta.yaml
site:
  name: "cnBeta"
  domain: "https://www.cnbeta.com.tw"
  encoding: "utf-8"
  
list:
  url_template: "https://www.cnbeta.com.tw/articles/tech/{page}.htm"
  start_page: 1
  max_pages: 5
  
  selectors:
    container: ".items-area .item"
    title: ".title a"
    link: ".title a"
    publish_time: ".meta span:first-child"
    summary: ".summary"
  
  pagination:
    type: "page_number"  # page_number / offset / cursor
    param_name: "page"

detail:
  selectors:
    title: "h1.title"
    author: ".author"
    publish_time: ".meta-item time"
    content: ".article-content"
    tags: ".tag-link"
    
  fields:
    title:
      required: true
    content:
      required: true
      min_length: 100
    publish_time:
      required: true
      format: "%Y-%m-%d %H:%M:%S"

incremental:
  enabled: true
  strategy: "time"  # time / id
  boundary_field: "publish_time"

quality:
  min_content_length: 200
  required_fields: ["title", "content", "publish_time"]
模块 2:适配器基类
python 复制代码
# src/adapters/base.py
import yaml
import requests
from bs4 import BeautifulSoup
from abc import ABC, abstractmethod
from pathlib import Path
from datetime import datetime
from urllib.parse import urljoin

class BaseSiteAdapter(ABC):
    """站点适配器基类"""
    
    def __init__(self, config_file):
        self.config = self._load_config(config_file)
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        })
    
    def _load_config(self, config_file):
        """加载站点配置"""
        config_path = Path(config_file)
        if not config_path.exists():
            raise FileNotFoundError(f"配置文件不存在:{config_file}")
        
        with open(config_path, 'r', encoding='utf-8') as f:
            return yaml.safe_load(f)
    
    def fetch_list_page(self, page):
        """
        采集列表页
        
        Returns:
            list: [{title, link, publish_time, summary, dedup_key}, ...]
        """
        url = self._build_list_url(page)
        print(f"📄 采集列表页 {page}:{url}")
        
        try:
            resp = self.session.get(url, timeout=15)
            resp.encoding = self.config['site']['encoding']
            resp.raise_for_status()
            
            soup = BeautifulSoup(resp.text, 'html.parser')
            return self._parse_list_page(soup, url)
            
        except Exception as e:
            print(f"   ❌ 列表页采集失败:{e}")
            return []
    
    def fetch_detail_page(self, url):
        """
        采集详情页
        
        Returns:
            dict: {author, content, tags, ...}
        """
        print(f"🔍 采集详情:{url}")
        
        try:
            resp = self.session.get(url, timeout=15)
            resp.encoding = self.config['site']['encoding']
            resp.raise_for_status()
            
            soup = BeautifulSoup(resp.text, 'html.parser')
            return self._parse_detail_page(soup)
            
        except Exception as e:
            print(f"   ❌ 详情采集失败:{e}")
            return None
    
    def _build_list_url(self, page):
        """构建列表页 URL"""
        template = self.config['list']['url_template']
        
        pagination = self.config['list']['pagination']
        page_type = pagination['type']
        
        if page_type == 'page_number':
            return template.format(page=page)
        elif page_type == 'offset':
            page_size = pagination.get('page_size', 20)
            offset = (page - 1) * page_size
            return template.format(offset=offset)
        else:
            return template.format(page=page)
    
    def _parse_list_page(self, soup, page_url):
        """解析列表页"""
        items = []
        selectors = self.config['list']['selectors']
        
        # 查找所有列表项
        containers = soup.select(selectors['container'])
        
        for container in containers:
            try:
                item = self._extract_list_item(container, selectors, page_url)
                if item:
                    items.append(item)
            except Exception as e:
                print(f"   ⚠️ 单条解析失败:{e}")
                continue
        
        print(f"   ✅ 提取到 {len(items)} 条链接")
        return items
    
    def _extract_list_item(self, container, selectors, page_url):
        """提取单个列表项"""
        # 标题
        title_elem = container.select_one(selectors['title'])
        if not title_elem:
            return None
        title = title_elem.get_text(strip=True)
        
        # 链接
        link_elem = container.select_one(selectors['link'])
        if not link_elem:
            return None
        link = link_elem.get('href', '')
        
        # 处理相对路径
        domain = self.config['site']['domain']
        if link and not link.startswith('http'):
            link = urljoin(domain, link)
        
        # 发布时间(可选)
        publish_time = None
        if 'publish_time' in selectors:
            time_elem = container.select_one(selectors['publish_time'])
            if time_elem:
                publish_time = time_elem.get_text(strip=True)
        
        # 摘要(可选)
        summary = None
        if 'summary' in selectors:
            summary_elem = container.select_one(selectors['summary'])
            if summary_elem:
                summary = summary_elem.get_text(strip=True)
        
        # 生成去重键
        from hashlib import md5
        site_name = self.config['site']['name']
        dedup_key = md5(f"{site_name}_{link}".encode()).hexdigest()
        
        return {
            'source': site_name,
            'title': title,
            'detail_url': link,
            'publish_time': publish_time,
            'summary': summary,
            'dedup_key': dedup_key,
            'list_url': page_url
        }
    
    def _parse_detail_page(self, soup):
        """解析详情页"""
        selectors = self.config['detail']['selectors']
        data = {}
        
        # 提取各字段
        for field, selector in selectors.items():
            elem = soup.select_one(selector)
            if elem:
                if field == 'content':
                    # 正文需要特殊处理
                    data[field] = self._extract_content(elem)
                elif field == 'tags':
                    # 标签可能有多个
                    tag_elems = soup.select(selector)
                    data[field] = ','.join([t.get_text(strip=True) for t in tag_elems])
                else:
                    data[field] = elem.get_text(strip=True)
        
        # 字段验证
        if not self._validate_detail(data):
            return None
        
        return data
    
    def _extract_content(self, content_elem):
        """提取并清洗正文"""
        # 移除脚本、样式
        for tag in content_elem(['script', 'style', 'iframe']):
            tag.decompose()
        
        # 提取文本
        import re
        text = content_elem.get_text(separator='\n', strip=True)
        
        # 清洗多余空行
        text = re.sub(r'\n{3,}', '\n\n', text)
        
        return text
    
    def _validate_detail(self, data):
        """验证详情数据完整性"""
        fields_config = self.config['detail']['fields']
        
        for field, rules in fields_config.items():
            if rules.get('required') and not data.get(field):
                print(f"   ⚠️ 必填字段缺失:{field}")
                return False
            
            # 最小长度检查
            if 'min_length' in rules:
                value = data.get(field, '')
                if len(value) < rules['min_length']:
                    print(f"   ⚠️ 字段长度不足:{field} ({len(value)} < {rules['min_length']})")
                    return False
        
        return True
    
    @abstractmethod
    def parse_publish_time(self, time_str):
        """解析发布时间(子类必须实现)"""
        pass
模块 3:具体站点适配器
python 复制代码
# src/adapters/cnbeta.py
from .base import BaseSiteAdapter
from datetime import datetime
import re

class CnbetaAdapter(BaseSiteAdapter):
    """cnBeta IT资讯适配器"""
    
    def parse_publish_time(self, time_str):
        """
        解析时间字符串
        
        示例:'2026-01-23 10:30:00' 或 '2小时前'
        """
        if not time_str:
            return None
        
        try:
            # 标准格式
            if re.match(r'\d{4}-\d{2}-\d{2}', time_str):
                return datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S')
            
            # 相对时间处理
            if '小时前' in time_str:
                hours = int(re.search(r'(\d+)', time_str).group(1))
                from datetime import timedelta
                return datetime.now() - timedelta(hours=hours)
            
            if '分钟前' in time_str:
                minutes = int(re.search(r'(\d+)', time_str).group(1))
                from datetime import timedelta
                return datetime.now() - timedelta(minutes=minutes)
            
            return None
            
        except Exception as e:
            print(f"   ⚠️ 时间解析失败:{time_str},错误:{e}")
            return None
    
    def _extract_list_item(self, container, selectors, page_url):
        """重写:添加站点特定逻辑"""
        item = super()._extract_list_item(container, selectors, page_url)
        
        if item and item.get('publish_time'):
            # 解析时间
            time_obj = self.parse_publish_time(item['publish_time'])
            if time_obj:
                item['publish_timestamp'] = int(time_obj.timestamp())
                item['publish_time'] = time_obj.strftime('%Y-%m-%d %H:%M:%S')
        
        return item
模块 4:通用爬虫引擎
python 复制代码
# src/core/spider.py
from src.core.database import DatabaseManager
from src.core.quality import QualityChecker
import time

class NewsSpider:
    """通用新闻爬虫引擎"""
    
    def __init__(self, adapter):
        self.adapter = adapter
        self.db = DatabaseManager()
        self.quality = QualityChecker(self.db)
        
        self.site_name = adapter.config['site']['name']
        self.incremental_enabled = adapter.config['incremental']['enabled']
    
    def run(self, max_pages=None):
        """运行完整采集流程"""
        if max_pages is None:
            max_pages = self.adapter.config['list']['max_pages']
        
        print("="*60)
        print(f"🚀 开始采集:{self.site_name}")
        print("="*60)
        
        # 步骤 1:采集列表页
        list_items = self._crawl_list_pages(max_pages)
        
        if not list_items:
            print("❌ 列表页采集失败")
            return
        
        # 步骤 2:保存列表数据
        inserted, skipped = self.db.save_list_items(list_items)
        print(f"\n💾 列表数据保存:新增 {inserted},跳过 {skipped}")
        
        # 步骤 3:采集详情页
        self._crawl_details()
        
        # 步骤 4:质量检查
        self.quality.generate_report(self.site_name)
        
        print("\n" + "="*60)
        print("✨ 采集完成")
        print("="*60)
    
    def _crawl_list_pages(self, max_pages):
        """采集多个列表页"""
        all_items = []
        start_page = self.adapter.config['list']['start_page']
        
        # 如果启用增量,获取上次边界
        last_boundary = None
        if self.incremental_enabled:
            last_boundary = self.db.get_last_boundary(self.site_name)
            if last_boundary:
                print(f"📌 增量模式:上次边界 {last_boundary}")
        
        for page in range(start_page, start_page + max_pages):
            items = self.adapter.fetch_list_page(page)
            
            if not items:
                print(f"🛑 第 {page} 页无数据,停止")
                break
            
            # 增量判断
            if self.incremental_enabled and last_boundary:
                filtered_items = []
                for item in items:
                    if self._should_skip(item, last_boundary):
                        print(f"⏭️  已到增量边界,停止采集")
                        return all_items + filtered_items
                    filtered_items.append(item)
                items = filtered_items
            
            all_items.extend(items)
            print(f"   累计:{len(all_items)} 条")
            
            time.sleep(2)  # 礼貌延迟
        
        return all_items
    
    def _should_skip(self, item, last_boundary):
        """判断是否跳过(增量逻辑)"""
        strategy = self.adapter.config['incremental']['strategy']
        boundary_field = self.adapter.config['incremental']['boundary_field']
        
        current_value = item.get(boundary_field)
        
        if strategy == 'time':
            # 时间比较
            return current_value <= last_boundary if current_value else False
        elif strategy == 'id':
            # ID 比较
            return current_value <= last_boundary if current_value else False
        
        return False
    
    def _crawl_details(self):
        """批量采集详情"""
        while True:
            pending = self.db.get_pending_details(source=self.site_name, limit=10)
            
            if not pending:
                print("\n✅ 所有详情采集完成")
                break
            
            print(f"\n📦 本批次:{len(pending)} 条")
            
            for article_id, detail_url, dedup_key in pending:
                detail = self.adapter.fetch_detail_page(detail_url)
                
                if detail:
                    self.db.update_detail(dedup_key, detail)
                else:
                    self.db.mark_failed(dedup_key, "详情采集失败")
                
                time.sleep(1)
模块 5:主程序
python 复制代码
# main.py
from src.adapters.cnbeta import CnbetaAdapter
from src.core.spider import NewsSpider
from src.core.database import init_database

def main():
    # 初始化数据库
    init_database()
    
    # 创建站点适配器
    adapter = CnbetaAdapter('config/sites/cnbeta.yaml')
    
    # 创建爬虫实例
    spider = NewsSpider(adapter)
    
    # 运行采集
    spider.run(max_pages=5)

if __name__ == '__main__':
    main()

📊 运行效果

json 复制代码
==================================================
🚀 开始采集:cnBeta
==================================================
📌 增量模式:上次边界 2026-01-22 10:00:00

📄 采集列表页 1:https://www.cnbeta.com.tw/articles/tech/1.htm
   ✅ 提取到 20 条链接
   累计:20 条

📄 采集列表页 2:https://www.cnbeta.com.tw/articles/tech/2.htm
   ✅ 提取到 20 条链接
   累计:40 条

⏭️  已到增量边界,停止采集

💾 列表数据保存:新增 15,跳过 25

📦 本批次:10 条

🔍 采集详情:https://www.cnbeta.com.tw/articles/tech/12345.htm
   ✅ 详情采集成功
...

✅ 所有详情采集完成

==================================================
📊 数据质量报告
==================================================
统计总览:
  成功:15 条
  失败:0 条
  
字段缺失率:
  author_missing_rate: 0.0%
  content_missing_rate: 0.0%

==================================================
✨ 采集完成
==================================================

🚀 快速适配新站点

三步适配流程

步骤 1:创建配置文件

yaml 复制代码
# config/sites/new_site.yaml
site:
  name: "新站点"
  domain: "https://news.example.com"

list:
  url_template: "https://news.example.com/list?page={page}"
  selectors:
    container: ".news-item"
    title: "h3.title"
    link: "a.link"
# ... 其他配置

步骤 2:创建适配器(可选)

如果时间格式特殊,才需要自定义:

python 复制代码
# src/adapters/new_site.py
from .base import BaseSiteAdapter

class NewSiteAdapter(BaseSiteAdapter):
    def parse_publish_time(self, time_str):
        # 自定义时间解析逻辑
        pass

步骤 3:运行采集

python 复制代码
adapter = NewSiteAdapter('config/sites/new_site.yaml')
spider = NewsSpider(adapter)
spider.run()

📝 小结

今天我们打造了一个生产级的通用新闻采集器

1配置化,快速适配)

  1. 完整功能集成 (增量、去重、质量检查)

  2. 可扩展架构(新站点 3 步适配)

这个模板可以直接用于商业项目,适配 80% 的静态新闻站。把它加入你的工具库,下次遇到类似需求就能秒级响应!

🎯 下期预告

静态站搞定了,但还有很多动态渲染的新闻站怎么办?

下一篇《动态新闻站适配:Playwright + 选择器配置(通用模板增强版)》,我们会在这个模板基础上增加 Playwright 支持,让它也能搞定 JavaScript 渲染的站点!

验收作业:用这个模板适配 3 个不同的新闻站,每个采集 100+ 篇文章。把配置文件发我看看!加油!

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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

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

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


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

相关推荐
摸鱼仙人~2 小时前
从 Gunicorn 到 FastAPI:Python Web 生产环境架构演进与实战指南
python·fastapi·gunicorn
毕设源码-朱学姐2 小时前
【开题答辩全过程】以 基于Django框架中山社区社会补助系统为例,包含答辩的问题和答案
后端·python·django
醉舞经阁半卷书12 小时前
Matplotlib从入门到精通
python·数据分析·matplotlib
历程里程碑2 小时前
Linux 5 目录权限与粘滞位详解
linux·运维·服务器·数据结构·python·算法·tornado
程序员哈基耄2 小时前
安全高效,本地运行:全能文件格式转换工具
大数据·python·安全
lixin5565563 小时前
基于神经网络的音乐生成增强器
java·人工智能·pytorch·python·深度学习·语言模型
养海绵宝宝的小蜗3 小时前
Python第二次作业
开发语言·python
我的xiaodoujiao3 小时前
使用 Python 语言 从 0 到 1 搭建完整 Web UI自动化测试学习系列 43--添加allure测试报告显示信息和其他封装方法
python·学习·测试工具·allure
无垠的广袤3 小时前
【CPKCOR-RA8D1】RUHMI 转换 AI 模型
人工智能·python·嵌入式硬件·开发板