Instagram爬虫实战:从Cookie到动态JSON接口的完整指南
1. 整体思路拆解先搞清楚Instagram的“门”在哪说实话爬取Instagram这类平台的数据难的不是Python语法而是你得先明白它到底允不允许你拿数据、数据又以什么形式存在。很多新手上来就找现成库结果装上不是报错就是被封根本原因就是没搞懂Instagram的“门”朝哪开。Instagram作为全球量级的图文视频社交平台它的前端数据大体分两类一类是服务端渲染的静态HTML另一类是浏览器端通过JavaScript动态请求回来的JSON数据。早期大家写的爬虫都盯着页面源码里的img标签和video标签去正则匹配但这个思路早就行不通了。现在的Instagram页面图片地址是加密混淆过的视频地址也是动态拼接的直接解析HTML基本拿不到完整的媒体文件链接。再往深一层说Instagram对外其实有官方的开放平台接口但那个东西申请门槛高、调用配额少而且只适用于商业合作类项目普通个人开发者拿它去做数据分析或者内容备份根本不现实。所以实际工程里大家普遍走的是一条“半官方”的链路模拟真实用户在浏览器端的行为去请求那些网页端自身在调用的内部接口。这个思路本身不复杂复杂度全在细节上——请求头、Cookie、签名参数、分页游标一个不对就是403或者login页面。我把整件事拆成四个模块来看帖子信息抓取包括文案、点赞数、评论数、发布时间、作者信息等元数据。图片资源下载包含单图和多图轮播两种形态。视频资源下载含普通视频和Reels短视频。本地落地存储按规则重命名、去重、分目录归档。每个模块之间是解耦的你可以只下载图片也可以只抓文案甚至可以单独做一个“只看某个博主最新十条更新”的提醒服务。后续我会按这个顺序逐步展开。2. 关键信息获取Cookie、Headers、动态JSON接口2.1 手动构造请求Session不管你是用requests还是httpx首先得解决“身份认证”问题。Instagram的接口基本都要求带上登录后的Cookie才能返回完整数据特别是当访问频次上去之后不带Cookie的匿名请求大概率会撞上要求登录的拦截页。我常用的做法是先手动在浏览器里登录自己的Instagram账号然后打开开发者工具F12在网络面板里随便点击一个请求找到Request Headers里的cookie和user-agent把它们复制出来拼到Python脚本里。这里有几个细节容易踩坑Cookie字段是先导能不能持续稳定抓取就看它。要注意Cookie里的sessionid和csrftoken这两个值前者是登录态的核心凭证后者用于部分写操作和请求校验。User-Agent不要用Python的默认值那种一眼就能识别是脚本的请求头非常容易被风控。直接把浏览器里的完整UA字符串抄过来就行。除了这两个字段建议把Accept-Language、Referer也一并带上虽然短时间不设置也能跑但长期稳定的抓取任务里缺一个字段都可能是触发风控的导火索。如果哪天发现自己请求返回的页面带着“Please wait a few minutes before you try again”的提示别怀疑就是请求头或者IP被标记了。2.2 从HTML页面里提取JSON数据Instagram的网页版虽然把大部分数据渲染成动态交互的样子但它有一个对我们极其友好的底层逻辑——服务端会把最关键的数据以JSON字符串的形式直接内联在页面HTML里。这个JSON位于script typetext/javascriptwindow._sharedData .../script这段脚本中早期的很多爬虫就是靠它吃饭的。你现在去访问任意一个公开的帖子页面查看网页源代码仍然能找到这个结构只不过字段加密程度和嵌套深度有所变化。解析思路分两步第一步用正则表达式或者字符串切割把window._sharedData 后面的JSON字符串提取出来。第二步用json.loads()把字符串解析成Python字典然后逐层导航到目标字段。比如帖子数据通常嵌套在entry_data - PostPage - [0] - graphql - shortcode_media这条链路上。这里想提醒一点不要试图背下固定的字段路径因为Instagram的前端代码经常小版本更新字段位置可能从第5层挪到第6层。更好的习惯是拿到JSON之后先用json.dumps格式化打印大致扫一眼结构再动手写提取逻辑。你花在观察上的两分钟通常能省下后面排查字段异常的两小时。2.3 短链与查询Hash如果只抓单条帖子解析_sharedData够用。但如果要抓某个用户的所有帖子或者抓某个话题标签下的内容就得走另一类接口——GraphQL查询接口。这类接口通常暴露在/graphql/query/路径下需要传递一个query_hash参数这个参数是一串固定的哈希值代表某个特定的数据查询操作。问题的麻烦点在于Instagram会在不通知的情况下更新这些hash值。我自己的做法是每次写新脚本前先在浏览器里手动触发一次目标页面的刷新操作在网络面板里找到对应的GraphQL请求把最新的query_hash和variables结构抄出来再填到代码里。这不是什么高科技但确实是最可靠的办法。指望某个第三方库永远内置最新hash是不现实的Item更新再怎么勤快也追不上平台变脸的速度。3. 核心实现细节从分页游标到媒体链接提取3.1 用户主页帖子分页机制要抓某个博主的所有公开帖子首先得拿到该用户主页的ID和分页游标。用户主页数据里有一个edge_owner_to_timeline_media字段它包含了page_info是否还有下一页、下一页的游标和edges帖子的简要信息数组。分页请求长这样简化表示def fetch_user_posts(session, user_id, after_cursorNone): variables { id: user_id, first: 12, after: after_cursor } # 注意这里的query_hash需要从最新网络请求里获取 url https://www.instagram.com/graphql/query/ params { query_hash: xxx, variables: json.dumps(variables) } resp session.get(url, paramsparams) data resp.json()[data][user][edge_owner_to_timeline_media] has_next data[page_info][has_next_page] end_cursor data[page_info][end_cursor] edges data[edges] return edges, has_next, end_cursorfirst参数决定每次请求返回的帖子条数设置成12是比较稳妥的太大容易触发风控太小则请求次数过多。每次请求拿到新的end_cursor循环下一轮直到has_next_page返回false。这里有个我砸过键盘的教训end_cursor是不断变化的有时像一串Base64编码有时又像一串数字谁都不能保证它的格式长期不变。所以处理游标时一定要用接口返回的最新值别自作聪明去拼接或者缓存老老实实每次都动态更新。3.2 帖子详情页里拆出图片和视频当拿到帖子列表之后如果只需要封面缩略图那列表页返回的display_url就够用了。但要做高质量备份那必须进到单条帖子的详情页去取原始分辨率的资源。在单帖详情JSON里核心字段是shortcode_media下的这几个display_url主图片的地址。is_video布尔值区分帖子是图还是视频。video_url视频直链存在才返回。edge_sidecar_to_children多图帖子的子节点列表每个子节点里又有自己的display_url或video_url。提取逻辑的核心就是先判断是不是多图帖再遍历所有子节点def extract_media(item): media_list [] if item.get(is_video): media_list.append({ type: video, url: item.get(video_url), post_id: item[shortcode] }) children item.get(edge_sidecar_to_children) if children: for node in children[edges]: sub node[node] if sub.get(is_video): media_list.append({type: video, url: sub.get(video_url)}) else: media_list.append({type: image, url: sub.get(display_url)}) elif not item.get(is_video): media_list.append({type: image, url: item.get(display_url)}) return media_list这里有个细节容易看走眼多图帖里的第一张图它的display_url和子节点列表里的第一项是重叠的。如果你两种方式都取就会造成重复下载。我个人做法是只要检测到存在edge_sidecar_to_children就直接忽略顶层的display_url全部以子节点里拿到的为准。3.3 视频和图片的二进制下载拿到媒体直链之后下载本身倒是简单的用session.get(url, streamTrue)把二进制流写进文件即可。但真实场景里有两个摩擦点一是媒体地址的域名有时会动态变化比如图片存在cdninstagram.com视频存在video.cdninstagram.com。域名变了但请求路径基本相似只要你不把域名硬编码到验证逻辑里一般没事。二是直接按原始URL下载部分情况下服务端会额外校验Referer头。这就需要在下载时再次复用之前构造的请求头尤其是Referer要填帖子详情页的地址。很多新手在此处只传了URL没用Session导致下载返回403排查半天发现是请求头丢了。文件保存环节我习惯这样组织目录结构downloads/ └── {username}/ ├── {post_id}_0.jpg ├── {post_id}_1.jpg └── {post_id}_video.mp4命名规则里带上post_id并且给多图帖子的每一张图追加序号这样既能防止不同帖子之间的文件名冲突又能在之后想挑出某一个帖子的所有资源时快速归类。4. 避坑指南反爬、字段变更与限流4.1 常见的状态码与处理策略下面这张表是我在实际抓取中反复遇到的几类情况直接做成速查表状态/返回特征可能原因处理方式403 ForbiddenCookie过期、请求头不全、IP被标记更新Cookie核对UA和Referer适当降低请求频率401 Unauthorized登录态失效csrf_token不匹配重新从浏览器复制最新Cookie200但页面显示login触发强制登录风控停止抓取一段时间换个请求指纹再试429 Too Many Requests频率过高限流生效增加延时随机Sleep考虑并发改成串行JSON解析报KeyError平台更新了返回字段结构打印最新JSON重新定位字段路径视频/图片下载403下载请求未携带Referer下载时也带上完整请求头我的经验是除了前面两项和工程Bug强相关的问题其他几乎都是“太贪心”导致的。Instagram的风控不像某些平台那么凶猛但它也不喜欢被高频请求。把单次请求间隔控制在3到5秒每次抓取总量控制在几百条以内用起来还算和平。4.2 Cookie失效的提前预警Cookie不会突然失效它是有前兆的。当你发现返回的数据里某些公开账户的粉丝数、帖子数变成null或者分页请求翻了几页之后突然返回空列表这些往往说明登录态已经被服务端悄悄标记了。这时候不需要等报错主动停掉任务去浏览器重新登录一次再复制新Cookie能省下不少封禁风险。另外有个小技巧Cookie里有个ds_user_id字段它和当前登录用户绑定。如果你脚本里同时跑多个账号的任务务必确保请求时用的ds_user_id和sessionid是一套的混着用必出错。4.3 尽量不做模拟点击和浏览器自动化我在评估技术方案时曾经考虑过用浏览器自动化方案去直接操作页面。这个思路的优点是选择器稳定的时候不用管接口参数缺点是资源占用大、速度慢、且更容易被检测到自动化特征。对于纯数据获取的场景我始终推荐直接用requests走JSON接口轻量、可控、易维护。有人担心JSON接口不稳定其实这种担心可以理解但实际工作中只要控制好频率、随时准备监听接口更新稳定性完全够用。浏览器自动化方案在遇到页面结构大改时要么重写选择器要么等插件更新反而更崩溃。5. 完整示例下载博主最新50条帖子的所有图片下面我用一个相对完整但保持精简的代码示例串联前面所有知识点。目标明确抓取指定博主主页最新的50条帖子把所有图片和视频下载到本地。import json import os import time import random import requests USERNAME 目标博主用户名 COOKIE 你的浏览器里复制的Cookie UA 你的浏览器UA字符串 QUERY_HASH 从最新网络请求里获取 headers { user-agent: UA, cookie: COOKIE, referer: fhttps://www.instagram.com/{USERNAME}/, accept-language: zh-CN,zh;q0.9,en;q0.8 } session requests.Session() session.headers.update(headers) def get_user_id(username): url fhttps://www.instagram.com/{username}/ resp session.get(url) # 简化处理实际建议用正则或按关键字切割 start resp.text.find(profilePage_) return None # 这里省略了具体的ID提取过程 def main(): user_id get_user_id(USERNAME) if not user_id: print(找不到用户ID) return os.makedirs(USERNAME, exist_okTrue) collected 0 cursor None while collected 50: variables {id: user_id, first: 12, after: cursor} resp session.get( https://www.instagram.com/graphql/query/, params{query_hash: QUERY_HASH, variables: json.dumps(variables)} ) data resp.json()[data][user][edge_owner_to_timeline_media] edges data[edges] page_info data[page_info] for edge in edges: if collected 50: break node edge[node] media_items extract_media(node) for idx, item in enumerate(media_items): ext mp4 if item[type] video else jpg fname f{node[shortcode]}_{idx}.{ext} download_media(item[url], os.path.join(USERNAME, fname)) time.sleep(random.uniform(1, 2)) collected 1 if not page_info[has_next_page]: break cursor page_info[end_cursor] time.sleep(random.uniform(3, 5)) def download_media(url, path): if os.path.exists(path): return resp session.get(url, streamTrue, timeout20) if resp.status_code 200: with open(path, wb) as f: for chunk in resp.iter_content(chunk_size8192): f.write(chunk) if __name__ __main__: main()这里为了篇幅删掉了部分字段提取细节但整体流程就是前面讲的先拿用户ID再分页拉列表然后逐条解析媒体链接最后下载保存。跑一遍这个脚本你可以得到一个以博主名命名的文件夹里面是干净命名的图片视频文件。这个示例本身不建议修改并发数量直接上量50条帖子在频率控制得当的前提下大约需要两到三分钟属于比较安全的范围。6. 合规边界与实践建议聊技术不能不聊边界。Instagram的官方服务条款里对自动化抓取是有限制的尤其是涉及登录态的数据操作很容易触碰平台规则。我的态度是这个技术最合理的用途是给自己或得到授权的人做内容备份、数据归档、个人学习研究而不是批量采集他人内容去建站、做数据集或者导流。在具体操作时有几条红线我自己是一直守着的只抓公开账号的数据不碰私密账号不去暴力猜测密码或绕过访问限制。下载的素材仅用于个人备份或学习涉及商业用途先获得授权。控制频率不用多账号矩阵去分散请求以规避风控这种做法既不道德也容易被一锅端。不把媒体资源二次分发尤其是不打上自己的水印去伪装原创。说到底爬虫工程本质上是“接口逆向工程落地”的组合技术能力决定你能不能做到合规意识决定你能做多久。保持良好的抓取习惯不把平台接口打挂也不把对方数据滥用这项技能才能长期用下去。7. 后续还能怎么玩写到这里基础版本已经跑通了。如果你还想往下深挖我建议从这几个方向扩展定时任务用系统计划任务或者定时脚本每天自动增量抓取关注的博主更新做成个人版的信息聚合流。数据分析把抓到的文案时间线、点赞数、评论数做成趋势图分析账号的发布规律和内容调性。去重与增量本地维护一个已处理帖子ID的集合每次运行只处理新增内容节省时间和流量。异常告警当某个博主删除帖子、账号改名或者评论异常增长时脚本自动发送提醒。我个人经验里最实用的组合是“增量抓取按周聚合报告”既不会频繁打扰平台又能让数据真正为你的决策提供参考。技术从来不是孤立的它落到具体使用场景里才产生价值。