贴吧大概是国内最古老也最长寿的论坛形态之一,从“帝吧出征”到各种兴趣吧沉淀下来的长尾内容,至今还在源源不断地产生。做过几年数据采集的人都知道,贴吧是一个很典型的半静态站点——列表页是服务端渲染,详情页又有异步加载,反爬不算凶但小坑不断。我之前给一个舆情项目做数据源时,花了两个晚上把百度贴吧爬虫完整趟了一遍,从requests直接怼到scrapy重新封装都试过。这篇文章把整个实现思路、踩坑记录和可以直接抄作业的代码都梳理出来,给准备做社区类数据采集的同学一个参考。
1. 项目整体思路与目标拆解
1.1 为什么选贴吧作为爬虫练习场景
很多新手第一次写爬虫不是爬豆瓣就是爬贴吧,这个选择其实有道理。贴吧的HTML结构相对规整,不像现在很多网站恨不得把整个页面拆成十几个接口,也不像短视频平台那样需要逆向加密参数。贴吧的核心数据——帖子标题、回复数、最后回复时间、楼主ID、楼层内容——基本都是直接嵌在HTML或者简单的JSON接口里,用requests加BeautifulSoup就能搞定。我从实际的爬虫项目角度来说,它也是一个很好的练手样本:既有列表页的翻页逻辑,又有详情页的异步楼层加载,还涉及图片抓取、编码处理、请求频率控制这几个经典问题。
做爬虫前一定要先想清楚目标。贴吧相关的内容采集,通常有三个层次:
- 列表页采集:抓取某个吧的主题帖列表,拿到帖子标题、链接、回复数、作者等索引数据。这类数据适合做趋势分析、热门话题监控。
- 详情页采集:进入具体帖子,抓取每一楼的正文内容和发帖人。这是做文本分析、舆情分析的主要数据来源。
- 多媒体资源采集:把帖子里附带的图片、视频等其他资源下载到本地。适合做素材库或者数据备份。
我这次的目标是前两个层次,顺手把图片下载一起做了。如果你的目标是定向监控某个吧的新帖,那还需要加定时调度和增量抓取,这个后面会专门讲。
1.2 技术选型:requests方案还是scrapy框架
技术选型是很多人纠结的第一关。我在这个项目里用了requests加BeautifulSoup的组合,配合fake_useragent和sqlite3做持久化。坦白说,如果你只是跑一个吧的数据,或者想快速验证思路,requests方案绰绰有余。它的好处是逻辑直观,每一行代码都在掌控之中,出问题了好排查。我在实际工作中跑过一批覆盖三十多个吧的采集任务,单机四五个线程,一天下来数据量大概在几十万条,requests方案完全没有瓶颈。
但如果你要抓的是几十上百个吧、需要反复增量更新、还要跑分布式,那我建议直接用scrapy。scrapy的并发模型比手写threading优雅得多,内置了请求去重、失败重试、pipelines存储等一系列机制,后期维护成本低很多。我自己的经验是:先用手写requests把全流程跑通一次,把URL规律、页面结构、反爬策略摸清楚,再决定要不要迁移到scrapy。直接上手scrapy反而容易被框架的复杂度劝退,因为你得同时理解框架本身的机制和目标站点的逻辑。
这个项目的核心链路是:构造请求头 → 请求列表页 → 解析帖子索引 → 请求详情页 → 解析楼层内容 → 提取数据 → 入库。每个环节都有几个容易踩的坑,下面详细拆开讲。
2. 环境准备与核心模块设计
2.1 环境依赖与工程目录结构
整个项目只需要Python 3.8以上环境,依赖库也非常少。建议用虚拟环境管理,避免把系统Python搞乱:
pip install requests beautifulsoup4 lxml fake-useragent这四个库的分工很清晰:requests负责发HTTP请求,beautifulsoup4负责解析HTML,lxml是beautifulsoup的解析引擎,比默认的html.parser快不少,fake-useragent用来随机生成User-Agent。如果你要处理异步加载的内容,还需要额外装一个requests-html或者直接上playwright,这个后面会说。
工程目录我习惯这样组织:
tieba_spider/ ├── config.py # 配置项:目标吧名、请求间隔、存储路径 ├── crawler.py # 核心爬虫逻辑:列表页和详情页抓取 ├── parser.py # 页面解析:解析函数独立封装 ├── storage.py # 数据存储:sqlite写入 ├── main.py # 入口:参数解析和任务调度 └── data/ # 数据输出目录模块划分没必要太细,但解析和抓取一定要分开。原因很简单:网页改版太频繁了,贴吧平均每隔一段时间就会微调一下HTML结构,到时候你只需要改parser.py,而不用动抓取逻辑。我见过不少项目把解析代码和请求代码揉在一起,改一次结构就要从头捋一遍,非常痛苦。
2.2 请求头伪装与常见反爬机制
贴吧的反爬不算强,但不代表没有。实际测试下来,主要就三样:请求频率限制、User-Agent检测、以及部分敏感内容的Cookie校验。
先说User-Agent。fake-useragent库可以随机生成UA,但用的时候有个小坑——它默认会从远程服务器拉取UA列表,如果网络不稳定或者被墙了就会报错。解决办法是把它改成从本地文件读取,或者直接手动维护一个UA列表。我在项目里用手动维护的方式,稳定且不依赖外部服务:
USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", ]除了UA,还有两个请求头值得带上:Accept-Language和Referer。贴吧对Referer不是特别严格,但带上总没错。完整的headers构造:
headers = { "User-Agent": random.choice(USER_AGENTS), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", "Referer": "https://tieba.baidu.com/" }再说Cookie。正常情况下不用带Cookie也能访问贴吧的列表页和详情页,但如果某些帖子涉及敏感话题或者需要登录才能查看的内容,服务端会校验Cookie。我处理的方式是:先用浏览器登录一次贴吧,从开发者工具里复制Cookie到配置文件中,请求时自动带上。注意Cookie会过期,跑长任务时最好设计一个过期自动退出的机制,方便及时发现。
请求频率方面,贴吧的限流策略比较隐晦,不会直接封IP,更多是返回验证码或者空数据。实测下来,单IP每秒不要超过2个请求,连续跑30分钟最好休息几分钟。我通常的做法是每个请求间隔随机0.3到1秒,这样既不会明显拖慢速度,也不会触发限流。
2.3 Cookie与登录态处理
贴吧详情页的楼层加载,如果帖子楼层数量超过一定阈值(我记得是几百楼),后面的楼层不是直接渲染在HTML里,而是通过一个单独的JSON接口异步加载。这个接口需要带一个tbs参数,这个参数是从cookie或者页面源码里取的。第一次跑的时候我没注意,结果发现只能爬到前几百楼,后面的楼层死活出不来。
tbs参数的获取其实很简单,随便打开一个贴吧详情页,在HTML源码里搜一下tbs就能找到。它是一个很短的时间戳字符串。可以用正则直接提取,或者用BeautifulSoup从script标签里解析。提取到之后存到全局变量里,后续的异步楼层请求都带上这个参数。
还有一种更省事的方案,就是直接用tieba.baidu.com/p/帖子ID?pn=页码这个带分页参数的形式。贴吧的楼层分页有几种URL风格,老版本是?pn=2这种,新版本还支持?pn=2&see_lz=1只看楼主。只要理解了URL参数的含义,翻页逻辑就很简单了。
3. 数据抓取完整实操:从列表页到详情页
3.1 列表页解析:翻页规律与字段提取
列表页的URL格式非常固定:https://tieba.baidu.com/f?kw=吧名&ie=utf-8&pn=页码。注意ie=utf-8是编码参数,带上可以用UTF-8编码解析,否则默认可能是GBK,解析中文会出问题。pn的值是翻页的关键——第1页是0,第2页是50,第3页是100,也就是每页50个帖子,页码等于(页数-1)*50。
用requests拿到页面HTML后,先用BeautifulSoup定位到div.threadlist_title或者a.j_th_tit这类元素。不同时期的贴吧模板类名会变,我跑的时候用的是a.j_th_tit提取标题和链接,这个类名在很长一段时间内都比较稳定。每个帖子的数据结构大概是:
- 标题:
a.j_th_tit的文本 - 链接:
a.j_th_tit的href属性,后面拼接帖子ID - 作者:
span.frs-author-name的文本 - 回复数:
span.threadlist_rep_num或类似元素的文本 - 最后回复时间:
span.threadlist_reply_date或span.threadlist_time的文本
写列表页解析函数时,要注意先确认页面是否为空。贴吧有一些吧是完全没人发帖的,列表为空;还有一些吧可能因为内容原因被隐藏了,这时候解析结果为空数组,需要判断一下,避免空跑浪费流量和请求次数。
def parse_list_page(html): soup = BeautifulSoup(html, "lxml") threads = [] for item in soup.select("li.j_thread_list"): link = item.select_one("a.j_th_tit") if not link: continue title = link.get_text(strip=True) href = link.get("href", "") author_el = item.select_one("span.frs-author-name") reply_el = item.select_one("span.threadlist_rep_num") threads.append({ "title": title, "url": f"https://tieba.baidu.com{href}" if href.startswith("/p/") else f"https://tieba.baidu.com/p/{href.split('?')[0].split('/')[-1]}", "author": author_el.get_text(strip=True) if author_el else "", "reply_num": int(reply_el.get_text(strip=True)) if reply_el else 0, }) return threads3.2 详情页解析:楼层结构与楼层分页
进入帖子详情页,URL格式是https://tieba.baidu.com/p/帖子ID。页面主体由一个个楼层组成,每个楼层对应div.l_post容器。楼层里最核心的字段是:楼层数(div.post-tail-wrap里的内容,或者楼层回复按钮的>def parse_detail_page(html, post_id): soup = BeautifulSoup(html, "lxml") floors = [] for post in soup.select("div.l_post"): content_el = post.select_one("div.d_post_content") author_el = post.select_one("a.p_author_name") time_el = post.select_one("span.post-tail") if not content_el: continue floors.append({ "post_id": post_id, "floor": post.get("data-floor", ""), "author": author_el.get_text(strip=True) if author_el else "", "time": time_el.get_text(strip=True) if time_el else "", "content": content_el.get_text(separator="\n", strip=True), }) return floors
3.3 图片与附件资源下载方案
帖子正文里的图片,很多都是放在百度自己的CDN上,URL格式一般是https://imgsa.baidu.com/...或者https://tiebapic.baidu.com/...。图片下载本身不难,用requests的stream=True模式流式写入本地文件。需要注意的坑有两个:
一是防盗链。有些图片的CDN会检查Referer,如果Referer不是贴吧域名,可能返回403。解决办法就是请求图片时把Referer设置成具体的帖子详情页URL。
二是图片重复。同一个图片可能在不同楼层被引用多次,下载前先检查本地文件是否存在,用图片URL的MD5作为文件名,天然去重。我在跑项目的时候发现有些热门帖子里图片量很大,几千张图如果不去重,很快就撑爆硬盘。
简单封装的图片下载函数:
def download_image(img_url, save_path, referer): headers = { "User-Agent": random.choice(USER_AGENTS), "Referer": referer, } resp = requests.get(img_url, headers=headers, stream=True, timeout=10) if resp.status_code == 200: with open(save_path, "wb") as f: for chunk in resp.iter_content(chunk_size=1024): f.write(chunk)3.4 数据存储:SQLite轻量方案与字段设计
存储层我用的是sqlite3标准库,零依赖、单文件、够用。表结构设计上,帖子、楼层、作者这几个实体分开存,方便后续做关联查询。核心表大概长这样:
CREATE TABLE threads ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT UNIQUE, title TEXT, author TEXT, reply_num INTEGER, create_time TEXT, last_reply_time TEXT, url TEXT, crawled_at TEXT ); CREATE TABLE floors ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT, floor INTEGER, author TEXT, time TEXT, content TEXT, UNIQUE(post_id, floor) ); CREATE TABLE images ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT, floor INTEGER, img_url TEXT, local_path TEXT, UNIQUE(img_url) );唯一约束是必须的。爬虫重跑是常态,没有唯一约束,重复数据会把你烦死。写入时用INSERT OR IGNORE,天然跳过已存在的数据。sqlite的并发写入不太行,但单线程、单进程、间隔写入的情况下完全没问题。如果你是多线程写库,记得把check_same_thread=False加在连接参数里,或者干脆用一个独立队列来执行写操作。
4. 并发调度与反爬策略实测
4.1 单线程、多线程与异步方案怎么选
这个项目的抓取量如果不大(几百个帖子),单线程顺序跑完全够用。优点是不会触发任何限流,代码也最好写。缺点是慢——一个帖子详情页平均要1秒,算上列表页,抓1000个帖子可能要20分钟以上。
如果目标量级是几万个帖子,单线程就不太行了。这时需要多线程。Python的多线程因为GIL的存在,对requests这种IO密集型的任务反而很友好——大部分时间都花在等待网络响应上,GIL不会成为瓶颈。我用的是concurrent.futures.ThreadPoolExecutor,简单高效:
from concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers=4) as executor: future_map = {executor.submit(crawl_post, post_id): post_id for post_id in post_ids} for future in as_completed(future_map): try: result = future.result(timeout=30) save_to_db(result) except Exception as e: logger.error(f"任务失败: {future_map[future]}, 错误: {e}")线程数不是越大越好。我实测过,贴吧场景下4到6个线程是比较平衡的,再往上走,限流概率直线上升,平均抓取速度反而下降。这里建议做一层请求频率总闸:用token bucket或者简单的time.sleep随机等待来控制整体QPS上限。
如果你的团队有分布式调度需求,scrapy加scrapy-redis是成熟方案。不过对贴吧这种量级,分布式多少有点杀鸡用牛刀。真正需要分布式的是那种全网级别的采集,单机几个吧的数据完全没必要。
4.2 User-Agent轮换与IP代理池的取舍
IP代理池可能是很多爬虫教程里最喜欢渲染的内容了,但贴吧这个场景真的不需要。如果你只是跑几个吧、几千个帖子,单个IP加正常的访问频率完全够用。我之前用requests方案连续跑了三天,没有触发过一次验证码。
不过有一点要注意:代理质量不行比不代理更糟。网上免费的代理池大部分是失效代理或者高匿度不够的代理,用起来反而导致请求超时、返回异常数据。如果真的需要代理,我建议用付费的、验证过稳定性的代理商API,或者干脆用住宅代理IP。贴吧的服务端会检测数据中心IP(就是机房IP),普通机房的IP反而容易被重点关注。
还有一点经验分享:尽量不要用携程、去哪儿这些大厂的代理接口给贴吧用,那些代理池多半是爬虫用户共用,早已被各大网站标记,命中率很低。真做项目的话,直接买那种按流量计费、自动轮换的代理服务,省心。
4.3 验证码应对与频率过限的降级方案
贴吧的验证码不是经常出现,但一旦出现就是硬骨头。我遇到过一次,是连续快速抓取后返回了一个要求输入验证码的页面。后来我总结出一套降级策略:
- 检测机制:在解析函数里首先检查页面是否包含验证码特征。贴吧的验证码页面通常包含
verify关键词,或者是window.location.href跳转到verify.tieba.baidu.com。 - 熔断机制:连续检测到验证码3次以上,立刻停止当前任务,等待10到30分钟后再重试。不要硬怼,硬怼只会触发更长的封禁周期。
- 限速重试:降低线程数,把请求间隔从0.5秒拉到3秒以上,重新跑。
验证码自动识别这块,虽然有打码平台可以用,但为贴吧专门接打码平台有点大材小用。我的建议是:能通过降低频率避开的,就别花钱用打码平台。爬虫的本质是模拟人类访问,人类看一个帖子不可能每秒点十次下一页。
5. 常见问题与排查技巧实录
5.1 乱码问题:页面编码判断与处理
贴吧历史上长期使用GBK编码,后来页面主体切到了UTF-8,但某些遗留页面、某些接口、某些CDN资源仍然可能返回GBK编码。如果你直接用resp.text去拿内容,requests会先根据响应头里的charset参数猜测编码,猜错的话就会出现中文乱码。
我的处理办法是:在拿到响应后,手动检查编码。如果页面里包含charset=gbk或者charset=gb2312,就用resp.encoding = "gbk"重新解析;如果是utf-8就用resp.encoding = "utf-8"。一个更稳妥的方式是读原始的字节内容,然后交给chardet库去判断:
import chardet def decode_response(resp): raw_data = resp.content if resp.encoding: try: return raw_data.decode(resp.encoding) except UnicodeDecodeError: pass detected = chardet.detect(raw_data) return raw_data.decode(detected.get("encoding", "utf-8"), errors="replace")关键点:一定不要直接信任响应头里的charset,很多服务器返回的Content-Type头并不准确。用chardet检测虽然慢一点点,但稳。
5.2 翻页失败:URL参数与for循环的坑
翻页失败是新手遇到最多的问题。常见原因有这么几个:
一是pn参数计算错误。贴吧的页码从0开始,第1页pn=0,第2页pn=50,第3页pn=100。如果你用(page - 1) * 50,第二页就是50,没毛病。但如果你用(page - 1),那就永远停在第一页。这个错我见过不止一次。
二是列表页和详情页搞混。列表页的URL参数是f?kw=xxx&pn=yyy,详情页的URL参数是p/帖子ID?pn=页码。详情页的pn参数从1开始,每页大约20到30楼,但具体多少楼取决于页面渲染。详情页翻页时需要注意判断当前页面是否存在,如果请求的页码超过了总页数,页面会重定向回第一页。这时候你的代码如果不判断URL是否变化,就会陷入死循环。
三是处理完第一页后没有把for循环推进下去。很多人写完一个for page in range(1, max_page + 1)循环后发现只跑了第一页,调试了半天发现是max_page计算错了。贴吧的列表页总页数通常会显示在页面底部,用BeautifulSoup解析ul.pagination或者span.page_count可以拿到。但保险起见,我建议用“抓取到当前页没有内容就停止”的兜底逻辑,防止服务端返回的空页面导致死循环。
5.3 异步加载内容抓不到:楼中楼与楼主发言的补充接口
详情页的楼层主体内容在HTML里能抓到,但楼中楼(楼层内的回复)和楼主发言(只看楼主模式)是单独接口。如果你的数据需求包含楼中楼,仅靠解析HTML是拿不全的。楼中楼的接口是:
https://tieba.baidu.com/p/comment?tid=帖子ID&pid=楼层ID&pn=页码返回JSON,字段结构跟总评论接口类似,包含回复者、回复内容、回复时间。调用这个接口时需要带上Cookie,否则可能返回空数据。
另一个异步场景是分享视频。现在很多贴吧帖子会直接嵌入B站或者小程序视频,这类内容在HTML里只有一个iframe或者video标签,实际的内容源在第三方服务器。如果你想抓视频本身,就得顺着iframe的src去解析第三方页面,复杂度会高不少。我在项目里直接跳过了这部分,只抓纯图片资源。
5.4 高频请求被封后的恢复策略
即使前面做了各种限制,长期跑还是可能被封。贴吧封IP的表现不是直接拒绝访问,而是所有返回页面变成安全验证页。判断是否被封很简单:检查响应URL是否跳转到了verify.tieba.baidu.com,或者页面HTML里是否包含请输入验证码字样。
恢复策略,我按严重程度排个序:
- 轻度限制:返回页面偶尔出现验证码。解决方式是降低频率,暂停5到10分钟再继续。
- 中度限制:连续多次返回验证码,或者API接口返回空数据。解决方式是停止任务至少30分钟,同时将并发数降到2以下,请求间隔拉长到5秒以上。
- 重度限制:IP被拉黑,所有页面都跳转验证。这种只能换IP了。如果是家庭宽带,重启路由器大概率能换IP;如果跑在云服务器上,就得换一台机器或者买新的代理。
在代码层面,我封装了一个简单的状态检测函数,每次请求后都检查是否进入验证页。一旦发现异常,就把当前进度保存到本地进度文件(比如把已经完成的任务ID列表存成json),下次启动时从断点续跑。断点续跑对长任务太重要了,没有这个机制,一次封禁就可能浪费之前几小时跑到的所有进度。
6. 成果展示与后续扩展方向
6.1 数据可视化与简单分析示例
数据抓下来后,如果只是躺在sqlite里就太可惜了。我用抓到的数据做过两件有意思的事:一是统计某个吧的每日发帖量变化趋势,看热度的波动规律;二是用jieba分词对帖子正文做词频统计,看大家在讨论什么。
每日发帖量统计其实很简单,按日期字段做group by就行:
SELECT substr(create_time, 1, 10) as date, count(*) as cnt FROM floors GROUP BY date ORDER BY date;词频统计用jieba加collections.Counter,几十行代码就能跑出结果。如果你抓的是特定领域贴吧(比如某个游戏的攻略吧),词频统计可以快速帮你定位到这个阶段大家都在关注什么装备、什么副本、什么角色。
可视化的话,我用的matplotlib直接把折线图、柱状图画出来,够用了。如果你想做交互式仪表盘,可以考虑streamlit或者flask加echarts,Python后端加JavaScript前端,网上模板很多,搭起来也不费劲。
6.2 从requests迁移到scrapy的注意事项
前面提到过,如果你觉得数据量会持续增长,可以迁移到scrapy。迁移时最需要注意的点不是代码重写,而是URL构造和解析逻辑的复用。scrapy的Rules和LinkExtractor虽然方便,但贴吧这种URL规律性很强的站点,直接在parse方法里用yield scrapy.Request更直观。
scrapy里设置请求间隔非常方便,DOWNLOAD_DELAY = 1.0表示每个请求之间至少间隔1秒。配合CONCURRENT_REQUESTS_PER_DOMAIN = 4,可以精确控制对贴吧的访问频率,比手写线程池的方式优雅很多。代理中间件在scrapy里也有成熟插件(scrapy-proxy-middleware),如果你之前用requests爬的时候使用了代理,迁移成本主要在把原有的请求头构造逻辑改造成中间件形式。
6.3 合规提醒与长期运行建议
最后说一点合规的事情。爬虫本身不违法,但爬什么、怎么爬、爬了干什么,这些环节都可能触碰法律红线。贴吧的用户协议里明确写了未经许可不得抓取数据,虽然实际上百度的态度是“睁一只眼闭一只眼”,但如果你把抓取的数据用于商业变现或者大规模公开传播,风险会急剧上升。我的建议是:**
- 只抓公开数据,不碰任何需要登录才能看的私密内容。
- 控制抓取频率,不要对目标服务器造成压力。
- 数据仅用于个人学习和研究,不用于商业用途。
- 如果做舆情监控类项目,尽量使用官方提供的API或者数据服务。
长期运行还有一个容易被忽视的问题:存储空间和日志管理。sqlite单文件超过几个GB后写入性能会下降,建议定期做数据归档或者分库。日志方面,爬虫这种无人值守的任务,日志一定要带上时间戳和请求URL,否则排查问题时会怀疑人生。我习惯把日志同时输出到控制台和文件,按天滚动,保留最近三十天。这个习惯在跑长任务时帮了我大忙。
贴吧这个项目做完后,我把同一套思路迁移到了几个其他中文社区站点,跑通后发现核心逻辑大同小异:URL规律拼出来、解析模块独立、请求频率控制好、断点续跑兜底。爬虫的价值从来不在代码本身,而在你对目标站点底层规则的理解和对数据链条的整体把控。希望这篇实战记录能帮你少走几步弯路。