Python爬虫实战:论坛社区数据采集实战:从主题列表到分页回帖爬取(附CSV导出 + SQLite持久化存储)!

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

㊗️爬虫难度指数:⭐⭐

🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:

🌟 开篇语

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

运营社区: 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)

反爬机制分析

常见反爬手段:

  1. User-Agent检测:拒绝Python-requests默认UA
  2. Cookie验证:需要先访问首页获取Cookie
  3. Referer检查:验证请求来源
  4. 频率限制:同IP短时间大量请求→封禁
  5. 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 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!

相关推荐
清水白石00829 分钟前
隔离的艺术:用 `unittest.mock` 驯服外部依赖,让测试真正可控
python
码农小韩1 小时前
AIAgent应用开发——大模型理论基础与应用(五)
人工智能·python·提示词工程·aiagent
百锦再1 小时前
Java中的char、String、StringBuilder与StringBuffer 深度详解
java·开发语言·python·struts·kafka·tomcat·maven
Jonathan Star2 小时前
Ant Design (antd) Form 组件中必填项的星号(*)从标签左侧移到右侧
人工智能·python·tensorflow
努力努力再努力wz2 小时前
【Linux网络系列】:TCP 的秩序与策略:揭秘传输层如何从不可靠的网络中构建绝对可靠的通信信道
java·linux·开发语言·数据结构·c++·python·算法
deep_drink2 小时前
【论文精读(三)】PointMLP:大道至简,无需卷积与注意力的纯MLP点云网络 (ICLR 2022)
人工智能·pytorch·python·深度学习·3d·point cloud
njsgcs3 小时前
langchain+vlm示例
windows·python·langchain
勇气要爆发3 小时前
LangGraph 实战:10分钟打造带“人工审批”的智能体流水线 (Python + LangChain)
开发语言·python·langchain
jz_ddk3 小时前
[实战] 从冲击响应函数计算 FIR 系数
python·fpga开发·信号处理·fir·根升余弦·信号成形
醒醒该学习了!3 小时前
如何将json文件转成csv文件(python代码实操)
服务器·python·json