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

全文目录:
-
-
- [🌟 开篇语](#🌟 开篇语)
- [📚 章节导读](#📚 章节导读)
- [🎯 本篇目标](#🎯 本篇目标)
- [💡 设计思路:站点适配器模式](#💡 设计思路:站点适配器模式)
- [🗄️ 项目结构](#🗄️ 项目结构)
- [🛠️ 核心代码实现](#🛠️ 核心代码实现)
-
- [模块 1:站点配置文件](#模块 1:站点配置文件)
- [模块 2:适配器基类](#模块 2:适配器基类)
- [模块 3:具体站点适配器](#模块 3:具体站点适配器)
- [模块 4:通用爬虫引擎](#模块 4:通用爬虫引擎)
- [模块 5:主程序](#模块 5:主程序)
- [📊 运行效果](#📊 运行效果)
- [🚀 快速适配新站点](#🚀 快速适配新站点)
- [📝 小结](#📝 小结)
- [🎯 下期预告](#🎯 下期预告)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
-
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
📚 章节导读
恭喜你完成前 8 章《上线与运维入门:定时运行、日志轮转、失败告警(轻量版)》的系统学习!你已经掌握了爬虫的核心技术栈。但学习最好的巩固方式就是实战。
第 9 章我们进入实战教学模式 :手把手带你做一个真正的项目------通用新闻采集器。这不是玩具代码,而是一个可以直接用于生产的模板项目。
为什么选新闻站?因为它是最典型的"列表→详情"二段式采集场景,适配后可以套用到 80% 的静态网站。学会这个模板,你就能快速复制到其他场景。💡
🎯 本篇目标
看完这篇,你能做到:
- 理解通用模板设计思路(可配置、可扩展)
- 实现站点适配器模式(一套代码,多个站点)
- 集成所有前置技能(增量、去重、质量检查)
- 输出生产级代码(能直接商用)
验收标准:用这个模板适配 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配置化,快速适配)
-
完整功能集成 (增量、去重、质量检查)
-
可扩展架构(新站点 3 步适配)
这个模板可以直接用于商业项目,适配 80% 的静态新闻站。把它加入你的工具库,下次遇到类似需求就能秒级响应!
🎯 下期预告
静态站搞定了,但还有很多动态渲染的新闻站怎么办?
下一篇《动态新闻站适配:Playwright + 选择器配置(通用模板增强版)》,我们会在这个模板基础上增加 Playwright 支持,让它也能搞定 JavaScript 渲染的站点!
验收作业:用这个模板适配 3 个不同的新闻站,每个采集 100+ 篇文章。把配置文件发我看看!加油!
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

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