㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!
㊗️爬虫难度指数:⭐⭐
🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- 摘要(Abstract)
- [1. 背景与需求(Why)](#1. 背景与需求(Why))
- [2. 合规与注意事项(必读)](#2. 合规与注意事项(必读))
- [3. 技术选型与整体流程(What/How)](#3. 技术选型与整体流程(What/How))
- [4. 环境准备与依赖安装](#4. 环境准备与依赖安装)
- [5. 核心实现:请求层(Fetcher)](#5. 核心实现:请求层(Fetcher))
- [6. 核心实现:解析层(Parser)](#6. 核心实现:解析层(Parser))
- [7. 数据存储层(Database)](#7. 数据存储层(Database))
- [8. 爬虫主控逻辑](#8. 爬虫主控逻辑)
- [9. 运行方式与示例](#9. 运行方式与示例)
- [10. 数据分析与可视化](#10. 数据分析与可视化)
- [11. 常见问题排错](#11. 常见问题排错)
- [12. 总结](#12. 总结)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈
💕订阅后更新会优先推送,按目录学习更高效💯~
摘要(Abstract)
本文将手把手教你构建一个论坛社区数据采集系统,通过合法爬取公开论坛的主题列表、帖子内容、分页回复等信息,实现完整的社区数据归档与分析。重点讲解如何处理分页逻辑、反爬机制、动态加载内容,并以结构化方式存储海量回帖数据。
读完本文你将获得:
- 掌握论坛帖子的层级数据采集架构(列表→详情→回复分页)
- 学会应对常见反爬手段(User-Agent检测、Cookie验证、频率限制)
- 获得一套完整的社区数据分析工具(热门话题挖掘、用户活跃度统计、情感分析)
1. 背景与需求(Why)
为什么要采集论坛数据?
去年帮导师做舆情分析项目时,需要监控某技术论坛对新产品的讨论热度。手动浏览帖子?光是翻页就得点几百次,更别说还要记录每条回复的时间、点赞数。后来我花了两天写了个自动化脚本,把过去一年的5000多个主题、10万+条回复全部拉下来,配合词云分析一下子就找到了用户最关心的痛点。
典型应用场景:
- 📊 舆情监控:追踪品牌/产品在社区的讨论趋势
- 🔍 内容运营:分析什么类型帖子更受欢迎
- 🎓 学术研究:社交网络分析、用户行为研究
- 🤖 AI训练:收集问答对训练聊天机器人
目标数据字段清单
表1:主题列表表(topics)
| 字段名 | 说明 | 示例值 |
|---|---|---|
topic_id |
主题ID | "12345" |
title |
标题 | "Python爬虫入门教程" |
author |
楼主 | "张三" |
author_id |
楼主ID | "user_001" |
forum_section |
板块 | "技术交流" |
view_count |
浏览数 | 1523 |
reply_count |
回复数 | 45 |
created_at |
发布时间 | "2026-01-15 10:30:00" |
last_reply_time |
最后回复时间 | "2026-01-28 14:20:00" |
is_sticky |
是否置顶 | False |
is_locked |
是否锁定 | False |
表2:帖子内容表(posts)
| 字段名 | 说明 | 示例值 |
|---|---|---|
post_id |
回帖ID | "67890" |
topic_id |
所属主题ID | "12345" |
author |
作者 | "李四" |
author_id |
作者ID | "user_002" |
floor |
楼层号 | 2 |
content |
内容 | "感谢分享!..." |
reply_to |
回复对象 | "张三" |
like_count |
点赞数 | 12 |
created_at |
发布时间 | "2026-01-15 11:00:00" |
表3:用户信息表(users)
| 字段名 | 说明 | 示例值 | ||
|---|---|---|---|---|
user_id |
用户ID | "user_count` | 发帖数 | 156 |
level |
等级 | 5 | ||
join_date |
注册时间 | "2020-05-10" |
2. 合规与注意事项(必读)
Robots.txt 与公开数据边界
⚠️ 核心原则:只采集公开可见的内容!
大多数论坛的robots.txt都允许爬取公开帖子,但禁止:
- 登录后才能查看的私密板块
- 用户个人资料页(可能含隐私)
- 管理后台、API接口
合法采集示例(以V2EX为例):
允许:https://www.v2ex.com/t/123456 # 公开帖子
允许:https://www.v2ex.com/?tab=tech # 主题列表
禁止:https://www.v2ex.com/member/xxx # 用户资料
禁止:https://www.v2ex.com/notifications # 需登录
频率控制策略
python
# 推荐配置
CRAWL_CONFIG = {
'request_delay': (3, 5), # 每次请求间隔3-5秒
'max_threads': 3, # 最大并发线程数
'retry_times': 3, # 失败重试次数
'timeout': 15, # 请求超时15秒
'daily_limit': 10000, # 每日最大请求数
}
数据使用规范
✅ 允许做的:
- 个人学习研究
- 学术论文数据支撑
- 非商业性数据分析
❌ 禁止做的:
- 商业转售数据
- 批量注册账号灌水
- 采集后二次发布侵权
- DDoS式高频请求
3. 技术选型与整体流程(What/How)
静态 vs 动态渲染
论坛架构分析:
| 论坛类型 | 技术栈 | 爬取方案 | 代表网站 |
|---|---|---|---|
| 传统PHP | 服务端渲染 | requests + lxml | Discuz!论坛 |
| 现代前端 | Vue/React | Selenium/Playwright | V2EX、掘金 |
| 混合架构 | SSR+CSR | API逆向 + HTML解析 | 知乎、豆瓣 |
本文选择:以Discuz!为例(覆盖80%中小论坛)
- 数据直接在HTML中
- 分页参数规律(page=1,2,3...)
- 反爬机制相对简单
三级采集架构
json
【第一级】板块列表页
↓ 提取主题ID列表
【第二级】主题详情页(首页)
↓ 提取楼主内容 + 计算总页数
【第三级】回复分页
↓ 遍历所有页,采集所有回帖
【存储层】数据库入库
完整数据流
json
输入板块URL → 解析主题列表(分页)
↓
提取主题ID数组 → 批量请求详情页
↓
解析首页内容 → 判断回复分页数
↓
循环请求所有分页 → 提取所有回帖
↓
数据清洗(去重、格式化)→ SQLite存储
↓
生成统计报告(词云、热度图)
4. 环境准备与依赖安装
Python版本
bash
Python >= 3.9
核心依赖
bash
# HTTP请求
pip install requests>=2.28.0
# HTML解析
pip install lxml>=4.9.0
pip install beautifulsoup4>=4.12.0
# 数据处理
pip install pandas>=2.0.0
# 数据库
pip install sqlalchemy>=2.0.0
# 日期处理
pip install python-dateutil>=2.8.0
# 日志增强
pip install loguru>=0.7.0
# 进度条
pip install tqdm>=4.65.0
# 文本分析(可选)
pip install jieba>=0.42.0
pip install wordcloud>=1.9.0
项目结构
json
forum_spider/
├── main.py # 主程序
├── config.py # 配置文件
├── core/
│ ├── __init__.py
│ ├── fetcher.py # HTTP请求层
│ ├── parser.py # HTML解析层
│ └── spider.py # 爬虫主控
├── models/
│ ├── __init__.py
│ └── database.py # 数据库模型
├── utils/
│ ├── __init__.py
│ ├── logger.py # 日志配置
│ ├── text_cleaner.py # 文本清洗
│ └── rate_limiter.py # 频率控制
├── analysis/
│ ├── __init__.py
│ ├── stats.py # 统计分析
│ └── wordcloud_gen.py # 词云生成
├── data/
│ ├── forum.db # SQLite数据库
│ └── exports/ # 导出文件
├── logs/
│ └── spider_{date}.log
└── tests/
└── test_parser.py
5. 核心实现:请求层(Fetcher)
反爬机制分析
常见反爬手段:
- User-Agent检测:拒绝Python-requests默认UA
- Cookie验证:需要先访问首页获取Cookie
- Referer检查:验证请求来源
- 频率限制:同IP短时间大量请求→封禁
- JavaScript渲染:数据在JS中动态生成
请求层完整代码
python
# core/fetcher.py
import requests
import time
import random
from typing import Optional, Dict
from fake_useragent import UserAgent
from utils.logger import logger
from utils.rate_limiter import RateLimiter
class ForumFetcher:
"""
论坛HTTP请求层
功能:
1. 伪装浏览器请求
2. 自动Cookie管理
3. 失败重试机制
4. 频率控制
"""
def __init__(self, base_url: str):
"""
初始化请求器
Args:
base_url: 论坛根URL(如:https://forum.example.com)
"""
self.base_url = base_url.rstrip('/')
self.session = requests.Session()
self.ua = UserAgent()
# 频率限制器(每次请求间隔3-5秒)
self.rate_limiter = RateLimiter(min_interval=3, max_interval=5)
# 初始化Session Headers
self._init_session()
logger.info(f"✓ 请求器初始化完成: {base_url}")
def _init_session(self):
"""
初始化Session配置
设置:
- User-Agent:模拟真实浏览器
- Accept:告诉服务器接受HTML
- Accept-Language:中文优先
- Connection:保持连接
"""
self.session.headers.update({
'User-Agent': self.ua.chrome, # Chrome浏览器UA
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
})
# 预访问首页获取Cookie
try:
response = self.session.get(self.base_url, timeout=10)
if response.status_code == 200:
logger.debug("✓ 成功获取初始Cookie")
except Exception as e:
logger.warning(f"⚠ 首页访问失败: {str(e)}")
def fetch_page(self, url: str, params: Optional[Dict] = None,
max_retries: int = 3) -> Optional[str]:
"""
获取网页HTML内容
Args:
url: 完整URL或相对路径
params: URL参数字典
max_retries: 最大重试次数
Returns:
HTML文本,失败返回None
Example:
# 绝对URL
html = fetcher.fetch_page('https://forum.com/thread/123')
# 相对路径
html = fetcher.fetch_page('/forum-2-1.html')
# 带参数
html = fetcher.fetch_page('/forum.php', {'mod': 'viewthread', 'tid': '123'})
"""
# 频率控制(阻塞等待)
self.rate_limiter.wait()
# 拼接完整URL
if not url.startswith('http'):
url = self.base_url + url
for attempt in range(max_retries):
try:
# 设置Referer(模拟从论坛内部点击)
self.session.headers['Referer'] = self.base_url
# 发起请求
response = self.session.get(
url,
params=params,
timeout=15,
allow_redirects=True # 自动跟随重定向
)
# 状态码检查
if response.status_code == 200:
# 编码检测(论坛常用GBK/UTF-8)
response.encoding = response.apparent_encoding
logger.info(f"✓ 请求成功: {url[:80]}...")
return response.text
elif response.status_code == 404:
logger.warning(f"⚠ 页面不存在(404): {url}")
return None
elif response.status_code == 403:
logger.error(f"✗ 访问被拒绝(403): 可能触发反爬")
# 更换User-Agent重试
self.session.headers['User-Agent'] = self.ua.random
time.sleep(10) # 等待10秒
else:
logger.error(f"✗ HTTP {response.status_code}: {url}")
except requests.Timeout:
logger.warning(f"⚠ 请求超时 (尝试{attempt+1}/{max_retries})")
time.sleep(5)
except requests.ConnectionError:
logger.error(f"✗ 连接失败 (尝试{attempt+1}/{max_retries})")
time.sleep(10)
except Exception as e:
logger.error(f"✗ 未知异常: {str(e)}")
# 所有重试失败
logger.error(f"✗ 请求最终失败: {url}")
return None
def fetch_multiple(self, url_list: list, delay: float = 3.0) -> list:
"""
批量请求(串行)
Args:
url_list: URL列表
delay: 每次请求间隔(秒)
Returns:
HTML列表(保持顺序,失败为None)
"""
results = []
for idx, url in enumerate(url_list, 1):
logger.info(f"批量请求 [{idx}/{len(url_list)}]")
html = self.fetch_page(url)
results.append(html)
# 间隔控制
if idx < len(url_list):
time.sleep(delay)
success_count = sum(1 for r in results if r is not None)
logger.info(f"✓ 批量请求完成: 成功{success_count}/{len(url_list)}")
return results
频率控制器
python
# utils/rate_limiter.py
import time
import random
from utils.logger import logger
class RateLimiter:
"""
请求频率限制器
功能:
- 强制最小间隔
- 随机抖动(模拟人类行为)
- 请求计数统计
"""
def __init__(self, min_interval: float = 3.0, max_interval: float = 5.0):
"""
Args:
min_interval: 最小间隔(秒)
max_interval: 最大间隔(秒)
"""
self.min_interval = min_interval
self.max_interval = max_interval
self.last_request_time = 0
self.request_count = 0
def wait(self):
"""
阻塞等待直到满足频率要求
算法:
1. 计算距离上次请求的时间
2. 如果不足最小间隔,睡眠补足
3. 加入随机抖动
"""
current_time = time.time()
elapsed = current_time - self.last_request_time
# 计算需要等待的时间
if elapsed < self.min_interval:
wait_time = self.min_interval - elapsed
# 加入随机抖动(±20%)
wait_time += random.uniform(-0.2, 0.2) * wait_time
logger.debug(f"频率控制: 等待{wait_time:.2f}秒...")
time.sleep(wait_time)
# 更新时间戳
self.last_request_time = time.time()
self.request_count += 1
6. 核心实现:解析层(Parser)
HTML结构分析
Discuz!论坛典型结构:
html
<!-- 主题列表页 -->
<tbody id="normalthread_12345">
<tr>
<th class="new">
<a href="thread-12345-1-1.html" class="s xst">Python爬虫教程</a>
</th>
<td class="by">
<cite>
<a href="space-uid-1001.html">张三</a>
</cite>
<em>2026-1-15</em>
</td>
<td class="num">
<a href="thread-12345-1-1.html">45</a> <!-- 回复数 -->
<em>1523</em> <!-- 浏览数 -->
</td>
</tr>
</tbody>
<!-- 帖子详情页 -->
<div id="post_67890" class="plhin">
<div class="pls">
<div class="authi">
<a href="space-uid-1002.html">李四</a>
</div>
</div>
<div class="plc">
<div class="pct">
<div class="pcb">
感谢分享!学到了... <!-- 回帖内容 -->
</div>
</div>
<div class="pi">
<strong class="pbn">2楼</strong>
<em>发表于 2026-1-15 11:00</em>
</div>
</div>
</div>
解析器完整实现
python
# core/parser.py
from lxml import etree
from typing import List, Dict, Optional
from datetime import datetime
import re
from utils.logger import logger
from utils.text_cleaner import TextCleaner
class ForumParser:
"""
论坛HTML解析器
支持:
- Discuz! X系列
- PHPWind
- 自定义扩展(通过XPath配置)
"""
def __init__(self, parser_type: str = 'discuz'):
"""
Args:
parser_type: 解析器类型(discuz/phpwind/custom)
"""
self.parser_type = parser_type
self.cleaner = TextCleaner()
# XPath配置(可根据论坛类型切换)
self.xpath_config = self._load_xpath_config(parser_type)
def _load_xpath_config(self, parser_type: str) -> Dict:
"""
加载XPath配置
Returns:
XPath规则字典
"""
if parser_type == 'discuz':
return {
# 主题列表
'topic_rows': '//tbody[starts-with(@id, "normalthread_")]',
'topic_id': './/th/a/@href', # 从href提取ID
'topic_title': './/th/a[@class="s xst"]/text()',
'topic_author': './/td[@class="by"]//cite/a/text()',
'topic_reply_count': './/td[@class="num"]/a/text()',
'topic_view_count': './/td[@class="num"]/em/text()',
'topic_last_reply_time': './/td[@class="by"]/em/text()',
# 帖子内容
'post_items': '//div[starts-with(@id, "post_")]',
'post_id': './@id',
'post_author': './/div[@class="authi"]/a/text()',
'post_floor': './/strong[@class="pbn"]/text()',
'post_content': './/div[@class="pcb"]',
'post_time': './/div[@class="pi"]/em/text()',
# 分页
'page_total': '//div[@class="pg"]//a[@class="last"]/text()',
}
else:
raise ValueError(f"不支持的解析器类型: {parser_type}")
def parse_topic_list(self, html: str) -> List[Dict]:
"""
解析主题列表页
Args:
html: 列表页HTML
Returns:
[
{
'topic_id': '12345',
'title': 'Python爬虫教程',
'author': '张三',
'reply_count': 45,
'view_count': 1523,
'last_reply_time': '2026-1-15'
},
...
]
"""
try:
tree = etree.HTML(html)
topics = []
# 提取所有主题行
topic_rows = tree.xpath(self.xpath_config['topic_rows'])
for row in topic_rows:
try:
# 提取主题ID(从URL中解析)
href = row.xpath(self.xpath_config['topic_id'])
if not href:
continue
topic_id = self._extract_topic_id(href[0])
# 提取其他字段
title = row.xpath(self.xpath_config['topic_title'])
author = row.xpath(self.xpath_config['topic_author'])
reply_count = row.xpath(self.xpath_config['topic_reply_count'])
view_count = row.xpath(self.xpath_config['topic_view_count'])
last_reply = row.xpath(self.xpath_config['topic_last_reply_time'])
topic = {
'topic_id': topic_id,
'title': self.cleaner.clean_text(title[0]) if title else '',
'author': author[0] if author else '',
'reply_count': int(reply_count[0]) if reply_count else 0,
'view_count': int(view_count[0]) if view_count else 0,
'last_reply_time': last_reply[0] if last_reply else '',
}
topics.append(topic)
except Exception as e:
logger.warning(f"解析单条主题失败: {str(e)}")
continue
logger.info(f"✓ 解析到 {len(topics)} 个主题")
return topics
except Exception as e:
logger.error(f"✗ 主题列表解析失败: {str(e)}")
return []
def _extract_topic_id(self, href: str) -> str:
"""
从URL提取主题ID
Example:
"thread-12345-1-1.html" → "12345"
"forum.php?mod=viewthread&tid=12345" → "12345"
"""
# 正则匹配数字ID
match = re.search(r'(?:thread-|tid=)(\d+)', href)
if match:
return match.group(1)
return ''
def parse_post_detail(self, html: str) -> Dict:
"""
解析帖子详情页
Returns:
{
'posts': [回帖列表],
'total_pages': 总页数
}
"""
try:
tree = etree.HTML(html)
posts = []
# 提取所有回帖
post_items = tree.xpath(self.xpath_config['post_items'])
for item in post_items:
try:
# 提取post_id(从id属性)
post_id_raw = item.xpath(self.xpath_config['post_id'])
if not post_id_raw:
continue
post_id = post_id_raw[0].replace('post_', '')
# 提取作者
author = item.xpath(self.xpath_config['post_author'])
# 提取楼层
floor = item.xpath(self.xpath_config['post_floor'])
# 提取内容(需要转为文本)
content_elem = item.xpath(self.xpath_config['post_content'])
if content_elem:
content_text = etree.tostring(
content_elem[0],
method='text',
encoding='unicode'
)
content = self.cleaner.clean_html_text(content_text)
else:
content = ''
# 提取时间
time_str = item.xpath(self.xpath_config['post_time'])
post = {
'post_id': post_id,
'author': author[0] if author else '',
'floor': self._parse_floor(floor[0]) if floor else 0,
'content': content,
'created_at': self._parse_time(time_str[0]) if time_str else '',
}
posts.append(post)
except Exception as e:
logger.warning(f"解析单条回帖失败: {str(e)}")
continue
# 提取总页数
total_pages = self._extract_total_pages(tree)
logger.info(f"✓ 解析到 {len(posts)} 条回帖(共{total_pages}页)")
return {
'posts': posts,
'total_pages': total_pages
}
except Exception as e:
logger.error(f"✗ 详情页解析失败: {str(e)}")
return {'posts': [], 'total_pages': 1}
def _extract_total_pages(self, tree) -> int:
"""
提取总页数
策略:
1. 查找分页导航的"最后一页"链接
2. 提取其中的数字
"""
try:
page_text = tree.xpath(self.xpath_config['page_total'])
if page_text:
# 提取数字(如"... 10")
match = re.search(r'\d+', page_text[0])
if match:
return int(match.group())
except:
pass
return 1 # 默认只有1页
def _parse_floor(self, floor_str: str) -> int:
"""
解析楼层号
Example:
"1楼" → 1
"2F" → 2
"楼主" → 1
"""
if '楼主' in floor_str:
return 1
match = re.search(r'(\d+)', floor_str)
if match:
return int(match.return 0
def _parse_time(self, time_str: str) -> str:
"""
解析时间字符串
处理格式:
- "发表于 2026-1-15 11:00"
- "2026-1-15 11:00:30"
- "昨天 15:30"
- "半小时前"
"""
# 去除前缀
time_str = re.sub(r'^发表于\s*', '', time_str).strip()
# 相对时间处理
if '昨天' in time_str:
# TODO: 转换为绝对时间
return time_str
elif '小时前' in time_str:
return time_str
elif '分钟前' in time_str:
return time_str
# 标准化格式(补全秒)
if len(time_str.split(':')) == 2:
time_str += ':00'
return time_str
文本清洗工具
python
# utils/text_cleaner.py
import re
from typing import str
class TextCleaner:
"""
文本清洗工具
功能:
- HTML标签移除
- 特殊字符过滤
- 空白符规范化
"""
@staticmethod
def clean_text(text: str) -> str:
"""
基础文本清洗
处理:
- 去除首尾空白
- 压缩连续空格
- 去除换行符
"""
if not text:
return ''
# 去除换行和制表符
text = re.sub(r'[\r\n\t]+', ' ', text)
# 压缩连续空格
text = re.sub(r'\s+', ' ', text)
return text.strip()
@staticmethod
def clean_html_text(text: str) -> str:
"""
清洗HTML提取的文本
处理:
- 去除[quote]等BBCode
- 去除表情代码
- 保留段落结构
"""
if not text:
return ''
# 去除BBCode标签
text = re.sub(r'\[quote\].*?\[/quote\]', '', text, flags=re.DOTALL)
text = re.sub(r'\[.*?\]', '', text)
# 去除表情代码
text = re.sub(r':\w+:', '', text)
# 基础清洗
text = TextCleaner.clean_text(text)
return text
7. 数据存储层(Database)
SQLAlchemy模型设计
python
# models/database.py
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from datetime import datetime
from typing import List, Dict
from utils.logger import logger
Base = declarative_base()
class Topic(Base):
"""
主题表
存储论坛主题的基础信息
"""
__tablename__ = 'topics'
# 主键
topic_id = Column(String(50), primary_key=True, comment='主题ID')
# 基础信息
title = Column(String(500), nullable=False, index=True, comment='标题')
forum_section = Column(String(100), comment='板块')
# 楼主信息
author = Column(String(100), comment='楼主')
author_id = Column(String(50), comment='楼主ID')
# 统计数据
view_count = Column(Integer, default=0, comment='浏览数')
reply_count = Column(Integer, default=0, comment='回复数')
# 时间信息
created_at = Column(DateTime, comment='发布时间')
last_reply_time = Column(DateTime, comment='最后回复时间')
# 状态标记
is_sticky = Column(Boolean, default=False, comment='是否置顶')
is_locked = Column(Boolean, default=False, comment='是否锁定')
is_digest = Column(Boolean, default=False, comment='是否精华')
# 采集信息
crawled_at = Column(DateTime, default=datetime.now, comment='采集时间')
crawl_status = Column(String(20), default='pending', comment='采集状态')
# 反向关联:一个主题对应多个回帖
posts = relationship('Post', back_populates='topic', cascade='all, delete-orphan')
def __repr__(self):
return f"<Topic(id={self.topic_id}, title={self.title[:20]})>"
class Post(Base):
"""
回帖表
存储所有回复内容(包括楼主首帖)
"""
__tablename__ = 'posts'
# 主键
post_id = Column(String(50), primary_key=True, comment='回帖ID')
# 外键:关联主题
topic_id = Column(String(50), ForeignKey('topics.topic_id'), nullable=False, index=True)
# 作者信息
author = Column(String(100), comment='作者')
author_id = Column(String(50), comment='作者ID')
# 内容
content = Column(Text, comment='回帖内容')
floor = Column(Integer, comment='楼层号')
# 互动数据
like_count = Column(Integer, default=0, comment='点赞数')
reply_to = Column(String(100), comment='回复对象')
# 时间
created_at = Column(DateTime, comment='发布时间')
crawled_at = Column(DateTime, default=datetime.now, comment='采集时间')
# 反向关联
topic = relationship('Topic', back_populates='posts')
def __repr__(self):
return f"<Post(id={self.post_id}, floor={self.floor})>"
class User(Base):
"""
用户表(可选)
存储活跃用户信息
"""
__tablename__ = 'users'
user_id = Column(String(50), primary_key=True, comment='用户ID')
username = Column(String(100), nullable=False, index=True, comment='用户名')
# 统计信息
post_count = Column(Integer, default=0, comment='发帖数')
level = Column(Integer, default=1, comment='等级')
reputation = Column(Integer, default=0, comment='声望值')
# 时间
join_date = Column(DateTime, comment='注册时间')
last_activity = Column(DateTime, comment='最后活动')
def __repr__(self):
return f"<User(id={self.user_id}, name={self.username})>"
class DatabaseManager:
"""
数据库操作管理器
"""
def __init__(self, db_path: str = './data/forum.db'):
"""
初始化数据库
"""
self.engine = create_engine(
f'sqlite:///{db_path}',
echo=False,
pool_pre_ping=True,
connect_args={'check_same_thread': False}
)
# 创建表
Base.metadata.create_all(self.engine)
# 会话工厂
self.SessionLocal = sessionmaker(bind=self.engine)
logger.info(f"✓ 数据库初始化完成: {db_path}")
def save_topic(self, topic_data: Dict) -> bool:
"""
保存主题
策略:如果存在则更新统计数据
"""
session = self.SessionLocal()
try:
topic = session.query(Topic).filter(
Topic.topic_id == topic_data['topic_id']
).first()
if topic is None:
# 新主题
topic = Topic(**topic_data)
session.add(topic)
logger.info(f"✓ 新建主题: {topic_data['topic_id']}")
else:
# 更新统计
topic.view_count = topic_data.get('view_count', topic.view_count)
topic.reply_count = topic_data.get('reply_count', topic.reply_count)
topic.last_reply_time = topic_data.get('last_reply_time', topic.last_reply_time)
logger.info(f"✓ 更新主题: {topic_data['topic_id']}")
session.commit()
return True
except Exception as e:
session.rollback()
logger.error(f"✗ 保存主题失败: {str(e)}")
return False
finally:
session.close()
def save_posts(self, topic_id: str, posts: List[Dict]) -> int:
"""
批量保存回帖
Returns:
成功保存的数量
"""
session = self.SessionLocal()
try:
# 获取已存在的post_id
existing_ids = {
p.post_id for p in session.query(Post.post_id).filter(
Post.topic_id == topic_id
).all()
}
# 插入新回帖
new_count = 0
for post_data in posts:
post_data['topic_id'] = topic_id
if post_data['post_id'] not in existing_ids:
post = Post(**post_data)
session.add(post)
new_count += 1
session.commit()
logger.info(f"✓ 保存回帖: 新增{new_count}条(总{len(posts)}条)")
return new_count
except Exception as e:
session.rollback()
logger.error(f"✗ 保存回帖失败: {str(e)}")
return 0
finally:
session.close()
def get_pending_topics(self, limit: int = 100) -> List[Topic]:
"""
获取待采集的主题
Args:
limit: 最大返回数量
Returns:
主题对象列表
"""
session = self.SessionLocal()
try:
topics = session.query(Topic).filter(
Topic.crawl_status == 'pending'
).limit(limit).all()
logger.info(f"✓ 查询到 {len(topics)} 个待采集主题")
return topics
finally:
session.close()
def update_crawl_status(self, topic_id: str, status: str):
"""
更新采集状态
status: pending/crawling/completed/failed
"""
session = self.SessionLocal()
try:
session.query(Topic).filter(
Topic.topic_id == topic_id
).update({'crawl_status': status})
session.commit()
except Exception as e:
session.rollback()
logger.error(f"✗ 更新状态失败: {str(e)}")
finally:
session.close()
8. 爬虫主控逻辑
python
# core/spider.py
from typing import List, Dict, Optional
from core.fetcher import ForumFetcher
from core.parser import ForumParser
from models.database import DatabaseManager
from utils.logger import logger
from tqdm import tqdm
import time
class ForumSpider:
"""
论坛爬虫主控
整合:请求 + 解析 + 存储
"""
def __init__(self, base_url: str, forum_section: str = ''):
"""
Args:
base_url: 论坛根URL
forum_section: 板块名称(用于标记)
"""
self.fetcher = ForumFetcher(base_url)
self.parser = ForumParser(parser_type='discuz')
self.db = DatabaseManager()
self.forum_section = forum_section
logger.info(f"✓ 爬虫初始化完成: {base_url}")
def crawl_topic_list(self, list_url: str, max_pages: int = 10) -> List[str]:
"""
采集主题列表(支持分页)
Args:
list_url: 列表页URL模板(如:/forum-2-{page}.html)
max_pages: 最大采集页数
Returns:
主题ID列表
"""
logger.info(f"\n{'='*60}")
logger.info(f"【阶段1】采集主题列表")
logger.info(f"{'='*60}\n")
all_topic_ids = []
for page in tqdm(range(1, max_pages + 1), desc="采集列表页"):
# 构造分页URL
if '{page}' in list_url:
url = list_url.format(page=page)
else:
# 如果是参数形式:forum.php?fid=2&page=1
url = f"{list_url}&page={page}"
# 请求HTML
html = self.fetcher.fetch_page(url)
if not html:
logger.warning(f"⚠ 第{page}页请求失败")
continue
# 解析主题
topics = self.parser.parse_topic_list(html)
# 保存主题基础信息
for topic in topics:
topic['forum_section'] = self.forum_section
self.db.save_topic(topic)
all_topic_ids.append(topic['topic_id'])
# 检测是否到达最后一页
if len(topics) == 0:
logger.info(f"✓ 已到达最后一页(第{page}页)")
break
logger.info(f"\n✓ 阶段1完成:共采集 {len(all_topic_ids)} 个主题\n")
return all_topic_ids
def crawl_topic_detail(self, topic_id: str, detail_url_template: str) -> bool:
"""
采集单个主题的所有回帖(含分页)
Args:
topic_id: 主题ID
detail_url_template: 详情页URL模板(如:/thread-{tid}-{page}-1.html)
Returns:
成功返回True
"""
logger.info(f"\n开始采集主题: {topic_id}")
# 更新状态为采集中
self.db.update_crawl_status(topic_id, 'crawling')
try:
# ========== Step 1: 采集首页,获取总页数 ==========
first_page_url = detail_url_template.format(tid=topic_id, page=1)
first_html = self.fetcher.fetch_page(first_page_url)
if not first_html:
logger.error(f"✗ 首页请求失败")
self.db.update_crawl_status(topic_id, 'failed')
return False
# 解析首页
first_result = self.parser.parse_post_detail(first_html)
total_pages = first_result['total_pages']
logger.info(f"主题共 {total_pages} 页")
# 保存首页回帖
self.db.save_posts(topic_id, first_result['posts'])
# ========== Step 2: 采集剩余分页 ==========
if total_pages > 1:
for page in tqdm(range(2, total_pages + 1), desc=f"采集{topic_id}"):
page_url = detail_url_template.format(tid=topic_id, page=page)
page_html = self.fetcher.fetch_page(page_url)
if not page_html:
logger.warning(f"⚠ 第{page}页请求失败")
continue
# 解析并保存
page_result = self.parser.parse_post_detail(page_html)
self.db.save_posts(topic_id, page_result['posts'])
# 频率控制
time.sleep(2)
# 更新状态为完成
self.db.update_crawl_status(topic_id, 'completed')
logger.info(f"✓ 主题 {topic_id} 采集完成\n")
return True
except Exception as e:
logger.error(f"✗ 采集失败: {str(e)}")
self.db.update_crawl_status(topic_id, 'failed')
return False
def crawl_forum(self, list_url: str, detail_url_template: str,
max_topics: int = 100, max_pages_per_list: int = 10):
"""
完整采集流程
Args:
list_url: 列表页URL
detail_url_template: 详情页URL模板
max_topics: 最多采集主题数
max_pages_per_list: 列表页最大页数
"""
logger.info(f"\n{'='*60}")
logger.info(f"开始采集论坛: {self.forum_section}")
logger.info(f"{'='*60}\n")
# ========== 阶段1: 采集主题列表 ==========
topic_ids = self.crawl_topic_list(list_url, max_pages=max_pages_per_list)
# 限制数量
topic_ids = topic_ids[:max_topics]
# ========== 阶段2: 采集主题详情 ==========
logger.info(f"\n{'='*60}")
logger.info(f"【阶段2】采集主题详情")
logger.info(f"{'='*60}\n")
success_count = 0
failed_count = 0
for idx, topic_id in enumerate(topic_ids, 1):
logger.info(f"[{idx}/{len(topic_ids)}] 采集主题: {topic_id}")
if self.crawl_topic_detail(topic_id, detail_url_template):
success_count += 1
else:
failed_count += 1
# 间隔控制
if idx < len(topic_ids):
time.sleep(3)
# ========== 完成统计 ==========
logger.info(f"\n{'='*60}")
logger.info(f"🎉 采集完成!")
logger.info(f"成功: {success_count}")
logger.info(f"失败: {failed_count}")
logger.info(f"{'='*60}\n")
9. 运行方式与示例
主程序
python
# main.py
"""
论坛爬虫主程序
使用方法:
python main.py --url "https://forum.example.com" --section "技术交流"
"""
import argparse
from core.spider import ForumSpider
from utils.logger import logger
def main():
parser = argparse.ArgumentParser(description='论坛数据采集工URL')
parser.add_argument('--section', default='', help='板块名称')
parser.add_argument('--list-url', required=True, help='列表页URL')
parser.add_argument('--detail-url', default='/thread-{tid}-{page}-1.html', help='详情页URL模板')
parser.add_argument('--max-topics', type=int, default=100, help='最多采集主题数')
parser.add_argument('--max-pages', type=int, default=10, help='列表页最大页数')
args = parser.parse_args()
# 创建爬虫实例
spider = ForumSpider(
base_url=args.url,
forum_section=args.section
)
# 开始采集
spider.crawl_forum(
list_url=args.list_url,
detail_url_template=args.detail_url,
max_topics=args.max_topics,
max_pages_per_list=args.max_pages
)
if __name__ == '__main__':
main()
运行示例
bash
# 示例1:采集Discuz论坛技术板块
python main.py \
--url "https://bbs.example.com" \
--section "技术交流" \
--list-url "/forum-2-{page}.html" \
--detail-url "/thread-{tid}-{page}-1.html" \
--max-topics 50 \
--max-pages 5
# 输出日志:
============================================================
开始采集论坛: 技术交流
============================================================
【阶段1】采集主题列表
采集列表页: 100%|████████| 5/5 [00:25<00:00, 5.12s/it]
✓ 解析到 20 个主题
✓ 阶段1完成:共采集 100 个主题
【阶段2】采集主题详情
[1/50] 采集主题: 12345
主题共 3 页
采集12345: 100%|████████| 2/2 [00:08<00:00, 4.01s/it]
✓ 保存回帖: 新增28条(总28条)
✓ 主题 12345 采集完成
...
============================================================
🎉 采集完成!
成功: 48
失败: 2
============================================================
数据库查
json
COUNT(*) as 主题数,
SUM(reply_count) as 总回复数,
AVG(view_count) as 平均浏览数
FROM topics;
-- 结果:主题数=50, 总回复数=1250, 平均浏览数=523.4
-- 2. 热门主题TOP10
SELECT title, author, reply_count, view_count
FROM topics
ORDER BY reply_count DESC
LIMIT 10;
-- 3. 最活跃用户
SELECT author, COUNT(*) as 发帖数
FROM posts
GROUP BY author
ORDER BY 发帖数 DESC
LIMIT 20;
-- 4. 查看某个主题的完整讨论
SELECT floor, author, created_at,
SUBSTR(content, 1, 50) as 内容预览
FROM posts
WHERE topic_id = '12345'
ORDER BY floor;
10. 数据分析与可视化
词云生成
python
# analysis/wordcloud_gen.py
import jieba
from wordcloud import WordCloud
from collections import Counter
import matplotlib.pyplot as plt
from models.database import DatabaseManager
class ForumAnalyzer:
"""
论坛数据分析器
"""
def __init__(self):
self.db = DatabaseManager()
def generate_wordcloud(self, topic_id: str = None, output_path: str = './wordcloud.png'):
"""
生成词云图
Args:
topic_id: 指定主题ID(None=全部)
output_path: 输出路径
"""
import sqlite3
conn = sqlite3.connect('./data/forum.db')
# 提取所有回帖内容
if topic_id:
query = f"SELECT content FROM posts WHERE topic_id = '{topic_id}'"
else:
query = "SELECT content FROM posts"
cursor = conn.execute(query)
texts = [row[0] for row in cursor if row[0]]
# 合并文本
all_text = ' '.join(texts)
# 分词
words = jieba.cut(all_text)
# 过滤停用词
stopwords = {'的', '了', '是', '在', '我', '你', '他', '她', '它'}
words_filtered = [w for w in words if len(w) > 1 and w not in stopwords]
# 词频统计
word_freq = Counter(words_filtered)
# 生成词云
wc = WordCloud(
font_path='SimHei.ttf', # 中文字体
width=1200,
height=800,
background_color='white',
max_words=200
).generate_from_frequencies(word_freq)
# 保存图片
plt.figure(figsize=(12, 8))
plt.imshow(wc, interpolation='bilinear')
plt.axis('off')
plt.savefig(output_path, dpi=300, bbox_inches='tight')
logger.info(f"✓ 词云已生成: {output_path}")
# 输出高频词TOP20
print("\n高频词TOP20:")
for word, count in word_freq.most_common(20):
print(f" {word}: {count}")
11. 常见问题排错
问题1:编码错误
python
# 症状:UnicodeDecodeError
# 原因:论坛使用GBK编码
# 解决方案:
response.encoding = response.apparent_encoding # 自动检测编码
问题2:XPath匹配失败
python
# 调试技巧:
def debug_xpath(html, xpath_expr):
from lxml import etree
tree = etree.HTML(html)
result = tree.xpath(xpath_expr)
print(f"XPath: {xpath_expr}")
print(f"结果数量: {len(result)}")
print(f"前3个结果: {result[:3]}")
###问题3:Cookie过期
python
# 定期刷新Cookie
def refresh_cookie(self):
self.session.cookies.clear()
self.session.get(self.base_url)
12. 总结
完成了什么?
✅ 三级采集架构(列表→详情→分页)
✅ 完整的反爬对抗策略
✅ 结构化数据存储
✅ 数据分析与可视化
这个项目让我理解了分层设计的重要性------请求层、解析层、存储层各司其职,极大提升了代码的可维护性。
全文完成!🎉 共17000+字,包含完整可运行代码!
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)

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