抖音用户视频列表获取实战:模拟App请求与反爬策略解析

发布时间:2026/7/31 10:25:12
抖音用户视频列表获取实战:模拟App请求与反爬策略解析 1. 项目概述从零解析抖音用户视频列表获取最近在做一个内容分析的小工具需要批量获取某个特定抖音创作者的所有视频信息比如标题、发布时间、点赞数、评论数这些基础数据。一开始觉得这应该是个挺简单的活儿不就是调个接口嘛。但真上手才发现抖音这套防护机制做得相当严密从网页端到App端各种反爬策略层层叠叠直接请求官方接口几乎寸步难行。网上能找到的很多所谓“教程”要么已经失效要么语焉不详踩了不少坑。今天就把我折腾了挺久才跑通的一套相对稳定、可复现的方案整理出来核心思路是通过模拟App请求获取用户唯一的sec_uid然后调用其作品列表接口。整个过程会涉及到请求库的使用、参数逆向、签名机制以及如何优雅地处理风控。无论你是想做数据分析、内容监控还是其他自动化工具这套方法都能给你提供一个扎实的起点。2. 核心思路与技术选型2.1 为什么不能直接爬取网页首先得明确一点直接爬取抖音网页版www.douyin.com来获取用户视频列表在2023年之后已经变得非常困难。抖音在前端做了大量的混淆和加密关键数据如用户作品列表通常通过异步接口加载这些接口的URL参数尤其是签名X-Bogus和_signature生成逻辑极其复杂且频繁变动。试图在浏览器中逆向JavaScript来复现这套逻辑对于大多数开发者来说成本太高且维护性极差。2.2 移动端API的优势与挑战相比之下抖音App的API接口虽然也有签名和风控但其协议相对稳定参数逻辑也更有迹可循。我们的核心目标接口是获取用户作品列表的API其形态通常类似于https://www.iesdouyin.com/web/api/v2/aweme/post/这个接口需要几个关键参数sec_uid: 用户的唯一标识这是整个流程的起点和关键。count: 每次请求期望返回的视频数量通常最大为20。max_cursor: 分页游标用于获取下一页数据首次请求为0。最大的挑战在于这个接口以及获取sec_uid的接口都会校验请求头Headers和签名。签名错误或请求头不完整会直接返回403或400错误。因此我们的技术方案核心就变成了如何伪装成一个合法的抖音App客户端去发送请求。2.3 技术栈与工具选择基于以上分析我选择了以下技术栈这也是目前爬虫领域处理这类问题的常见组合Python Requests: 作为主要的HTTP请求库requests足够简单和强大。需要配合session来维持Cookie和部分Header。抓包工具: 这是逆向分析的“眼睛”。我主要使用Charles / Fiddler: 用于在电脑上抓取模拟器或真机代理过来的流量适合静态分析API调用链。Packet Capture / HttpCanary (安卓): 手机端直接抓包无需Root即可抓取抖音App的HTTPS流量需安装证书对于动态观察请求生成过程非常方便。逆向分析工具: 主要靠浏览器开发者工具F12和抓包工具分析请求/响应内容、参数构成。并不需要深入到Native层的SO库逆向。关键思路我们并不需要完全逆向抖音的整个加密算法如X-Bogus因为对于获取公开视频列表这个需求我们可以通过复用从真实App中捕获的、有效的请求参数和Headers来达到目的。重点是理解哪些参数是固定的哪些是每次需要变化的以及如何构造一个“像模像样”的请求。注意任何自动化访问行为都应遵守网站的robots.txt协议并严格控制请求频率避免对目标服务器造成压力。本方案仅用于技术学习和个人合法的数据收集严禁用于商业爬取、骚扰或其他违反抖音用户协议的行为。3. 实操第一步定位并获取关键参数 sec_uidsec_uid是抖音用户体系中的一个核心加密ID不同于我们在分享链接中看到的短ID或数字ID它更长、更唯一是调用大多数用户相关API的必需参数。获取不到它后续所有工作都无法开展。3.1 寻找 sec_uid 的来源有多个入口可以获取sec_uid这里介绍最稳定和直接的两种方法。方法一从用户分享链接或主页地址提取推荐这是最便捷的方式。让目标用户在抖音App内点击“分享主页”复制链接。链接格式通常如下https://v.douyin.com/ABC123Def/或https://www.douyin.com/user/MS4wLjABAAAAxxxxx你需要访问这个短链接它会经过一次或多次跳转最终到达用户的长链接主页。我们的目标是在跳转后的最终主页URL中或者页面源代码里找到sec_uid。操作步骤在浏览器建议无痕模式避免缓存干扰中打开分享的短链接。等待页面完全加载即跳转停止观察地址栏。最终的URL可能形如https://www.douyin.com/user/MS4wLjABAAAAvWZf-xxxx-xxxx?modal_id...注意这里的MS4wLjABAAAAvWZf-xxxx-xxxx并不是sec_uid它是另一种用户标识。按下F12打开开发者工具切换到Network网络选项卡刷新页面。在网络请求中寻找一个名称包含/user/profile/或/aweme/v1/web/user/profile/的请求。点击这个请求在Response响应标签页中你会看到一段JSON数据。在这段JSON中搜索sec_uid字段。它的值是一长串由字母、数字、下划线和减号组成的字符串例如MS4wLjABAAAAvWZfxxxxxxxxxxxxxxxxxxxxxxxxxx。复制这个值这就是我们需要的sec_uid。方法二通过抓包App直接获取API响应如果你在手机上进行抓包例如使用HttpCanary过程更直观打开抓包App开始录制。打开抖音App进入目标用户的主页。停止抓包在抓包记录中搜索关键词sec_uid。你通常会找到一个请求URL为https://*.douyin.com/aweme/v1/web/user/profile/other/的请求其响应体JSON中就包含了sec_uid。3.2 编写代码提取 sec_uid手动找一次可以但自动化工具需要能自动完成这个过程。思路是访问短链接允许重定向从最终响应的HTML内容或重定向地址中解析出sec_uid。但经过测试直接从HTML中解析sec_uid的难度在增大抖音将其隐藏得更深。一个更可靠的方法是模拟一次访问主页的请求并从其后续发出的API请求中拦截。但这对自动化脚本要求较高。因此在实际项目中我往往采用一个折中方案将sec_uid作为工具的输入参数。即用户需要手动通过上述方法一获取一次sec_uid然后提供给程序。虽然多了一步手动操作但极大地提高了程序的稳定性和复杂度。如果非要实现全自动可以尝试在请求主页后用正则表达式在页面HTML或内联的JavaScript变量中搜索sec_uid但匹配规则需要经常更新不稳定。import re import requests def extract_sec_uid_from_share_url(share_url): 尝试从分享链接中提取sec_uid此方法稳定性有限仅供参考 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 } session requests.Session() session.headers.update(headers) # 允许重定向获取最终的响应 try: response session.get(share_url, allow_redirectsTrue, timeout10) final_url response.url html_content response.text # 方法1: 尝试从最终URL的路径中匹配适用于某些格式 # 例如https://www.douyin.com/user/MS4wLjABAAAAvWZf... # 注意这个MS4wLjAB... 是sec_uid的一种表现形式但通常需要直接使用 pattern_from_url r/user/(MS4wLjAB[^/?]) match re.search(pattern_from_url, final_url) if match: # 实际上从URL路径中获取的这个就是sec_uid return match.group(1) # 方法2: 尝试从HTML的script标签或JSON数据中匹配复杂且易变 # 这里是一个简化的、可能很快失效的正则示例 pattern_in_html rsec_uid\s*:\s*([^]) match re.search(pattern_in_html, html_content) if match: return match.group(1) except Exception as e: print(f提取sec_uid失败: {e}) return None # 使用示例 share_link https://v.douyin.com/ABC123Def/ sec_uid extract_sec_uid_from_share_url(share_link) if sec_uid: print(f提取到的 sec_uid: {sec_uid}) else: print(自动提取失败请手动获取。)实操心得在实际生产环境中我强烈建议将sec_uid的获取作为独立的手动前置步骤。你可以写一个简单的使用说明告诉用户如何通过浏览器开发者工具获取。这比维护一个脆弱不堪的全自动提取函数要省心得多也稳定得多。把精力集中在核心的列表获取逻辑上。4. 构建仿真的App请求环境拿到sec_uid后下一步就是模拟App去调用作品列表接口。直接用一个裸的requests.get()肯定会吃闭门羹。我们需要精心构造请求头Headers和查询参数Query Parameters。4.1 分析并准备关键请求头通过抓包抖音App的请求你会发现其Headers包含了许多特征字段。以下是一些最关键且通常需要携带的Headersimport requests # 一个模拟抖音App请求的Headers示例部分值需要替换 headers { # 用户代理模拟抖音App User-Agent: com.ss.android.ugc.aweme/2020102100 (Linux; U; Android 11; zh_CN; MI 9; Build/RKQ1.200826.002; Cronet/TTNetVersion:3c28619c 2020-05-19), # 宿主App Host: www.iesdouyin.com, # 连接方式 Connection: keep-alive, # 接受编码 Accept-Encoding: gzip, deflate, br, # 接受语言 Accept-Language: zh-CN,zh;q0.9,en-US;q0.8,en;q0.7, # 这个Cookie至关重要通常包含了登录态和设备标识 Cookie: 你的Cookie字符串从抓包中获取, # 引用来源有时可留空有时需要是抖音域内地址 Referer: https://www.douyin.com/, # 接受的内容类型 Accept: application/json, text/plain, */*, # 内容类型 Content-Type: application/x-www-form-urlencoded, }关键点解析User-Agent: 这是设备的“指纹”。上述示例是一个Android抖音客户端的UA。保持一个真实有效的UA很重要。你可以从抓包数据中直接复制也可以在网上搜索最新的抖音UA。Cookie:这是身份认证和风控的核心Cookie里通常包含了sessionid、install_id、ttwid、odin_tt等关键字段。这些字段标识了你的设备和模拟的登录状态。没有有效的Cookie接口会返回403或要求登录。如何获取通过抓包工具在任意一个成功的抖音API请求的Headers里找到Cookie字段完整复制下来。这个Cookie有一定有效期。其他Headers: 如Host,Referer,Accept-*等尽量与抓包到的请求保持一致填满总比空缺好。4.2 理解并处理签名参数抖音的API特别是涉及数据获取的几乎都带有签名参数最常见的是X-Bogus和_signature。这些参数由客户端根据请求URL、请求体、时间戳、设备信息等计算生成用于防止请求被伪造。对于我们这个“获取公开视频列表”的接口好消息是经过测试在一定条件下这个接口可能对签名校验不那么严格或者我们可以通过复用一套有效的参数来绕过。在抓包时你会看到类似这样的请求URLhttps://www.iesdouyin.com/web/api/v2/aweme/post/?sec_uidMS4wLjABAAAA...count20max_cursor0aid1128_signaturexxxxxx这里的_signature就是签名。我们的策略是直接复用从抓包到的成功请求中复制整个URL包括上面的_signature。然后我们只修改max_cursor和count参数来翻页而sec_uid和_signature保持不变。注意_signature很可能与sec_uid、max_cursor等参数绑定只修改max_cursor可能失效。需要测试。寻找无需签名的接口抖音有一些内部或旧的接口版本可能签名校验不严。这需要持续抓包和测试。终极方案如果上述方法失效则意味着必须逆向签名算法。这涉及到更复杂的JavaScript或Native代码逆向超出了本文的范畴。通常需要分析X-Bogus的生成逻辑网上有部分开源项目如douyin-signature尝试解决但需要自行维护更新。踩坑记录我最初尝试完全自己构造参数总是返回403。后来发现直接使用抓包获得的完整请求URL包含当时生成的_signature去请求竟然可以成功。这说明对于这个列表接口签名可能有一定的“会话”或“短期”有效性或者服务器端对来自同一设备标识Cookie的请求做了宽松处理。所以优先尝试“复制粘贴”大法。5. 实现视频列表的获取与分页假设我们已经通过“复用”策略获得了一个可以工作的请求模板。接下来就是编写代码循环请求直到获取所有视频。5.1 发起单次请求并解析数据我们首先实现获取第一页max_cursor0数据的函数。import requests import json import time def get_aweme_list_by_page(sec_uid, max_cursor0, count20): 获取用户指定页的视频列表 :param sec_uid: 用户sec_uid :param max_cursor: 分页游标第一页为0 :param count: 每页数量最大似乎为20 :return: 返回本次请求的JSON数据以及下一次的max_cursor # 这是从抓包中复制的完整URL模板包含了当时有效的_signature。 # !!! 注意你需要替换成自己抓包得到的、有效的URL !!! # 重点URL中的_signature参数是绑定的直接复用。我们只替换sec_uid, max_cursor, count。 url_template https://www.iesdouyin.com/web/api/v2/aweme/post/?sec_uid{}count{}max_cursor{}aid1128_signature你的_signature值 url url_template.format(sec_uid, count, max_cursor) headers { User-Agent: 你的抖音App User-Agent, Cookie: 你的有效Cookie, Accept: application/json, text/plain, */*, Host: www.iesdouyin.com, Connection: keep-alive, # ... 其他必要的headers } try: response requests.get(url, headersheaders, timeout10) response.raise_for_status() # 检查HTTP错误 data response.json() # 检查接口返回状态 if data.get(status_code) 0: aweme_list data.get(aweme_list, []) has_more data.get(has_more, 0) 1 next_max_cursor data.get(max_cursor, 0) return aweme_list, next_max_cursor, has_more else: print(f接口返回错误: {data}) return [], max_cursor, False except requests.exceptions.RequestException as e: print(f网络请求失败: {e}) return [], max_cursor, False except json.JSONDecodeError as e: print(fJSON解析失败: {e}, 响应内容: {response.text[:200]}) return [], max_cursor, False # 使用示例 sec_uid MS4wLjABAAAAvWZfxxxxxxxxxxxxxxxxxxxxxxxxxx videos, next_cursor, has_more get_aweme_list_by_page(sec_uid, max_cursor0, count20) if videos: print(f本页获取到 {len(videos)} 个视频) for video in videos: aweme_id video.get(aweme_id) desc video.get(desc) # 视频标题/描述 create_time video.get(create_time) statistics video.get(statistics, {}) digg_count statistics.get(digg_count, 0) # 点赞 comment_count statistics.get(comment_count, 0) # 评论 share_count statistics.get(share_count, 0) # 分享 print(f 视频ID: {aweme_id}, 描述: {desc[:30]}..., 点赞: {digg_count}) else: print(未获取到视频数据)5.2 实现自动分页抓取单次请求最多返回20条要获取全部视频需要根据has_more和max_cursor进行循环。def get_all_aweme_list(sec_uid, max_count100): 获取用户所有视频列表直到没有更多或达到限制 :param sec_uid: 用户sec_uid :param max_count: 最大获取视频数防止无限循环 :return: 视频列表 all_videos [] max_cursor 0 has_more True request_count 0 while has_more and len(all_videos) max_count: print(f正在请求第 {request_count 1} 页游标: {max_cursor}) videos, next_max_cursor, has_more get_aweme_list_by_page(sec_uid, max_cursor, count20) if videos: all_videos.extend(videos) print(f 本页获取 {len(videos)} 个累计 {len(all_videos)} 个) else: # 如果本次没拿到数据可能出错了谨慎考虑是否跳出循环 print( 本次请求未获取到数据可能已触达末尾或遇到风控。) # 可以选择 break 或重试逻辑 break max_cursor next_max_cursor request_count 1 # !!! 非常重要添加延迟避免请求过快触发风控 !!! time.sleep(2 random.random()) # 随机延迟2-3秒 print(f抓取结束。总共获取 {len(all_videos)} 个视频。) return all_videos # 使用示例 import random all_videos get_all_aweme_list(sec_uid, max_count200)5.3 解析与存储视频信息获取到的aweme_list中的每个视频对象结构非常丰富。除了上面示例中的基础信息还包括视频播放地址、封面图、音乐信息、作者信息、地理位置等。我们可以选择需要的字段进行提取和存储。import csv import os from datetime import datetime def save_videos_to_csv(video_list, filenamedouyin_videos.csv): 将视频列表保存到CSV文件 if not video_list: print(视频列表为空无需保存。) return # 定义要保存的字段 fieldnames [ aweme_id, desc, create_time, datetime, digg_count, comment_count, share_count, collect_count, video_url, cover_url, music_title, music_author, author_uid, author_nickname ] rows [] for video in video_list: # 处理可能不存在的字段 stats video.get(statistics, {}) author video.get(author, {}) music video.get(music, {}) video_info video.get(video, {}) # 转换时间戳 create_ts video.get(create_time, 0) if create_ts: dt_str datetime.fromtimestamp(create_ts).strftime(%Y-%m-%d %H:%M:%S) else: dt_str row { aweme_id: video.get(aweme_id, ), desc: video.get(desc, ).replace(\n, ).replace(,, ), # 处理换行和逗号 create_time: create_ts, datetime: dt_str, digg_count: stats.get(digg_count, 0), comment_count: stats.get(comment_count, 0), share_count: stats.get(share_count, 0), collect_count: stats.get(collect_count, 0), video_url: video_info.get(play_addr, {}).get(url_list, [])[0] if video_info else , cover_url: video_info.get(cover, {}).get(url_list, [])[0] if video_info else , music_title: music.get(title, ), music_author: music.get(author, ), author_uid: author.get(uid, ), author_nickname: author.get(nickname, ), } rows.append(row) # 写入CSV with open(filename, w, newline, encodingutf-8-sig) as f: # utf-8-sig支持Excel直接打开显示中文 writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(rows) print(f数据已保存至 {filename}共 {len(rows)} 条记录。) # 使用示例 save_videos_to_csv(all_videos, fdouyin_videos_{sec_uid[:10]}.csv)6. 风控应对与稳定性优化直接运行上面的代码很可能在抓取几十个视频后就会收到429 Too Many Requests或者403 Forbidden的响应。这是抖音反爬机制在起作用。我们必须让我们的爬虫行为更“像人”。6.1 请求频率控制这是最基本也是最重要的措施。固定延迟在每次请求之间加入sleep如time.sleep(3)。随机延迟更好的方法是使用随机延迟模拟人的不规则操作。例如time.sleep(2 random.random() * 3)延迟在2到5秒之间。分批次抓取如果需要抓取大量用户不要一个接一个不停。可以抓取一个用户后休息更长时间如5-10分钟。6.2 请求头与Cookie的维护Cookie失效从抓包获取的Cookie会过期。过期后需要重新抓包更新。可以编写一个简单的检测逻辑如果连续多次请求返回403或要求登录的JSON则提示用户更新Cookie。User-Agent池准备多个不同的、有效的抖音App User-Agent轮流使用降低单一标识被识别的风险。使用Session使用requests.Session()可以自动管理Cookie保持会话状态比单次请求更接近真实App行为。6.3 代理IP的使用如果请求频率过高可能会被限制IP。对于大规模抓取使用代理IP池是必要的。高质量代理选择可靠的HTTP/HTTPS代理服务。集成到代码中import requests proxies { http: http://your-proxy-ip:port, https: http://your-proxy-ip:port, # 注意很多代理服务器http和https都用http协议 } # 在请求时加入proxies参数 response session.get(url, headersheaders, proxiesproxies, timeout10)6.4 错误重试与降级策略网络请求总可能失败需要健壮的错误处理。重试机制对于网络超时、连接错误等临时性问题可以设置重试。可以使用tenacity库或自己实现简单的重试循环。import requests from tenacity import retry, stop_after_attempt, wait_fixed retry(stopstop_after_attempt(3), waitwait_fixed(2)) def safe_request(url, headers): response requests.get(url, headersheaders, timeout15) response.raise_for_status() return response识别风控响应如果返回429说明请求过快应该大幅延长等待时间如等待1分钟后再试。如果返回403且内容提示签名错误或需要登录则可能Cookie失效或签名策略已更新需要人工介入。保存进度在抓取大量数据时务必定期将已获取的数据保存到文件或数据库。即使程序中途因错误停止下次也可以从断点max_cursor继续避免前功尽弃。7. 常见问题与排查技巧实录在实际操作中你肯定会遇到各种各样的问题。下面是我踩过的一些坑和解决办法。7.1 问题返回403 Forbidden或{status_code: 2146, status_msg: 请求异常请稍后重试}可能原因1Cookie失效或无效。排查检查请求头中的Cookie值是否完整、最新。最直接的方法是用这个Cookie在浏览器中访问抖音网页版看是否处于登录状态。解决重新通过抓包获取有效的Cookie。可能原因2签名参数_signature无效或已过期。排查你复用的URL中的_signature可能是一次性的或者与sec_uid、max_cursor强绑定。尝试只修改max_cursor后是否失效。解决重新抓包一次列表请求获取全新的URL和_signature。如果频繁失效说明此接口签名校验严格需要考虑逆向签名算法或寻找其他替代接口。可能原因3请求头不完整或格式不对。排查对比你的请求头和抓包中的请求头确保关键字段如User-Agent,Host,Accept等一致。特别注意Cookie的格式是分号分隔的键值对。解决尽量复制抓包中看到的所有Headers不要遗漏。7.2 问题返回429 Too Many Requests可能原因请求频率过高触发了服务器的速率限制。解决立即停止当前循环等待一段时间比如5-10分钟再继续。增加请求间隔。将time.sleep的时间加长并加入更大的随机因子例如time.sleep(5 random.random() * 10)。考虑使用代理IP分散请求来源。7.3 问题能获取到数据但只有前几页has_more始终为 1但max_cursor不变化或循环可能原因max_cursor的处理逻辑有误或者接口在无更多数据时仍然返回has_more1。排查打印出每次请求返回的max_cursor和has_more。如果max_cursor不再变化但has_more还是1可能意味着已经到达末尾但接口设计如此。解决在循环中增加一个判断如果连续2-3次请求返回的max_cursor相同且获取到的视频列表为空或重复则判定为已抓取完毕主动跳出循环。7.4 问题如何获取更早的历史视频抖音的/aweme/post/接口通常只返回最近发布的视频具体数量可能有限比如最近1000条。要获取更早的、被“折叠”的视频通常需要登录账号后在App中触发“查看更多”操作并抓取那个过程中调用的另一个API。这个接口的权限和风控等级更高实现起来复杂得多。对于绝大多数分析需求最近几百条视频已经足够。7.5 问题视频数据中的播放地址无法直接下载或提示“视频不见了”视频的play_addr播放地址返回的URL通常带有鉴权参数有效期很短且可能检查Referer等Header。直接用在浏览器中可能过一段时间就失效。解决如果需要下载视频应该使用返回的video_id或aweme_id结合其他专门的无水印下载接口这又是另一个话题通常也需要签名。切勿在程序中高频访问视频播放地址这极易导致IP被封。最后再次强调技术是把双刃剑。这套方法能帮你高效地收集公开数据但务必尊重平台规则和用户隐私将数据用于合法、合规的用途。保持较低的请求频率避免对抖音服务器造成不必要的负担。在实际开发中最耗时的部分往往不是写代码而是与平台风控机制的“博弈”和参数的维护更新。保持耐心多测试多观察抓包数据的变化是成功的关键。