
1. 项目全貌与核心思路1.1 为什么要拿百度贴吧做爬虫练手项目说实话百度贴吧这个目标放在2024年来看技术难度不算顶尖但它的综合性价比非常高。我做了这么多年爬虫带过不少新人几乎每一次都会推荐贴吧作为入门到进阶的过渡项目原因有三点。第一贴吧的页面结构相对稳定不像某些电商平台动不动就改版换接口。虽然中间有过几次前端改版但帖子列表和楼层内容的基础HTML结构变动不大这意味着你写好的解析代码能存活很长时间不至于今天写完明天就报废对新手建立信心特别重要。第二贴吧的数据特征非常丰富。一个吧里面有帖子标题、作者信息、发布时间、回复数、楼层内容、图片、附件甚至还有楼中楼这种嵌套结构。通过爬取贴吧你需要同时处理列表页和详情页两种页面类型要写翻页逻辑要处理相对时间比如3天前这种字段这些几乎是所有通用爬虫都会遇到的经典问题。把这些搞明白了换个目标网站基本也能举一反三。第三贴吧的数据量足够大。一天几百万条新帖的规模做并发测试、压测自己的爬虫脚本数据源完全不愁。比如你想研究一个话题的热度变化趋势贴吧的历史帖子是很好的样本库。我之前做过一个针对某游戏吧的舆情分析爬了一整个月的帖子数据最后产出的词云和话题热度曲线效果非常直观。需要特别说明的是本文所有内容仅用于技术学习和个人研究。爬虫抓取的数据如果涉及个人隐私、版权内容或者用于商业用途一定要提前确认合规性问题。贴吧的用户协议里明确规定了数据使用边界做技术研究没问题但请勿批量下载后用于恶意营销或二次售卖。1.2 技术选型requests还是Scrapy还是Playwright聊到爬虫第一个要解决的就是工具选择。市面上主流方案有三类requests全家桶、Scrapy框架、Playwright无头浏览器。很多新手一上来就问哪个最好我直接说结论没有最好只有合不合适。requests适合中小规模爬虫学习曲线平缓调试方便随手开一个IPython交互环境就能跑。Scrapy适合大规模采集自带去重、调度、并发、中间件写出来的工程结构清晰适合长期维护的项目。Playwright适合重度依赖JavaScript渲染的网站比如内容是通过AJAX动态加载的或者页面交互复杂点击按钮、下拉加载但它占用资源大爬取速度远不如纯HTTP请求。回到百度贴吧这个项目我的选择很明确requests BeautifulSoup作为主力并发部分用concurrent.futures的线程池存储用SQLite或者CSV。为什么不选Scrapy因为贴吧的请求链路并不复杂用Scrapy有点杀鸡用牛刀而且Scrapy的调试效率对新手来说不如requests直观。为什么不选Playwright因为贴吧的帖子列表和楼层的首屏内容是直接渲染在HTML源码里的不需要浏览器渲染用HTTP请求就能拿到完整数据。如果哪一天贴吧改成纯前端渲染Playwright才会成为必要选项。我在实际测试中发现用requests请求百度贴吧的帖子详情页响应体大概在200KB到500KB之间单线程大概每秒能请求1到2个页面加上解析和存储一分钟能处理60到120个页面。而贴吧的帖子列表页包含大约20到50个帖子链接也就是说一分钟能采集几千条帖子索引这个速度对个人研究完全够用了。1.3 这个项目能学到什么核心技能做过这个项目之后你会掌握几项非常核心的能力这些能力是通用的换个网站照样用得上。第一是页面结构分析与定位能力。拿到一个陌生的HTML页面快速定位到目标数据所在的标签层级能写对CSS选择器或者XPath这是爬虫的基本功。贴吧的HTML结构有一定嵌套深度练好了这个再去爬其他网站会非常轻松。第二是请求上下文的理解。HTTP请求头里哪些字段是关键Referer和User-Agent的作用是什么Cookie失效了怎么处理这些问题在贴吧爬虫中都会遇到。第三是并发控制。单线程爬虫太慢但无限并发又会被封IP这里需要你理解线程池的核心参数配比。我会在后面详细展开讲并发参数怎么调。第四是异常处理的工程思维。网络请求没有100%的成功率爬虫代码里必须有重试机制、超时机制、异常捕获、断点续爬这些是保证爬虫稳定运行的基石。2. 环境准备与依赖安装2.1 基础环境与Python版本我用的是Python 3.10.11实际上Python 3.8以上版本都完全可以跑通后续代码。Windows、macOS、Linux都没问题但有一点要提醒如果你用的是Windows系统后续SQLite存储部分最好用Python内置的sqlite3模块不要额外装什么第三方数据库毕竟贴吧数据量级完全用不到MySQL或者PostgreSQL新手没必要把这个项目复杂化。Python装好之后建议顺手建一个虚拟环境隔离项目依赖。这一步虽然有些老生常谈但我见过太多人把全局环境搞乱装了一堆互相冲突的包最后连import都报错。命令很简单python -m venv tieba_env source tieba_env/bin/activate # Windows下执行 tieba_env\Scripts\activate2.2 核心依赖库与安装命令整个项目只用四个库requests发请求BeautifulSoup4解析HTMLpandas做数据处理其实用csv模块也够但pandas在处理字段时更方便lxml是BeautifulSoup的解析引擎。pip install requests beautifulsoup4 pandas lxml这里我特别说一下为什么用BeautifulSoup而不是正则表达式。贴吧的HTML结构是有规律但直接用正则去匹配内容会非常痛苦因为你需要考虑标签嵌套、属性顺序、CDATA内容等边界情况。BeautifulSoup会把这堆HTML解析成一棵标准的DOM树然后用选择器定位元素代码可读性和维护性都会好很多。如果你有精力强烈建议同时安装一个fake_useragent库它能随机生成User-Agent避免每次请求都用同一个UA配合后面要讲的反爬规避策略效果明显。pip install fake_useragent3. 核心代码实现与细节拆解3.1 请求头的伪装最容易被忽略的细节爬虫第一步是模拟浏览器发送请求而请求头Headers是服务器识别客户端身份的第一道关卡。贴吧服务器对最基本的User-Agent是有校验的你用一个默认的Python-requests UA去请求大概率会被拒绝访问返回403。我第一次写爬虫时就踩过这个坑看到返回的状态码是403第一反应是代码写错了排查了半天才发现是这个原因。处理办法很简单构造请求头时加上UA和Refererimport requests from fake_useragent import UserAgent ua UserAgent() headers { User-Agent: ua.random, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.8,en-US;q0.5,en;q0.3, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, Referer: https://tieba.baidu.com/ }这里有几个容易踩坑的点Accept-Encoding不要手贱去掉。如果你把这个字段删了服务器返回的可能是未压缩的响应体数据体积会大好几倍解析速度和网络消耗都会变差。requests库会自动解码gzip、deflate、br格式不需要你手动处理。Referer字段要随着请求页面动态变化。如果你在爬帖子详情页Referer应指向帖子所在的列表页模拟用户从列表页点击进详情页的跳转行为。fake_useragent建议先用ua.random测试一下能不能正常生成有时候这个库内置的真实UA数据库会被更新掉如果报错直接写死几个主流浏览器的UA字符串就行。3.2 列表页解析拿到所有帖子链接贴吧列表页的URL格式是https://tieba.baidu.com/f?kw吧名ieutf-8pn页码其中pn参数是翻页的关键第一页是0第二页是50第三页是100以此类推每页50个帖子。请求列表页拿到响应之后先确认页面结构。贴吧列表页中每个帖子是li classj_thread_list clearfix这个li标签标题在a.j_th_tit里回复数和点赞数在span.threadlist_rep_num center_text里。def parse_list_page(html): soup BeautifulSoup(html, lxml) items [] for li in soup.select(li.j_thread_list): title_tag li.select_one(a.j_th_tit) if not title_tag or not title_tag.get(href): continue title title_tag.get_text(stripTrue) href title_tag[href] if not href.startswith(http): href https://tieba.baidu.com href # 回复数 reply_tag li.select_one(span.threadlist_rep_num.center_text) reply_count reply_tag.get_text(stripTrue) if reply_tag else 0 # 发布时间贴吧列表页有相对时间如 2024-01-15 或 3天前 date_tag li.select_one(span.threadlist_reply_date.pull_right) pub_date date_tag.get_text(stripTrue) if date_tag else items.append({ title: title, url: href, reply_count: reply_count, pub_date: pub_date }) return items这里要特别提醒不要用正则去匹配标题。贴吧标题里可能有转义符、可能有嵌套标签用BeautifulSoup的get_text方法能一次性把标签内的所有文本取出来而且自动处理空白字符省心很多。翻页逻辑也简单就是循环拼接URLdef fetch_list(keyword, pages, session): all_items [] base_url https://tieba.baidu.com/f for page in range(pages): params { kw: keyword, ie: utf-8, pn: page * 50 } resp session.get(base_url, paramsparams, headersheaders, timeout10) if resp.status_code ! 200: continue items parse_list_page(resp.text) all_items.extend(items) time.sleep(0.5) # 频率控制防止请求太快被封 return all_items3.3 详情页解析楼层、作者、发布时间帖子详情页的URL格式是https://tieba.baidu.com/p/帖子ID帖子的全部内容都在这个页面上。楼层是div classl_post j_l_post l_post_bright块如果你用的选择器没生效可以换成div classd_post_content_main。楼层数放在span classtail-info标签里发帖用户昵称在a.p_author_name里内容在div.d_post_content里。def parse_detail_page(html, post_id): soup BeautifulSoup(html, lxml) result [] for item in soup.select(div.l_post): author_tag item.select_one(a.p_author_name) author author_tag.get_text(stripTrue) if author_tag else content_tag item.select_one(div.d_post_content) content content_tag.get_text(\n, stripTrue) if content_tag else floor_tag item.select_one(span.tail-info) floor floor_tag.get_text(stripTrue) if floor_tag else result.append({ post_id: post_id, floor: floor, author: author, content: content }) return result一个很容易踩的坑是贴吧的内容是分页加载的一页只显示30层楼如果你只请求一次详情页拿到的只是前30条回复。要拿完整楼层得解析底部翻页信息。详情页底部有一个li classl_reply_num里面存了总楼层数比如span classred1/span回复贴共span classred23/span页需要拿到总页数然后继续拼URL翻页。URL格式是在帖子ID后面加?pn页码def fetch_all_floors(session, post_id, max_pages): all_floors [] base_url fhttps://tieba.baidu.com/p/{post_id} for pn in range(1, max_pages 1): if pn 1: url base_url else: url base_url f?pn{pn} resp session.get(url, headersheaders, timeout10) if resp.status_code ! 200: continue floors parse_detail_page(resp.text, post_id) all_floors.extend(floors) time.sleep(0.3) return all_floors解析拿到总页数的小技巧直接搜索页面里的正则/p/\d\?pn\d把出现过的页码全部找出来取最大值即可。这个方法比找翻页标签更稳。3.4 相对时间为什么一定要转换这个点特别值得单独拿出来说。贴吧列表页和详情页里的时间字段存在三种格式完整时间2024-01-15 14:30日期格式2024-01-15相对时间3天前、昨天、刚刚相对时间是爬虫里一个非常经典的数据清洗问题因为你在跑数据分析的时候总不能让一个字符串3天前参与时间排序吧。转换逻辑参考from datetime import datetime, timedelta def parse_relative_time(value): value value.replace( , ) if 刚刚 in value: return datetime.now() if 分钟 in value: minutes int(re.search(r\d, value).group()) return datetime.now() - timedelta(minutesminutes) if 小时 in value: hours int(re.search(r\d, value).group()) return datetime.now() - timedelta(hourshours) if 昨天 in value: return datetime.now() - timedelta(days1) if 天前 in value: days int(re.search(r\d, value).group()) return datetime.now() - timedelta(daysdays) try: return datetime.strptime(value, %Y-%m-%d) except: try: return datetime.strptime(value, %Y-%m-%d %H:%M) except: return None这个函数我在多个爬虫项目里复用不只在贴吧场景。写爬虫不只是把数据抓下来数据清洗和后处理同样重要尤其是在做时间线分析、热度趋势这类研究的时候。3.5 数据存储SQLite pandas双保险数据存储方案我推荐两条路并行原始数据存SQLite便于后续增量查询和去重分析数据用pandas处理之后导出CSV或Excel方便可视化。SQLite建表语句很简单import sqlite3 conn sqlite3.connect(tieba_data.db) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT, title TEXT, author TEXT, reply_count INTEGER, pub_date TEXT, url TEXT, UNIQUE(post_id) ) ) cursor.execute( CREATE TABLE IF NOT EXISTS floors ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT, floor INTEGER, author TEXT, content TEXT, FOREIGN KEY (post_id) REFERENCES posts(post_id) ) ) conn.commit()注意我在posts表里加了UNIQUE(post_id)这是用来做断点续爬的。如果爬虫中途崩了重启的时候用INSERT OR IGNORE就能跳过已经入库的帖子不用从头再来。def save_post(cursor, post): cursor.execute( INSERT OR IGNORE INTO posts (post_id, title, author, reply_count, pub_date, url) VALUES (?, ?, ?, ?, ?, ?) , (post[post_id], post[title], post[author], post[reply_count], post[pub_date], post[url]))pandas处理导出的部分import pandas as pd def export_to_csv(cursor, filenametieba_data.csv): df pd.read_sql_query(SELECT * FROM posts, conn) df.to_csv(filename, indexFalse, encodingutf-8-sig)用utf-8-sig编码导出Excel兼容性最好直接用utf-8可能在Excel里打开乱码这个问题我碰到不下三次后来干脆默认用utf-8-sig。4. 并发设计从单线程到协程4.1 为什么单线程爬虫不够用如果你只是爬一个吧几十个帖子单线程完全没问题。但如果你要爬的是热门吧比如某个吧每小时新增几千个帖子用单线程一页一页慢慢爬可能要跑好几天才能抓完。这时候就必须引入并发。Python爬虫的并发方案主要有三种多线程ThreadPoolExecutor适合IO密集型任务网络请求大部分时间在等待响应线程不占CPU是爬虫最常用的并发方式。多进程ProcessPoolExecutor适合CPU密集型任务爬虫场景用得少。异步IOasyncio aiohttp事件循环驱动线程数可以降到个位数但代码复杂度高如果你对异步不熟悉不建议拿这个项目练手。我推荐先用多线程理解起来最直观真不够用了再考虑异步。4.2 ThreadPoolExecutor的正确打开方式贴吧场景的并发颗粒度有两种选择按帖子并发和按网页并发。按帖子并发就是主线程先拿到列表页的所有帖子链接然后把这些链接丢给线程池去爬。按网页并发是列表页和详情页都并发请求。第一种方式更合理。因为列表页总共就几十页并发请求页面比较简单但详情页的数量是列表页的几十倍是耗时大头。from concurrent.futures import ThreadPoolExecutor, as_completed def crawl_post(session, post): post_id extract_post_id(post[url]) max_pages fetch_total_pages(session, post_id) all_floors fetch_all_floors(session, post_id, max_pages) floors_data [{post_id: post_id, **floor} for floor in all_floors] return floors_data def main(): post_list fetch_list(python, pages10, sessionsession) all_results [] with ThreadPoolExecutor(max_workers8) as executor: future_map {executor.submit(crawl_post, session, post): post for post in post_list} for future in as_completed(future_map): post future_map[future] try: floors future.result() all_results.extend(floors) print(f帖子 {post[post_id]} 完成共 {len(floors)} 层) except Exception as e: print(f帖子 {post[post_id]} 失败: {e})4.3 并发数到底应该配多少这个参数没有标准答案要看你的网络环境和目标网站的容忍度。根据我的实测经验百度贴吧普通浏览器访问频率约每秒钟1次通过代理池访问能到每秒钟10次而一个高匿名代理池配上合理的重试逻辑每秒钟可以跑到50个请求但这已经是极限再往上就会被封。对于家用宽带爬贴吧max_workers配4到8比较安全。我用8个线程爬某个中型吧的帖子跑了半个小时期间没有出现403被拒的情况。如果配到16个线程短时间内请求频率会明显飙升建议搭配请求间隔来控制。贴吧的反爬其实不是特别凶但也要尊重它的边界。我一直提倡君子协议式爬虫——带频率上限带并发上限对目标服务器无损害。爬虫本身不违法违法的是把对方服务器搞瘫痪了。4.4 线程安全与Session复用多线程并发下有个很容易忽略但不处理必炸的细节Session对象是不是线程安全的。requests.Session()官方没有明确承诺线程安全但实践中多个线程共享同一个Session并同时发起请求偶尔会触发连接池异常。最稳妥的方案是每个线程创建自己的Sessionimport threading local_session threading.local() def get_session(): if not hasattr(local_session, session): local_session.session requests.Session() local_session.session.headers.update(headers) return local_session.session这样每个线程各有一个Session互不干扰能有效避免连接池竞争问题。另外一个更实用的技巧Session对象要设置连接池大小。默认连接池对每个host只维持10个连接并发线程超过10个就会出现连接等待。手动调大一下session requests.Session() adapter requests.adapters.HTTPAdapter(pool_connections20, pool_maxsize20) session.mount(https://, adapter) session.mount(http://, adapter)5. 反爬对策与IP代理策略5.1 贴吧常见的反爬手段爬虫写好了不代表就能一路顺畅地跑下去。贴吧虽然不是反爬最狠的网站但以下几个类型的反爬手段你一定会遇到。User-Agent检查最简单的校验处理方式前面已经说过。Cookie校验贴吧部分页面需要Cookie才能正常访问主要是BAIDUID和BDUSS两个关键Cookie。BAIDUID是匿名用户的追踪ID一般第一次访问就会下发BDUSS是登录态Cookie如果你需要爬取需要登录才能查看的帖子就得带上BDUSS。访问频率限制短时间大量请求会返回一个安全验证页面或者直接返回Error 503。验证码在触发频控之后会弹出滑块验证或字符验证码。自动过验证码的门槛较高得接打码平台所以最好的策略是避免触发它。5.2 请求频率控制的三个层级控制请求频率的方式有三个层级从强到弱分别是固定延迟每次请求后sleep(1)最简单但效率最低。随机延迟sleep(random.uniform(0.5, 1.5))模拟真人操作节奏效率尚可不易被识别。自适应限速根据响应状态动态调整延迟。如果遇到了403就加大延迟如果连续200就稍微缩小延迟保持在高水位运行。我强烈建议项目里起手就写一个自适应限速模块import time import random class AdaptiveRateLimiter: def __init__(self, min_delay0.3, max_delay5.0): self.current_delay 1.0 self.min_delay min_delay self.max_delay max_delay def wait(self, status_code): if status_code 403 or status_code 503: self.current_delay min(self.current_delay * 1.5, self.max_delay) elif status_code 200: self.current_delay max(self.current_delay * 0.95, self.min_delay) time.sleep(self.current_delay random.uniform(-0.1, 0.1))这个模块的经验就是遇到429、503这种限流错误码最好的处理方式是直接退避而不是硬闯。很多人在爬虫被拒之后选择加大并发去对抗结果只会让自己的IP被更严格地限制。5.3 IP代理到底要不要用关于IP代理我的态度一直是小规模研究不需要大规模采集才需要。如果你只是爬个别吧的数据家用IP配合合理的访问频率完全够用。如果需要进行超大范围采集那免费代理还是算了实效性太差几乎全是失效的建议用正规服务商。使用代理时requests的写法很简单proxies { http: http://user:passwordip:port, https: http://user:passwordip:port } resp requests.get(url, headersheaders, proxiesproxies, timeout10)用代理池的时候要注意代理质量检测。很多代理服务商提供的IP几分钟就会失效如果你不判断代理是否可用就直接用爬虫会反复报ConnectionError而且重试逻辑会自动把失效代理标为失败。我自己的做法是启动时先发一个探针请求到https://httpbin.org/ip看返回的IP和代理IP是否一致如果响应时间超过5秒或者返回状态码非200就把这个代理放到淘汰列表里。每过5分钟再对淘汰列表里的代理做一次回归测试看是否有恢复可用的情况。5.4 登录Cookie的处理需要登录的场景下处理BDUSS这一步很简单但有些细节值得注意。手动从浏览器复制Cookie是一个有效率考量的事如果Cookie过期了你又得手动复制一次。更高级的做法是用playwright或者selenium控制浏览器做一次可视化登录然后把登录后的Cookie持久化保存下次直接用。不过这里要注意用浏览器自动化工具登录并获取Cookie时操作路径直接模拟真人登录即可不建议在登录环节做其他任何异常操作。实际项目中我用的方案是requests 手动Cookie配置 过期自动提醒import os def get_cookie(): cookie_str os.getenv(TIEBA_BDUSS, ) if not cookie_str: raise ValueError(请先设置 TIEBA_BDUSS 环境变量) return {BDUSS: cookie_str} # 每次请求遇到登录失效时打印提醒 def check_login_status(resp): if resp.url.startswith(https://wappass.baidu.com): print(登录态已失效请重新获取 BDUSS 环境变量) raise PermissionError(登录态失效)一般情况下BDUSS的过期时间在30天左右如果你长期用这个爬虫留意一下要不要更新Cookie。6. 页面解析的高级实践与性能细节6.1 用CSS选择器还是XPath这是另一个经典的选择困难症问题。我的建议是短平快用CSS选择器层级深、节点复杂用XPath。两者没有绝对的优劣你熟悉哪个用哪个。CSS选择器在BeautifulSoup里的写法是select(li.j_thread_list a.j_th_tit)可读性好代码简短。XPath能做的事更多比如按文本内容定位元素、精确匹配属性值、通过父节点找兄弟节点等但在BeautifulSoup里用XPath需要借助lxml.objectify或者单独用lxml库略麻烦。如果你决定直接用lxml库代替BeautifulSoup代码会快一些但可读性略差from lxml import etree tree etree.HTML(html) titles tree.xpath(//li[contains(class, j_thread_list)]//a[contains(class, j_th_tit)]/text())贴吧页面结构不算深CSS选择器完全够用。我始终认为爬虫代码是给人看的在性能差异不超过两倍的情况下优先选可读性好的方案。6.2 图片和表情内容的处理不要以为爬帖子的文字内容就万事大吉了。贴吧楼层里有大量图片和表情比如[滑稽]、[doge]这些图片表情在HTML源码中是通过img标签加classBDE_Image来标记的alt属性就是表情文字。爬虫处理图片有两种思路简单粗暴型不爬图片只提取alt属性。比如img classBDE_Image alt滑稽 src...解析的时候拿到alt滑稽转成[滑稽]存入字段。优点是不占额外存储空间缺点是丢失了图片本身的细节。完整采集型用src属性下载图片到本地。注意贴吧的图片域名有两种imgsrc.baidu.com是常规图片域名imgsrc.baidu.com/forum/pic/item/是表情图目录。常规图片建议下载表情图建议直接转文字不然一个帖子几十个表情图下载起来又慢又占空间。6.3 断点续爬与爬虫的工程化思维断点续爬是爬虫能不能用于真实项目的关键。这里的实现思路很容易理解每次爬取前先查一下数据库里已有的帖子ID集合然后跳过它们。前面提到的UNIQUE(post_id)就是为这个准备的。不过在增量场景下我不用INSERT OR IGNORE因为我想拿到变化的数据。比如一个帖子的回复数从100变成150应该是更新而不是忽略。这时候改成def upsert_post(cursor, post): cursor.execute( INSERT INTO posts (post_id, title, author, reply_count, pub_date, url) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(post_id) DO UPDATE SET reply_countexcluded.reply_count, titleexcluded.title , (post[post_id], post[title], post[author], post[reply_count], post[pub_date], post[url]))这就是工程化思维的体现脚本断点续跑、数据增量更新、异常日志输出、结构化的任务管理这些才是爬虫值钱的部分。页面解析只是入口到了生产级爬虫你面对的更多是稳定性问题和数据管理问题。6.4 日志与主流程的合理组装代码写到这个阶段建议把所有函数收进一个类或者几个模块里逻辑清晰也方便扩展。主流程我最终的实现大致是这样的先从列表页抓取帖子的索引信息随后对每个帖子详情页做并发的楼层采集最终把采集结果存进数据库并输出一份统计日志。import logging import json import sqlite3 import requests import time from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import urlencode, urlparse logging.basicConfig(levellogging.INFO, format%(asctime)s %(levelname)s %(message)s) logger logging.getLogger(__name__) class TiebaCrawler: def __init__(self, keyword, max_workers8, max_pages10): self.keyword keyword self.max_workers max_workers self.max_pages max_pages self.session self._create_session() self.conn sqlite3.connect(tieba_data.db) self.init_db() def _create_session(self): session requests.Session() adapter requests.adapters.HTTPAdapter(pool_connections20, pool_maxsize20) session.mount(https://, adapter) session.mount(http://, adapter) return session def init_db(self): cursor self.conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT, title TEXT, author TEXT, reply_count INTEGER, pub_date TEXT, url TEXT, UNIQUE(post_id) ) ) cursor.execute( CREATE TABLE IF NOT EXISTS floors ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT, floor INTEGER, author TEXT, content TEXT ) ) self.conn.commit() def fetch_list(self, page): params {kw: self.keyword, ie: utf-8, pn: page * 50} url https://tieba.baidu.com/f? urlencode(params) resp self.session.get(url, headersheaders, timeout10) resp.raise_for_status() return parse_list_page(resp.text) def crawl(self): all_posts [] logger.info(开始爬取列表页) for page in range(self.max_pages): try: posts self.fetch_list(page) all_posts.extend(posts) logger.info(f第 {page1} 页获取到 {len(posts)} 个帖子) except Exception as e: logger.error(f第 {page1} 页获取失败: {e}) time.sleep(0.5) logger.info(f共获取 {len(all_posts)} 个帖子开始并发爬取详情页) with ThreadPoolExecutor(max_workersself.max_workers) as executor: future_map {} for post in all_posts: future executor.submit(self.crawl_post, post) future_map[future] post for future in as_completed(future_map): post future_map[future] try: result future.result() logger.info(f帖子 {post[post_id]} 完成楼层数 {result}) except Exception as e: logger.error(f帖子 {post[post_id]} 失败: {e}) self.conn.close() logger.info(爬取完成数据已存入 SQLite)这个主流程覆盖了列表获取、详情并发、异常处理、日志输出、数据库存储的全链路。你把它当作模板改一改换成其他网站也能直接跑起来。7. 常见问题与排查技巧实录7.1 返回403不止User-Agent一个原因403是爬虫遇到最多的状态码它的原因很多排查要按优先级逐层递进先查User-Agent。是不是用了requests默认UA换成浏览器UA。再查Cookie。是不是请求了需要登录的板块但没带BAIDUID先访问一次https://tieba.baidu.com/拿到Cookie再继续。接着查频率。是不是短时间请求过多触发了频控降低并发数、增大延迟。最后查IP。是不是IP被限制试试手机热点或者换网络环境。从我的经验看贴吧403大概率是频率问题而非UA问题。很多新手一碰到403就去找代理IP实际上是过度反应。7.2 解析出来是乱码乱码问题的根源是编码不一致。贴吧老版本页面是GBK编码新版本页面大部分是UTF-8。解决方案很简单resp.encoding resp.apparent_encoding或者手动指定resp.encoding utf-8如果你用requests.get()请求后直接拿resp.text有时候结果会乱因为requests默认用ISO-8859-1去解码这就是乱码的根源。解决办法是先resp.encoding utf-8再取resp.text或者干脆用resp.content手动decode(utf-8)。7.3 页面加载到了但解析为空这种情况通常是你的选择器写法有问题。贴吧页面在某些情况下会有两套模板一套给PC浏览器一套给移动端。你平时在手机上看贴吧APP的页面结构和网页端不太一样但爬虫请求的是网页端HTML。调试方法很笨但很有效把响应的HTML保存到本地文件用浏览器打开打开开发者工具在Elements面板里手动搜你要定位的关键文字然后用复制选择器的方式生成CSS选择器。这样生成的选择器虽然类名冗长但准确率高调试完再手动优化精简。7.4 爬取中途IP被临时限制怎么办如果爬虫跑到一半被服务器限流返回403或503不要慌区别对待。临时限制通常在半小时到一小时内自动解除这期间你可以做如下操作冻结主任务切换网络出口或等待限流解除记录当前进度到断点文件定期检查恢复情况。如果被封一整天都没恢复而且你确认请求频率不高那大概率是你的爬虫被对方的WAF识别出了特殊指纹。检查一下请求头里有没有遗漏字段比如Sec-Fetch-*、Upgrade-Insecure-Requests这些现代浏览器都会带的身份标识。7.5 线程池提交任务过多导致内存暴涨这个坑在并发岗位比较常见。如果你的帖子数量有几万个一次性把几万个任务全部submit到线程池线程池的任务队列会撑爆内存程序直接卡死。正确做法是分批提交比如每批500个任务等提交的任务全部完成后再拿下一批def crawl_in_batches(posts, batch_size500): total_batches (len(posts) batch_size - 1) // batch_size for i in range(total_batches): batch posts[i * batch_size: (i 1) * batch_size] logger.info(f处理第 {i1}/{total_batches} 批共 {len(batch)} 个帖子) with ThreadPoolExecutor(max_workers8) as executor: futures [executor.submit(crawl_post, post) for post in batch] for future in as_completed(futures): try: future.result() except Exception as e: logger.error(f任务执行失败: {e}) time.sleep(1)这里也有一个关于as_completed的使用心得它返回的是已完成的future正常来说提交顺序和完成顺序是乱的这正是我们想要的。你不需要关心哪个任务先完成只需要知道每个future返回后对应的结果及时写入数据库即可。这样内存占用始终在可控范围内。8. 合规、经验与后续扩展方向8.1 关于爬虫合规的几条务实建议用爬虫的人心里都要有根弦尤其是爬取百度这种大平台的数据有些红线不能碰。不抓取个人敏感信息。聊天内容、联系方式、家庭住址这些字段能避开就避开。贴吧虽然是公开社区但帖子里的个人隐私内容在技术上公开不等于可以任意采集和分析。控制采集频率。单位时间内的请求数量尽量不要超过目标网站正常用户的上限。站在对方角度想你也不希望你维护的网站被爬虫拖垮。不以商业变现为目的。个人学习研究是主要场景如果你要把爬到的数据用于商业分析请先确认数据是否有版权合规问题必要时咨询专业律师。说实话我一直觉得爬虫技术本身没有原罪问题在于用它做什么。君子爱财取之有道做爬虫也是一样采集有度、用之合规这个行业才能健康发展。8.2 后续扩展从爬虫到数据洞察如果你不想止步于爬下来存起来贴吧爬虫还有几个很有价值的扩展方向。第一是热度趋势分析。把某个吧每天的帖子量、回帖量、关键词频次统计出来做一条时间序列曲线能明显看到话题的生命周期。比如某款游戏上线前后对应贴吧的热度曲线会有明显的脉冲型波动。第二是舆情分析。对帖子内容分词、统计情感倾向能分析出网友对某个话题的态度变化。这里你会用到jieba分词和简单的机器学习模型和爬虫技术衔接自然。第三是用户行为分析。统计一个吧里的活跃用户群分析他们的发帖时段、互动对象、话题偏好可以做社区运营参考。这个方向要做到数据质量可控才行因为贴吧存在大量匿名水帖和小号数据清洗成本不低。第四是定时任务化。用APScheduler或者系统crontab定时跑爬虫持续积累数据形成自己的小型数据库后续随时可以从数据里挖出有价值的信息。8.3 我在这个项目实操中的一些体会最后说几句个人经验。我重写过很多爬虫也教过很多人写爬虫感觉新手最容易犯的错误不是技术不会而是太急着写代码。拿到一个网站解析页面、写并发半天就写完结果一跑全是bug然后到处问为什么。我现在的习惯是把目标网站打开先用5分钟看页面结构再用5分钟看HTML源码最后才动手写代码。前期分析的时间占到整个项目时间的三分之一这是最划算的时间投资。这篇文章里的所有代码都是在分析清楚页面结构之后一气呵成的几乎没有返工。另一个体会是爬虫代码写完只是开始稳定运行才是关键。项目里一定要有日志、要有异常捕获、要有断点续跑这些工程化的东西看着不起眼但当你需要跑一个连续一周的采集任务时你会发现它们比功能代码还重要。如果看完这篇文章你的第一反应不是这个代码真复杂而是原来爬虫是这么回事我也能实现那我的目的就达到了。爬虫的世界没有那么多黑魔法无非是分析页面、构造请求、解析响应、存储数据再加上工程化的保障一步步来就好。