Python多线程爬虫实战:爬取论坛帖子及评论
在数据采集与分析的过程中,论坛数据是一个重要的来源。本文将分享一个用 Python 编写的多线程爬虫,专门爬取某地方论坛(麻辣社区)的特定板块帖子及其评论,并将数据保存为 CSV 文件。通过这个实例,你可以了解到如何结合 requests、BeautifulSoup 和多线程技术高效地抓取网页数据。
一、背景与目标
麻辣社区是四川地区知名的网络论坛,其中"群众呼声"板块(fid=70)聚集了大量民生诉求帖。我们需要抓取该板块下所有帖子的基本信息(标题、作者、发布时间、回复数、查看数等)以及每个帖子内的全部评论(评论者、评论时间、赞同数等),最终存入 CSV 文件供后续分析。
二、技术选型
| 库 | 用途 |
|---|---|
requests |
发送 HTTP 请求,获取网页内容 |
BeautifulSoup |
解析 HTML 文档,提取所需数据 |
re |
正则表达式匹配元素 ID |
threading |
多线程并发请求,提高爬取效率 |
csv |
将数据写入 CSV 文件 |
所有代码均使用 Python 3 编写,在 Windows 环境下测试通过。
三、爬虫设计思路
3.1 整体流程
- 从列表页获取所有帖子的入口链接及基本信息。
- 为每个帖子创建一个线程,在子线程中爬取该帖子的详细内容和评论。
- 主线程等待所有子线程结束后,将数据统一写入 CSV 文件。
3.2 列表页解析
列表页的 URL 形如:
https://bbs.mala.cn/forum.php?mod=forumdisplay&fid=70&typeid=1215&page=1
每个帖子的信息都包含在一个 <tbody> 标签中,其 id 以 normalthread_ 开头,后面跟着帖子 ID。我们通过 find_all('tbody', id=re.compile("^normalthread_")) 提取所有帖子块。
从每个 tbody 中提取:
- 帖子 ID:从
id属性中截取数字部分 - 标题:
<a class="s xst">的文本 - 用户名和用户链接:
<cite><a>标签 - 发帖时间:
<td class="by"><em><span>的文本 - 回复数和查看数:
<td class="num">下的<a>和<em>标签 - 帖子详情页链接:标题标签的
href属性
3.3 详情页解析
进入详情页后,帖子的正文内容位于 <td id="postmessage_xxxx"> 中,通过正则匹配所有此类标签,取第一个作为主帖内容。评论则位于 <table class="plhin"> 表格中,每个评论对应一个 <table> 标签。
对于每个评论,提取:
- 评论者用户 ID 和链接:
<div class="authi"><a>标签 - 评论时间:
<em id="authorpostonxxx">标签,去掉"发表于 "前缀 - 赞同数和反对数:
<a class="replyadd">和<a class="replysubtract">标签,提取数字部分 - 评论内容:同样来自
id="postmessage_xxx"的<td>标签
3.4 多线程并发
如果串行爬取,每个帖子都需要一次请求,当帖子数量较多时耗时严重。因此我们使用多线程:主线程遍历帖子列表,为每个帖子创建一个线程并启动。每个线程独立请求详情页并解析,将结果追加到共享的列表(content_data 和 comment_data)中。
由于 Python 的 GIL(全局解释器锁)使得列表的 append 操作是原子的,因此可以不加锁直接使用。但需要注意,线程的执行顺序不确定,最终数据在列表中的顺序可能与帖子顺序不一致,不过这不影响数据分析。
3.5 数据存储
使用 Python 内置的 csv 模块,将数据写入两个 CSV 文件:
文章内容.csv:包含字段诉求ID、标题、用户名、用户链接、时间、回复数、查看数、文章内容评论内容.csv:包含字段诉求ID、用户ID、用户名、用户链接、时间、赞同数、反对数、评论内容
四、关键代码解析
4.1 设置请求头
为了避免被服务器识别为爬虫,我们添加了常见的浏览器 User-Agent:
python
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
}
4.2 使用正则表达式匹配标签 ID
BeautifulSoup 的 find_all 支持传入正则表达式,这使得我们可以灵活匹配动态生成的 ID:
python
tbodies = soup.find_all('tbody', id=re.compile("^normalthread_"))
tds = soup.find_all('td', id=re.compile("^postmessage_"))
4.3 数据清洗
由于网页源码中可能存在多余的空格、换行符等,我们在提取文本时做了简单的清洗:
python
contents = [context.text.replace('?', '').replace('\n', '').replace('\t', '').replace(' ', '').strip() for context in tds]
4.4 多线程实现
主函数中创建并启动线程:
python
threads = []
for tbody in tbodies:
# 提取数据...
t = threading.Thread(target=fetch_and_parse,
args=(thread_id, headers, title_link, title, user_name, user_link, date, replies, views,
content_data, comment_data))
threads.append(t)
t.start()
for t in threads:
t.join()
fetch_and_parse 函数负责处理详情页,并将解析结果添加到共享列表。
五、运行结果
运行脚本后,控制台会实时打印当前正在爬取的帖子和评论信息:
正在爬取文章作者为【张三】标题为【xx问题】浏览量为【123】的文章信息。。。
正在爬取评论作者为【李四】发帖时间为【2025-3-28 10:00】的评论信息。。。
爬取完成后,会在当前目录下生成 文章内容.csv 和 评论内容.csv 两个文件,可用 Excel 或任何文本编辑器打开查看。
六、注意事项与改进方向
6.1 反爬虫处理
- 虽然添加了 User-Agent,但频繁请求仍可能触发网站反爬机制。建议增加
time.sleep()或使用代理 IP。 - 如果请求失败,可以加入重试机制。
6.2 线程数量控制
- 当帖子数量很多时,创建大量线程可能会对服务器造成压力,也可能导致本地资源耗尽。可以使用线程池(
concurrent.futures.ThreadPoolExecutor)来控制并发数。
6.3 数据完整性
- 代码中假设了所有标签都存在,实际中可能出现缺失,因此需要增加异常捕获,确保程序不会因某个字段缺失而崩溃。
6.4 编码问题
- 文件开头声明了
#coding=gbk,因为 Windows 下默认编码为 GBK,而网页是 UTF-8,保存 CSV 时使用encoding='utf-8'可以避免乱码。
6.5 分页爬取
- 目前只爬取了第一页,若需爬取所有页,可增加循环,通过修改 URL 中的
page参数来实现。
七、总结
本文介绍了一个实际的多线程爬虫项目,展示了如何使用 Python 解析论坛结构、提取数据并利用多线程提高效率。虽然代码较为简单,但涵盖了爬虫开发中的常用技巧:HTML 解析、正则匹配、多线程并发等。你可以基于此框架,根据目标网站的结构稍作修改,快速构建自己的数据采集工具。
完整的源码已附于文末,欢迎交流讨论。
完整代码(已按原文件整理):
python
#coding=gbk
import threading
import re
import requests
from bs4 import BeautifulSoup
import csv
def main_get_data():
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
}
start_url = 'https://bbs.mala.cn/forum.php?mod=forumdisplay&fid=70&typeid=1215&typeid=1215&filter=typeid&page=1'
response = requests.get(url=start_url, headers=headers)
soup = BeautifulSoup(response.text, 'lxml')
tbodies = soup.find_all('tbody', id=re.compile("^normalthread_"))
content_data = []
comment_data = []
threads = []
for tbody in tbodies:
thread_id = tbody['id'].replace('normalthread_', '')
title_tag = tbody.find('a', class_='s xst')
title = title_tag.text.strip() if title_tag else '无标题'
user_tag = tbody.find('cite').find('a')
user_name = user_tag.text.strip()
user_link = user_tag['href']
date_tag = tbody.find('td', class_='by').find('em').find('span')
date = date_tag.text.strip() if date_tag else '无时间'
reply_view_tag = tbody.find('td', class_='num')
replies = reply_view_tag.find('a').text.strip()
views = reply_view_tag.find('em').text.strip()
title_link = title_tag.get('href')
t = threading.Thread(target=fetch_and_parse,
args=(thread_id, headers, title_link, title, user_name, user_link, date, replies, views,
content_data, comment_data))
threads.append(t)
t.start()
for t in threads:
t.join()
return content_data, comment_data
def fetch_and_parse(thread_id, headers, title_link, title, user_name, user_link, date, replies, views, content_data, comment_data):
response = requests.get(url=title_link, headers=headers)
soup = BeautifulSoup(response.text, 'lxml')
tds = soup.find_all('td', id=re.compile("^postmessage_"))
contents = [context.text.replace('?', '').replace('\n', '').replace('\t', '').replace(' ', '').strip() for context in tds]
content_data.append([thread_id, title, user_name, user_link, date, replies, views, contents[0]])
print(f"正在爬取文章作者为【{user_name}】标题为【{title}】浏览量为【{views}】的文章信息。。。")
tables = soup.find_all('table', attrs={'class': 'plhin'})
for index, table in enumerate(tables):
user_info_link = table.find('div', class_='authi').find('a').get('href')
user_id = re.findall('uid=(\d+)', user_info_link, re.S)[0]
user_info_tag = table.find('div', class_='authi').find('a')
comment_user_name = user_info_tag.text.strip()
post_time_tag = table.find('em', id=re.compile("^authorposton"))
post_time = post_time_tag.text.replace('发表于 ', '') if post_time_tag else '无时间'
support_tag = table.find('a', class_='replyadd')
against_tag = table.find('a', class_='replysubtract')
support_count = support_tag.text.strip().split(' ')[-1] if support_tag else '0'
against_count = against_tag.text.strip().split(' ')[-1] if against_tag else '0'
support_count = '0' if support_count == '赞同' else support_count
against_count = '0' if against_count == '反对' else against_count
comment_data.append(
[thread_id, user_id, comment_user_name, user_info_link, post_time, support_count, against_count, contents[index]])
print(f"正在爬取评论作者为【{comment_user_name}】发帖时间为【{post_time}】的评论信息。。。")
def save_data(content_data, comment_data):
with open('文章内容.csv', 'w', newline='', encoding='utf-8') as content_file:
content_writer = csv.writer(content_file)
content_writer.writerow(['诉求ID', '标题', '用户名', '用户链接', '时间', '回复数', '查看数', '文章内容'])
content_writer.writerows(content_data)
with open('评论内容.csv', 'w', newline='', encoding='utf-8') as comment_file:
comment_writer = csv.writer(comment_file)
comment_writer.writerow(['诉求ID', '用户ID', '用户名', '用户链接', '时间', '赞同数', '反对数', '评论内容'])
comment_writer.writerows(comment_data)
if __name__ == '__main__':
content_data, comment_data = main_get_data()
save_data(content_data, comment_data)