1. 项目缘起与整体设计思路
做数据采集这些年,我经手过不少论坛类站点的抓取需求,贴吧类的页面结构算是比较有代表性的一个。它的分页机制、楼层嵌套、异步加载逻辑,几乎涵盖了传统论坛采集会遇到的大部分问题。这次拿一个模拟的贴吧类论坛站点做实例,把整套流程从头到尾走一遍,包括请求构造、页面解析、翻页控制、数据落盘,以及中途踩到的各种坑。
这个实例适合谁看?如果你已经会写基本的 Python 脚本,知道 requests 和 BeautifulSoup 是干什么的,但还没完整做过一个多页采集项目,那这篇内容基本可以照着复现。如果你已经做过类似的东西,也可以看看后面关于反爬应对和编码问题的部分,那些是我在实际操作中反复调试后才总结出来的细节。
整个项目的目标很明确:给定一个论坛版块,采集指定页数范围内的帖子列表,提取标题、作者、回复数、最后回复时间这几个字段,最后输出成结构化的 CSV 文件。听起来简单,但真正动手时会发现,光是"翻页"这一件事就有好几种做法,每种做法的稳定性和效率差别很大。
先说整体思路。采集类项目我一般拆成四层:请求层负责发 HTTP 请求拿回 HTML;解析层负责从 HTML 里提取结构化数据;调度层负责翻页和循环控制;存储层负责把数据写出去。这四层分开写的好处是,哪一层出问题就单独调哪一层,不用整个脚本推倒重来。很多人写爬虫喜欢把所有逻辑塞在一个函数里,跑通了还好,一旦某个页面结构变了,排查起来非常痛苦。
提示:分层不是为了显得专业,而是为了调试方便。我试过把请求和解析写在一起,结果某次目标站点改版,我花了两个小时才定位到是解析逻辑的问题,而不是请求失败。
关于技术选型,请求库用 requests 基本是默认选择,它的 API 直观,异常处理也清晰。解析库这里我选 BeautifulSoup 配合 lxml 解析器,原因是论坛页面的 HTML 结构通常比较规整,用 CSS 选择器或 find 方法就能定位,不需要上正则那么底层。如果你追求极致速度可以换 pyquery 或者直接上 lxml 的 XPath,但对这个实例来说 BeautifulSoup 的可读性更好,维护成本低。
存储用 CSV,因为字段少、结构固定,用 pandas 的 to_csv 一行就能搞定。如果字段多或者需要后续查询,换成 SQLite 会更合适,这个后面会提。
2. 请求层的核心细节与实操要点
请求层看着简单,就是发个 GET 请求,但里面有几个参数直接决定了你能不能拿到数据。
2.1 请求头构造的关键字段
最基础的是 User-Agent。很多站点会检查这个字段,如果发现是 Python 默认的 requests UA,直接返回 403。我一般会准备几个常见的浏览器 UA 轮换使用,但注意不要用太老的版本号,否则反而显得可疑。
HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", }Accept-Language 这个字段容易被忽略,但有些站点会根据它返回不同语言版本的页面,如果你不设置,可能拿到的是英文版,解析规则就对不上了。Accept 字段同理,声明你接受 HTML 类型,让服务器走正常的页面渲染路径。
2.2 超时与重试机制
requests 默认是没有超时的,这意味着如果目标服务器不响应,你的脚本会一直卡在那里。必须显式设置 timeout,我一般设 10 秒,超过就认为这次请求失败。
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504]) session.mount("http://", HTTPAdapter(max_retries=retry)) session.mount("https://", HTTPAdapter(max_retries=retry))这里用 Session 而不是每次 requests.get,好处是复用 TCP 连接,多次请求同一个站点时速度明显更快。Retry 策略里 backoff_factor 设为 1,意思是第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒,这种指数退避能避免短时间内频繁冲击服务器。
注意:重试次数不要设太多,3 次足够。设成 10 次的话,遇到持续故障的页面会拖慢整个采集进度,而且对目标站点也不友好。
2.3 编码问题的处理
中文论坛最常见的坑就是编码。requests 会根据响应头里的 Content-Type 自动猜测编码,但猜错的概率不低,尤其是老站点。稳妥的做法是拿到响应后手动指定编码。
resp = session.get(url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding html = resp.textapparent_encoding 会用 chardet 库去探测实际编码,比默认猜测准得多。但要注意,chardet 探测本身有开销,如果确定目标站点是 UTF-8,直接写死resp.encoding = "utf-8"更快。我一般先用 apparent_encoding 跑一遍,确认实际编码后再写死。
2.4 请求频率控制
这是很多人容易忽视的一点。连续快速请求同一个站点,轻则被限速,重则 IP 被封。我的做法是在每次请求之间加一个随机延时。
import time import random time.sleep(random.uniform(1, 3))随机延时比固定延时好,因为固定间隔的请求模式很容易被识别出来。1 到 3 秒这个区间是我实测下来比较稳妥的,既能保证采集速度,又不会触发大部分站点的频率限制。
3. 解析层的定位策略与数据提取
拿到 HTML 之后,下一步是从里面把需要的数据抠出来。这一步的核心是找到稳定的定位方式。
3.1 页面结构分析的方法
动手写解析代码之前,一定要先在浏览器里把页面结构看清楚。打开开发者工具,找到帖子列表所在的容器,然后逐层往下看每个字段对应的标签和 class。
以模拟的论坛列表页为例,典型的帖子结构是这样的:
<div class="thread-item"> <a class="thread-title" href="/thread/12345">帖子标题</a> <span class="author">某用户</span> <span class="reply-count">42</span> <span class="last-reply">2024-01-15 10:30</span> </div>分析结构时要注意两点:一是 class 名是否稳定,有些站点会用带随机数的 class,那种就不能依赖;二是字段是否可能缺失,比如某些帖子没有回复,reply-count 可能整个标签都不存在。
3.2 BeautifulSoup 解析实操
from bs4 import BeautifulSoup soup = BeautifulSoup(html, "lxml") items = soup.select("div.thread-item") for item in items: title_tag = item.select_one("a.thread-title") author_tag = item.select_one("span.author") reply_tag = item.select_one("span.reply-count") time_tag = item.select_one("span.last-reply") record = { "title": title_tag.get_text(strip=True) if title_tag else "", "url": title_tag["href"] if title_tag else "", "author": author_tag.get_text(strip=True) if author_tag else "", "reply_count": reply_tag.get_text(strip=True) if reply_tag else "0", "last_reply": time_tag.get_text(strip=True) if time_tag else "", }这里每个字段都做了空值判断,这是必须的。我踩过的坑就是某次采集时没做判断,结果遇到一个没有回复数的帖子,脚本直接抛 AttributeError 崩了,前面采的数据全白费。
3.3 数据清洗的细节
拿到的原始文本往往带着多余的空格、换行符,甚至全角字符。get_text(strip=True) 能去掉首尾空白,但中间的多余空格还得自己处理。
import re def clean_text(text): text = re.sub(r"\s+", " ", text) text = text.replace("\u3000", " ") return text.strip()\u3000是全角空格,中文站点里很常见,用普通 strip 去不掉,必须显式替换。回复数这种字段如果要做数值分析,还得转成整数,但要注意有些站点会显示成"1.2万"这种格式,需要单独处理。
提示:解析出来的 URL 如果是相对路径,记得用 urljoin 拼成绝对路径,否则后续要用的时候还得手动补域名。
4. 翻页调度与循环控制
翻页是整个项目里逻辑最容易出错的部分。论坛类站点的翻页方式主要有三种,每种的处理策略不一样。
4.1 三种翻页方式的识别与处理
第一种是 URL 参数翻页,最常见,形如?page=2。这种最好处理,直接构造 URL 循环就行。
第二种是路径翻页,形如/list/2.html。处理方式类似,只是拼接的位置不同。
第三种是动态加载,页面本身不翻页,靠 JavaScript 请求接口拿下一页数据。这种就得去分析网络请求,找到实际的接口地址。
BASE_URL = "https://example-forum.test/list?page={}" def build_url(page): return BASE_URL.format(page)4.2 循环终止条件的判断
翻页循环什么时候停?有三种判断方式,我一般组合使用。
一是达到预设的最大页数,这是硬性上限,防止无限循环。二是某一页解析出来没有任何帖子,说明已经到底了。三是页面里出现了"下一页"按钮但被禁用,这也是到底的标志。
MAX_PAGES = 20 for page in range(1, MAX_PAGES + 1): url = build_url(page) html = fetch(url) records = parse(html) if not records: print(f"第 {page} 页无数据,停止采集") break all_records.extend(records) time.sleep(random.uniform(1, 3))4.3 断点续采的实现思路
如果采集页数很多,中途可能因为网络问题中断。每次都从头开始太浪费,我一般会记录已完成的页码,下次从断点继续。
import os import json PROGRESS_FILE = "progress.json" def load_progress(): if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, "r") as f: return json.load(f) return {"last_page": 0} def save_progress(page): with open(PROGRESS_FILE, "w") as f: json.dump({"last_page": page}, f)这个机制在采集几百页的时候特别有用。我试过一次采 300 多页,跑到 200 多页时网络断了,如果没有断点续采,前面 200 页的请求就全白做了。
5. 数据存储与后续处理
数据拿到手之后,怎么存、存成什么格式,直接影响到后续能不能方便地使用。
5.1 CSV 存储的实操
字段少、结构固定的场景,CSV 是最省事的选择。用 pandas 一行就能写出去。
import pandas as pd df = pd.DataFrame(all_records) df.to_csv("forum_data.csv", index=False, encoding="utf-8-sig")注意这里的 encoding 用的是utf-8-sig而不是utf-8。区别在于前者会写入 BOM 头,用 Excel 打开时中文不会乱码。这个细节我踩过坑,用 utf-8 存出来的文件在 Excel 里全是乱码,换成 utf-8-sig 就正常了。
5.2 数据去重与清洗
采集过程中可能因为重试机制拿到重复数据,落盘前最好去一次重。按 URL 去重是最稳妥的,因为 URL 是帖子的唯一标识。
df = df.drop_duplicates(subset=["url"]) df["reply_count"] = pd.to_numeric(df["reply_count"], errors="coerce").fillna(0).astype(int)reply_count 转数值时用 errors="coerce",遇到无法转换的值会变成 NaN,再用 fillna(0) 补上,这样不会因为个别脏数据导致整个转换失败。
5.3 存储方案的选型对比
| 存储方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| CSV | 字段少、一次性分析 | 简单、通用 | 不支持复杂查询 |
| SQLite | 需要反复查询、数据量大 | 支持 SQL、单文件 | 需要建表 |
| JSON | 嵌套结构、字段不固定 | 灵活 | 不便分析 |
如果只是采一次看看数据,CSV 足够。如果打算长期采集、反复分析,建议直接上 SQLite,后面查询会方便很多。
6. 常见问题与排查技巧实录
这部分是我在实际操作中积累的问题清单,基本都是踩过坑之后才总结出来的。
6.1 请求返回 403 或 418
最常见的原因是 UA 被识别。先检查请求头是否完整,尤其是 User-Agent 和 Accept-Language。如果补全后还是 403,可能是 IP 被限,这时候需要降低请求频率,或者换一个网络环境测试。
还有一种情况是站点用了 Cookie 校验,首次访问需要先拿一个 Cookie。这种可以用 Session 自动处理,因为 Session 会保持 Cookie。
6.2 解析结果为空
页面能拿到但解析不出数据,八成是定位选择器写错了。排查方法是把拿到的 HTML 存到本地文件,用浏览器打开,对照着看实际结构。
with open("debug.html", "w", encoding="utf-8") as f: f.write(html)存下来之后用浏览器打开,按 F12 检查元素,看看你写的选择器和实际结构是否匹配。我遇到过好几次是因为页面里有多层嵌套的同名 class,选择器匹配到了外层容器而不是目标元素。
6.3 中文乱码
前面提过,核心是设置正确的编码。如果 apparent_encoding 也判断不准,可以手动试几个常见编码:utf-8、gbk、gb2312。判断方法很简单,解码后看中文是否正常显示。
6.4 采集速度突然变慢
可能是触发了站点的限速机制。这时候不要硬扛,把延时调大,或者暂停一段时间再继续。硬扛的结果往往是 IP 被封,反而更耽误事。
6.5 问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 403 Forbidden | UA 或请求头不完整 | 补全请求头 |
| 解析为空 | 选择器错误 | 存 HTML 本地对照 |
| 中文乱码 | 编码设置错误 | 手动指定编码 |
| 速度骤降 | 触发限速 | 增大延时 |
| 数据重复 | 重试导致 | 按 URL 去重 |
| 脚本卡死 | 无超时设置 | 加 timeout 参数 |
注意:排查问题时,先把单页的请求和解析跑通,再扩展到多页循环。很多人一上来就写完整循环,结果出错时不知道是请求的问题还是解析的问题,还是翻页逻辑的问题。
7. 合规采集的边界与实操建议
做数据采集,技术之外还有一层必须考虑的东西,就是采集行为的边界。这部分不是套话,是我在实际操作中逐渐形成的习惯。
7.1 控制采集规模与频率
采集前先想清楚需要多少数据。如果只是做分析,几百条样本往往就够了,没必要把整个站点的数据都拉下来。请求频率控制在合理范围内,既是对目标站点的尊重,也能降低自己被封的风险。
7.2 遵守站点的公开规则
很多站点在根目录下有 robots.txt 文件,里面写明了哪些路径允许采集、哪些不允许。动手前花一分钟看一下,能避免很多不必要的麻烦。
from urllib.robotparser import RobotFileParser rp = RobotFileParser() rp.set_url("https://example-forum.test/robots.txt") rp.read() can_fetch = rp.can_fetch("*", "https://example-forum.test/list?page=1")7.3 数据使用的自我约束
采集到的数据怎么用,这个边界要自己把握好。用于个人学习分析没问题,但如果涉及公开发布或商业用途,需要格外谨慎。我个人的原则是,采集的数据只用于技术验证和趋势分析,不涉及任何个人信息的二次传播。
7.4 异常情况的处理预案
采集过程中如果发现目标站点返回大量错误,或者响应时间明显异常,应该立即停止,而不是加大重试力度。这可能是站点在做维护,也可能是你的行为已经引起了注意。停下来观察一段时间,比硬冲要明智。
8. 项目扩展与进阶方向
这个实例跑通之后,可以往几个方向继续深入。
一是异步化改造。用 aiohttp 配合 asyncio,把串行的请求改成并发,速度能提升好几倍。但要注意并发数不能太高,一般控制在 5 到 10 之间,太高了反而容易被限。
二是增量采集。记录已采集的帖子 URL,下次只采新增的。这个在长期跟踪某个版块时特别有用,能大幅减少重复请求。
三是数据可视化。把采集到的回复数、发帖时间做统计,用 matplotlib 或 pyecharts 画出来,能直观看到版块的活跃规律。我之前做过一个发帖时间分布图,发现某类版块的发帖高峰集中在晚上,这个结论对内容运营有参考价值。
四是异常监控。给脚本加上日志和告警,采集失败时能及时知道。用 logging 模块记录每次请求的状态,出问题时翻日志就能定位。
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", filename="crawler.log", )日志文件比 print 输出的好处是可以长期保留,而且能按级别过滤。排查问题时,先看 ERROR 级别的记录,再看 WARNING,效率比翻控制台输出高得多。
这套流程我反复用过很多次,每次遇到新站点,无非是解析规则和翻页方式需要调整,整体框架基本不用动。把请求、解析、调度、存储这四层分清楚,再复杂的采集需求也能拆解成可管理的模块。真正花时间的往往不是写代码,而是分析页面结构和处理各种边界情况,这部分没有捷径,只能靠多动手积累经验。