简介面向微博平台定制的网络爬虫项目资源适合需要批量采集微博正文与评论数据的爬虫开发者、数据分析师及舆情研究人员。内容围绕“抓取微博内容”与“抓取微博评论”两条主线展开系统涉及微博API调用受限时的替代方案、requests库发送HTTP请求、BeautifulSoup解析HTML页面、动态加载评论的分页处理、模拟浏览器行为绕过反爬限制等核心知识点也对应社交平台数据挖掘中的常见工程问题。压缩包采用zip格式整体大小约1.26MB。该资源上线以来已有1899人学习参考从请求发送、页面解析到数据存储代码组织与实现思路完整清晰爬虫排错经验也覆盖常见坑点能帮助Python爬虫学习者快速理解微博评论抓取的全流程是社交媒体数据采集领域的实用实战案例。1. 为什么 weibo_spider 绕不开 m.weibo.cn 这套接口社区里叫 weibo_spider 的项目一年能新出现好几个但大多数代码在首次运行一周后就开始间歇性失败报错的形态高度一致414、ok:0、空白 cards。真正让爬虫挂掉的不是 HTML 解析而是接口入口选错。PC 端 weibo.com 的页面接口布了多层风控匿名访问几乎不可用而 m.weibo.cn 移动端接口保留了清晰的 JSON 结构字段覆盖博文、评论、转发、点赞是各开源微博爬虫默认的主战场。下面把一条可复现的路径拆开讲从登录态、接口选型到博文与评论的翻页游标再到反爬节奏和定时增量覆盖把 weibo_spider 从“让跑一次”升级成“能长期跑”的全部关键环节。适合正要动手写第一个微博爬虫、以及手上脚本已经偶尔断流的读者。2. 爬取微博前的三层准备登录态、接口选型与存储方案2.1 三个入口的取舍m.weibo.cn 与 weibo.com 怎么选先把目标接口定下来这比写第一行请求代码更重要。入口返回格式风控强度适合场景m.weibo.cn/api/*JSON中用户博文、评论、搜索、热榜抓取主战场weibo.com/ajax/*JSON高部分 Web 端专属接口需要完整登录态s.weibo.cnHTML中高搜索结果页早期方案常用现在多被搜索容器替代m.weibo.cn 的优势在于移动端容器接口把用户时间线、搜索、热榜都统一成了/api/container/getIndex参数结构一致翻页游标由响应直接给出评论也有独立的 list 接口。我一般会先在这套接口上完成原型只有遇到移动端没有的字段才去 weibo.com 的 ajax 接口补。这套容器接口同样能拉热搜榜containerid 换成106003type1即可返回结构和用户时间线一致这也是很多 weibo_spider 项目把热搜与评论放在同一个模块里处理的原因。需要注意weibo.com 的 ajax 接口对 Cookie 完整性要求更高对 UA 也更敏感两者不要混用同一个 Session。2.2 登录态从浏览器复制 Cookie而不是模拟登录模拟登录微博的最大障碍不是用户名密码而是滑块和人机验证环节社区里多数登录方案过一段时间就失效。务实做法是手动登录一次从浏览器开发者工具复制完整 Cookie 字符串注入到 requests.Session 里。import requests from urllib.parse import unquote BASE https://m.weibo.cn def build_session(cookie_str: str) - requests.Session: session requests.Session() # 移动端 UA 缺失时接口可能返回 HTML 而不是 JSON session.headers.update({ User-Agent: (Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148), Accept: application/json, text/plain, */*, Referer: https://m.weibo.cn/, }) for item in cookie_str.split(;): item item.strip() if not in item: continue key, value item.split(, 1) session.cookies.set(key, value) return session这段代码做了两件事固定移动端 UA 与 Referer保证接口返回 JSON 而不是跳转到登录页把浏览器 Cookie 逐项写入 Session后续请求自动携带。Cookie 字符串直接粘过来即可不需要手工挑字段。XSRF-TOKEN 这个 Cookie 的值默认是 URL 编码过的如果后面要 POST 操作需要先 unquote 再放进 X-XSRF-TOKEN 请求头纯 GET 抓取可以暂时不处理。2.3 存储选型MongoDB 适合长期爬CSV 反而更快落地抓取代码写完后最容易返工的是存储层。微博数据是典型文档型一条博文里有 text、pics、video、page_info、topic_struct还有嵌套的 user 对象字段不确定的情况非常多。用 MongoDB 可以做无脑 upsert以 mid 做唯一键update_one({mid: mid}, {$set: item}, upsertTrue)天然去重。如果只是调研几百条数据CSV 也可以但要注意 text 里的换行和逗号以及 emoji 在 Excel 里的乱码问题落盘前先把换行替换成空格emoji 转成 unicode 转义序列。另外有一个字段规范性细节mblog 里的 created_at 常见值是“刚刚”“5分钟前”“昨天”这类相对时间入库前必须转换成绝对时间。可以在解析层统一调用一个 convert_time 函数按“分钟前/小时前/昨天/日期”几种前缀分别解析解析失败就置空。不要直接落原始字符串否则后面做时间过滤时所有历史数据都得重写。MySQL 适合做统计查询代价是字段要提前设计微博每次改版都可能导致表结构变更所以第一版建议全部落 MongoDB统计需求再通过导出或者聚合管道补上。3. 爬取微博正文containerid、卡片过滤与 since_id 翻页3.1 先拿容器 ID再请求用户时间线m.weibo.cn 的用户时间线核心参数是 containerid。用户主页的容器 ID 规律是107603 uid但直接拼接偶尔会拿到空 cards原因是接口版本变化或者用户开启了某些展示限制。稳妥流程是先请求一次 profile 容器从返回的data.tabsInfo里找到“微博”对应的 containerid再拿这个值去拉时间线。def get_profile_container(session: requests.Session, uid: str) - str: resp session.get(f{BASE}/api/container/getIndex, params{ type: uid, value: uid, }, timeout10) data resp.json() if data.get(ok) ! 1: raise RuntimeError(fprofile 请求失败: {data}) for tab in data[data][tabsInfo][tabs]: if tab.get(title) 微博: container_type tab.get(containerid) return f107603{uid} if not container_type else container_type raise RuntimeError(未找到微博 tab)动态获取 containerid 的意义不只是拼 URL而是确认账号存在、接口版本正常顺带验证 Cookie 是否有效。很多脚本为了省这一次请求直接硬编码一旦遇到接口改版排查方向就变成“为什么 cards 是空的”而动态获取至少能把问题定位到“Cookie 失效”还是“接口字段变更”。3.2 过滤 card_type 与提取 mblog 字段/container/getIndex返回的 cards 是一个混合数组card_type 为 9 的是博文卡片11 是推荐广告位其他还有用户卡片、话题卡片、直播卡片。抓博文时不做过滤入库数据就会被推荐位污染这也是很多爬虫数据里出现“主页没发过这条广告、时间线里却有”的原因。def parse_status_cards(session: requests.Session, cards: list) - list: posts [] for card in cards: if card.get(card_type) ! 9: continue mblog card.get(mblog) if not mblog: continue item { mid: mblog.get(id), bid: mblog.get(bid), uid: mblog.get(user, {}).get(id), screen_name: mblog.get(user, {}).get(screen_name), text: clean_html(mblog.get(text, )), created_at: mblog.get(created_at), reposts_count: mblog.get(reposts_count), comments_count: mblog.get(comments_count), attitudes_count: mblog.get(attitudes_count), } posts.append(item) return postsclean_html 建议用 lxml.html 转纯文本后去掉首尾空白不要用正则硬删 a 标签因为微博正文里的 用户和话题都包在 a 标签里正则容易把超链接文字一起删掉。pics 是图片列表下载图片时记得加Referer: https://weibo.com否则会拿 403。视频地址藏在page_info.media_info里拿到播放地址后交给下载器同样要带 Referer 和 Range 参数微博视频下载与图片下载共用同一套防盗链逻辑。3.3 长微博与转发微博的处理m.weibo.cn 对超过一定长度的博文做了截断mblog.text 只是预览isLongText1 表示需要另外请求全文。def expand_longtext(session: requests.Session, mblog: dict) - str: if mblog.get(isLongText) ! 1: return mblog.get(text, ) resp session.get(f{BASE}/statuses/show, params{id: mblog[id]}, timeout10) detail resp.json() if detail.get(ok) 1: return clean_html(detail[data][text]) return mblog.get(text, )长文本接口的代价是每条多一次请求而且这个接口的风控比时间线更敏感。批量抓取时先判断 isLongText 再决定是否展开避免对短微博做无意义请求。转发微博的内容取mblog.retweeted_status.text但在数据模型里要单独留字段不要直接覆盖外层 text否则会丢失转发层级信息。3.4 翻页用 since_id 游标而不是页码这是 weibo_spider 最常见的翻页误区。用户时间线没有 page 参数翻页靠上一次响应里cardlistInfo.since_id字段。def fetch_user_statuses(session: requests.Session, uid: str, since_idNone): params { type: uid, value: uid, containerid: f107603{uid}, } if since_id: params[since_id] since_id resp session.get(f{BASE}/api/container/getIndex, paramsparams, timeout10) data resp.json() if data.get(ok) ! 1: raise RuntimeError(fstatuses 请求失败: {data}) info data[data][cardlistInfo] cards data[data][cards] next_sid info.get(since_id) return cards, next_sid调用方用一个 while 循环驱动直到 next_sid 为空或重复出现就认为该用户历史博文拉完。两个边界要处理一是翻到一定深度后可能返回空 cards 但仍有 since_id这时要设置最大页数兜底二是同一 since_id 在连续两页出现说明游标没有前进继续循环就是死循环判断条件里必须加上“本次 since_id 不等于上一次”。4. 爬取微博评论的完整链路max_id 分页、楼中楼与去重4.1 评论接口参数与第一次请求的差异评论挂在单条微博下所以爬评论的前提是把 mid 传对。m.weibo.cn 的评论接口是/api/comment/list第一页请求不传分页参数响应里返回 total_number 和 max_id。只传 page 参数不行新版本服务端对固定页码翻页有限制翻到十几页后就开始返回重复内容。def fetch_comments(session: requests.Session, mid: str, max_idNone): params { id: mid, mid: mid, max_id_type: 0, } if max_id: params[max_id] max_id resp session.get(f{BASE}/api/comment/list, paramsparams, timeout10) data resp.json() if data.get(ok) ! 1: raise RuntimeError(fcomments 请求失败: {data}) body data[data] return body.get(comments, []), body.get(max_id)id 与 mid 都传数值型 mid兼容性最好。max_id_type 用来区分排序方式0 是默认时间线1 在某些版本表示热评通道语义随 m.weibo.cn 改版会有变化建议抓包确认而不是硬编码。第一次请求 max_id 为空后面每次把上一次响应的 max_id 原样回传直到返回的 max_id 为空或与上一轮相同。4.2 遍历评论流与评论数对账拿评论数做对账是这章最核心的技巧。mblog 里的 comments_count 是接口统计口径而 comment/list 翻完后的累计条数经常对不上一是热评与最新评论是两条独立游标二是被折叠的评论不计入 total_number。做舆情分析时对账逻辑应该是“累计条数达到 total_number 的 80% 就停止”而不是死等两者相等。def walk_all_comments(session, mid, max_pages100): comments [] max_id None last_max_id None for _ in range(max_pages): batch, max_id fetch_comments(session, mid, max_id) comments.extend(batch) if not max_id or max_id last_max_id: break last_max_id max_id time.sleep(random.uniform(1.5, 3.0)) return comments循环里 sleep 不能省评论接口的风控触发阈值比时间线低很多。max_pages 是兜底防止接口异常时游标无限循环真实场景 100 页基本能覆盖绝大多数微博遇到百万级评论的突发事件再考虑热评接口分流或直接改采样。4.3 楼中楼的拉取评论接口返回的 comments 里每一项带 rootid 和 reply_comment就说明存在楼中楼。老版本 m.weibo.cn 有独立的 listbyflow 接口用于子评论改版后部分路径合并进 comment/list通过 cid 参数指定父评论 id。def fetch_replies(session, mid, cid): params { id: mid, mid: mid, cid: cid, } resp session.get(f{BASE}/api/comment/list, paramsparams, timeout10) data resp.json() return data.get(data, {}).get(comments, [])cid 参数在不同版本里又出现过 cid 与 comment_id 两种命名最靠谱的做法是打开浏览器开发者工具翻一条带楼中楼的微博看实际网络请求里的 query 参数名再回填到代码里。楼中楼的量级通常是父评论的几倍抓取前先统计 rootid 数量决定是否展开全部子评论还是抽样即可。4.4 评论去重接口层去重加库层约束评论数据在翻页时有一个已知问题抓取过程中新评论持续产生游标翻过的边界区域会互插同一条评论可能出现在上一页和下一页。处理办法是两重去重接口层用一个 set 记录本次任务已见过的 comment id库层用 comment id 做唯一键 upsertMongoDB 里可以把 rootid 建成索引方便统计楼中楼。seen set() for c in batch: cid c[id] if cid in seen: continue seen.add(cid) collection.update_one({comment_id: cid}, {$set: c}, upsertTrue)去重不能只靠内存任务重启后 set 会清空至少要把已抓 comment_id 的最后值持久化到文件或 Redis作为断点续爬的输入。这个和游标持久化是同一套机制放一起设计。5. weibo_spider 的反爬工程化限速、代理、多账号与断点续爬5.1 限速不是越慢越好是越不确定越好固定间隔 3 秒的请求在服务端看来比随机间隔更容易被识别。weibo_spider 的限速建议采用随机区间加突发上限双约束。import random import time MIN_INTERVAL 2.0 MAX_INTERVAL 6.0 def pace(min_secMIN_INTERVAL, max_secMAX_INTERVAL): time.sleep(random.uniform(min_sec, max_sec))连续两次请求间隔始终落在区间内但每次取值不可预测。突发上限是另一个维度无论怎么随机同一 Session 每分钟请求数不要超过 20 次评论接口要更保守。抓到 414 或 ok:0 时不要马上重试先停 60 到 120 秒再试一次连续两次失败就切换账号或代理。5.2 代理池质量优先于数量微博对代理 IP 的检测集中在归属地、机房段和连接质量三个点。没有合规代理资源的情况下单机单 IP 跑慢速任务也比一个不干净的代理池安全得多。代理验证要在任务开始前做一次连通性测试爬虫进程不要动态切换到不可用节点。日志里把 PROXY_ERROR 和 AUTH_ERROR 分开记排查时能直接看出是代理断了还是账号失效。5.3 多账号轮换与 Cookie 保活需求量大时单账号限速会明显拖慢任务常见做法是准备一组被授权使用的账号按 uid 或关键词哈希到不同账号上。轮换策略有一个反直觉的细节同一个账号不要短时间内处理太多不同 uid 的请求账号风控维度里“行为一致性”比“请求总量”更敏感。一个账号在 30 分钟里只抓一个目标用户的全部博文再切换下一个账号比三个账号同时抓同一个人安全得多。Cookie 保活没有全自动的稳定方案因为 SUB 等关键 Cookie 的刷新需要重新登录。可行的做法是每天定时半自动刷一次登录态把新 Cookie 写回配置中心任务发现 ok:0 直接告警而不是被动等全部任务失败再人工介入。多账号配置建议用单独的配置文件管理字段包含 uid、cookie、权重、最后使用时间脚本启动时按权重分配。5.4 断点续爬把游标当作一等公民长期跑任务的关键在断点。博文抓取断点是 since_id评论抓取断点是 max_id这两个值必须在每一批次入库时落盘。import redis r redis.Redis(host127.0.0.1, port6379, db0) def save_checkpoint(key: str, value: str): r.set(key, value) def load_checkpoint(key: str): return r.get(key)不要把 checkpoint 保存在任务结束时一次性写任务可能在任何一页崩溃。每次翻页后立即写 Redis成本很低。数据库里的博文本身也可以作为断点依据抓取前先查已存的最大 mid时间线翻到该 mid 之后把剩余部分过滤掉即使游标丢了也能从库里恢复。日志至少输出“页码、游标值、本页条数、累计条数、耗时”这些字段是排错的第一手信息。6. 把 weibo_spider 改成定时增量任务的三个落地细节6.1 写代码之前先用 curl 验证接口M 站接口字段变化频繁每次开始新任务前用 curl 把目标接口真实响应拉下来看一遍比直接跑 Python 更快发现字段变动。curl -s https://m.weibo.cn/api/container/getIndex?typeuidvalue123456containerid107603123456 \ -H User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 \ -H Referer: https://m.weibo.cn/ \ -H Cookie: SUBxxx把返回 JSON 保存下来用 jq 查看.data.cards[0].card_type和.data.cardlistInfo确认字段名和上一次一致再跑脚本。这个习惯能省掉大半“代码昨天还好今天为什么不行”的排错时间。6.2 用 mid 的字符串递增做增量游标mid 是雪花号值随时间递增已抓数据里 mid 最大的那条就是时间线增量的起点。定时任务里可以不起 Redis直接查数据库该用户最大的 mid然后对时间线逐页翻到mid 这个值停止。实现流程查询数据库中该用户已存在的最大 mid。从时间线第一页开始请求逐页解析博文。当前页最小 mid 小于等于库中最大 mid 时过滤掉已存在的部分并停止。这个方案比纯 Redis 游标更稳健因为即使 Redis 数据清空也能从数据库恢复进度。评论增量则还是优先用 max_id 断点评论 id 虽然也递增但评论流存在热评和最新两条游标只靠 id 对账精度不够。6.3 退避重试的分层模板增量任务失败重试时退避要分层处理单接口失败等 2 到 4 秒连续失败三轮等 60 秒账号被风控返回 ok:0 时等 15 分钟以上。503、超时这类瞬时错误可以立即重试414 和 ok:0 属于风控信号重试越频繁越容易加重限制。最后一个细节定时任务调度统一用系统 crontab 加 flock 防重入比在 Python 内部做单实例锁更简洁进程崩溃后不会残留锁文件。本文还有配套的精品资源点击获取