做抖音视频数据抓取这个事,说起来其实挺偶然。我一开始只是想把一个对标账号的几百条视频标题和播放量拉下来做分析,结果被分享链接的跳转机制、页面里嵌的JSON、接口返回的水印地址挨个折腾了一遍,硬是把一个“小脚本需求”变成了一个完整的爬虫项目。如果你也想抓抖音上的视频数据——不管是给自媒体运营做对标分析、给导师交一份短视频传播研究报告,还是单纯想把某个账号的作品批量备份下来,这篇文章应该是你需要的。
先说清楚这篇博文要讲什么:从抖音分享链接入手,拆解一条视频从短链接到最终文件落地的完整链路,然后给出一个可复用的Python脚本,把批量抓取、并发控制、数据存储和简单分析一次性讲透。适合有Python基础、想通过真实项目练手爬虫的读者,也适合运营和数据分析师拿来做自己的工具。整篇文章我不讲虚的,所有代码都能跑,所有思路都能复现,踩过的坑也一并摆出来。
1. 项目全貌:抖音数据抓取到底在抓什么
1.1 抓取目标和场景梳理
抖音数据抓取这个需求,听起来是一件事,实际上能拆成好几个完全不同的方向。有人要的是视频文件本身——比如把自己的作品批量下载备份,或者把某个账号更新的视频自动归档;有人要的是结构化数据——比如视频标题、发布时间、点赞数、评论数、分享数,用来做内容趋势分析;还有人想抓评论内容做舆情分析,甚至想监测某个话题下持续新增的视频。目标不同,技术路径差别很大。
我建议你在动手之前先想清楚:你最终要的是“文件”还是“数据”。要文件,核心链路是对视频资源地址的解析和下载;要数据,核心是拿到视频信息接口的返回并清洗存储。这两者前半段技术路径相同——都要先解析出视频ID、拿到作品信息——但后半段就分叉了,一个关注二进制流的保存,一个关注字段的抽取和入库。
我这里的例子是“数据+文件”都做的综合场景:抓取某个抖音账号主页的所有公开作品,保存视频标题、点赞数、评论数、发布时间等结构化信息,顺带把视频文件也下载到本地。这个需求覆盖了从接口分析、参数构造、响应解析、并发加速到数据落盘的全流程,做完之后,你对爬虫这条技术栈会有一个非常完整的认知。
1.2 技术路线选型:为什么是Python + requests
抖音数据抓取目前大致有三条路线,我把它整理成一张对比表,方便你按自己的情况选:
| 方案 | 优点 | 缺点 | 适用人群 |
|---|---|---|---|
| 第三方采集工具 | 上手快、操作可视化 | 收费、闭源、字段不全、不稳定 | 不想写代码的运营人员 |
| 浏览器自动化(Selenium/Playwright) | 能处理复杂交互、绕过大部分前端限制 | 速度慢、吃资源、DOM一改就崩 | 需要处理登录态或复杂页面时用 |
| Python + requests直接请求接口 | 轻量、快速、返回JSON结构干净 | 需要手动分析接口和参数 | 想学技术、做深度数据加工的人 |
我最终选的是第三条路线。原因很简单:它更接近爬虫技术的本质,学到的链路分析、参数补全、容错处理这些技能可以迁移到任何网站,而不是被某个特定工具绑死。而且Python生态里的requests、pandas对后面的数据处理也是天然支持,一个语言搞定抓取和分析两端,不用来回切换工具。
需要说明的是,浏览器自动化并非没有用武之地。如果你要抓的是需要登录才能看到的评论数据,或者要模拟点击“展开全部”这种交互操作,Selenium这类方案还是应备不时之需。我个人的习惯是“能用requests绝不开浏览器”,但真到了非用不可的场景,也不排斥混合使用。技术选型的核心是匹配需求,不是追求某种纯粹。
1.3 红线与边界:哪些能碰、哪些不能碰
这个必须单独拿出来说,因为做爬虫不是写出来的代码能跑就行的。我自己给自己定的规则有五条:
- 只抓公开数据,不碰需要登录态的私密接口;
- 频率控制严格,单账号串行请求间隔不低于2秒,并发不超过5;
- 抓下来的数据只用于个人学习、内容备份和统计分析,不售卖、不用于任何商业黑产;
- 不绕过平台的风控机制,不做签名逆向的暴力破解;
- 尊重内容版权和用户隐私,视频素材二次传播前先确认授权。
遵守这些边界,一方面是为了合规安全,另一方面也是降低IP被封、账号受牵连的风险。抖音的Web端对无登录态的请求限制并不算极端,克制使用,完全可以稳定运行。很多人一上来就追求“全网数据”、“全量采集”,这种心态本身就容易把自己玩进去——爬虫项目做得越久,我越觉得合理设定边界不是妥协,而是让项目能长期跑下去的前提。
2. 核心原理拆解:从分享链接到视频文件的完整链路
2.1 短链接跳转与视频ID提取
抖音分享出去的一般是v.douyin.com开头的短链接,比如https://v.douyin.com/xxxxx/。这种短链接实际上是一个跳板,requests请求后会自动重定向,最终落到一个形如https://www.iesdouyin.com/share/video/{视频ID}/的页面。视频ID就是一串19位左右的数字,它是抖音视频在平台内的唯一标识,后续所有操作都要围绕它展开。
这里有一个关键点:短链接的解析必须依赖真实的浏览器UA。如果你用默认的python-requestsUA去请求,短链接可能会跳到验证页面而不是目标视频页。所以第一步先设置好请求头:
import re import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://www.douyin.com/", } def resolve_share_url(share_url: str) -> str: resp = requests.get(share_url, headers=HEADERS, allow_redirects=True, timeout=10) return resp.url def extract_video_id(real_url: str) -> str: match = re.search(r"/video/(\d+)", real_url) if not match: raise ValueError(f"无法从链接中提取视频ID: {real_url}") return match.group(1)注意allow_redirects=True这个参数,requests默认就会跟随重定向,但显式写出来能让代码意图更清晰。短链接跳转过程中可能经历2到3次302,最终落点就是我们需要的分享页。整个解析过程大概100毫秒,非常快。
2.2 页面内嵌JSON的解析思路
拿到了视频ID后,直接访问分享页面,你会发现页面的<script>标签里内嵌了一段名为_ROUTER_DATA的JSON数据。这里面包含了视频的标题、封面、播放量、点赞数、评论数等大量信息。用正则把这一段提取出来再JSON解析就行,不需要任何签名参数,因为这是服务端渲染的静态数据,纯粹靠普通的GET请求就能拿到。
具体做法是:先请求分享页面,然后匹配window._ROUTER_DATA =后面的JSON内容。这里要小心的是,JSON字符串可能包含嵌套的</script>标签干扰正则匹配,所以用re.S标志让.匹配换行符,并且匹配到最后一个</script>为止:
def fetch_video_info(video_id: str) -> dict: share_url = f"https://www.iesdouyin.com/share/video/{video_id}" resp = requests.get(share_url, headers=HEADERS, timeout=10) html = resp.text match = re.search(r"window\._ROUTER_DATA\s*=\s*(\{.*?\})\s*</script>", html, re.S) if not match: raise ValueError("页面中未找到_ROUTER_DATA数据") router_data = json.loads(match.group(1)) # 从嵌套结构中提取视频信息 video_info = router_data["loaderData"]["video_(id)/page"]["videoInfoRes"]["item_list"][0] return video_info这个返回的video_info字典非常丰富,除了基本标题、描述,还包括statistics字段下的播放数、点赞数、评论数、分享数,以及video字段下的封面、时长、视频地址等信息。拿到它,相当于拿到了一个视频的半结构化全集。
2.3 无水印地址的定位逻辑
在video_info里,视频资源地址通常出现在video.play_addr字段,里面的url_list会包含多个视频流地址。默认分享地址一般带水印,特征是在地址路径里能找到playwm这个关键字。去掉水印的一个常见技巧,是把playwm替换成play,返回的地址就是无水印版。
这个逻辑算是行业内公开的小技巧。我实际验证过,替换后的地址确实能正常下载,而且视频时长和分辨率与原视频完全一致。要注意的是,拿到地址后不要直接存起来第二天再用——这些资源地址通常有时效性,可能是几小时也可能是几分钟,所以我每次下载前都会重新解析一次链接,而不是缓存URL。
还有一种情况是接口返回的play_addr本身就是无水印的,而带水印版本在video字段下的download_addr里。不同时期、不同端返回的结构会有差异,所以写代码时不要写死字段路径,尽量封装一个“从多个可能的层级中查找目的字段”的函数,这样平台小改时脚本不至于立刻失效。
2.4 User-Agent与请求头伪装背后的逻辑
爬虫被封的大多数原因不是请求太多,而是请求头太像“机器人”。抖音Web端会在服务端校验UA、Referer、Cookie等字段。我的习惯是:把一个真实的Chrome UA复制下来,Referer设为来源页,如果需要登录态的接口再加Cookie,然后把常见浏览器特性如Accept、Accept-Language也一并带上。
这里有一个容易忽略的细节:UA里包含的浏览器版本号和系统信息要匹配。比如你UA写的是Chrome 120,但Accept字段还是老旧的写法,服务端会根据这种不一致判断为异常请求。更稳妥的做法是直接复制浏览器Network面板里的完整请求头,原样带入Python代码。
当然,这做法的本质是让请求看起来像一个普通用户在浏览网页,而不是绕过平台去做坏事。配合后面的限速策略,能极大减少触发风控的几率。我的经验是:与其花大量精力在伪造请求头上,不如在请求频率上多下功夫,后者才是长期稳定运行的关键。
3. 实操落地:一个可复用的抖音视频抓取脚本
3.1 环境准备与工程结构
先建一个干净的项目目录,我的习惯是按功能拆分文件,而不是把几百行代码塞在一个文件里:
douyin_crawler/ ├── requirements.txt ├── main.py # 入口脚本 ├── douyin.py # 核心抓取逻辑 └── data/ # 存放抓取结果requirements.txt就三个库:requests、pandas、retry。其中retry不是必须,但加上之后,网络抖动时的重试逻辑会好写很多——下载视频这种长耗时任务,遇到一次连接重置就前功尽弃,重试机制几乎是必备的。安装命令:
pip install requests pandas retrypandas在这个项目里的角色是数据落盘和分析。如果你只需要下载视频不关心统计数据,去掉pandas也可以,但既然都做了爬虫,顺手把结构化数据留下来,后面做分析时你会庆幸当时多写了这行代码。
3.2 分享链接解析器的完整实现
实际上把2.1的代码整理一下,再加上异常处理就能用。我做的时候额外加了一个输入校验:用户直接粘贴分享文案(就是那种带中文的整段文案)时,也要能从里面提取出URL。做法很简单,用正则https?://[^\s]+匹配第一段链接就行:
def extract_url_from_text(text: str) -> str: match = re.search(r"https?://[^\s]+", text) if not match: raise ValueError("文本中未找到链接") return match.group(0).rstrip("。,,;;")这个函数虽然简单,但在实际使用中非常实用。别人发给你一段分享文案,不会贴心地只给你一个干干净净的URL,而是带前后缀的完整文案。有了这个提取函数,整个脚本的易用性一下子就上来了。
接下来是主流程的封装。我觉得爬虫脚本的设计核心是“可组合”——每个函数只做一件事,然后把它们按顺序串起来。这样即使抖音改版导致某个环节失效,你只需要修对应的函数,而不必动整个主流程。
def parse_video(share_text: str) -> dict: share_url = extract_url_from_text(share_text) real_url = resolve_share_url(share_url) video_id = extract_video_id(real_url) video_info = fetch_video_info(video_id) return { "video_id": video_id, "title": video_info.get("desc", ""), "video_url": extract_playable_url(video_info), "stats": video_info.get("statistics", {}), }3.3 视频文件下载与进度展示
信息提取之后就是下载。这里我建议用流式下载,而且最好加上超时和重试机制。很多新手写下载代码容易犯的错误是直接用resp.content一次性读完,视频文件动辄几十MB,内存占用会直接爆炸。正确的做法是用stream=True分块写入:
def download_video(url: str, filename: str) -> None: resp = requests.get(url, headers=HEADERS, stream=True, timeout=30) if resp.status_code != 200: raise RuntimeError(f"下载失败,状态码={resp.status_code}") total_length = int(resp.headers.get("content-length", 0)) downloaded = 0 with open(filename, "wb") as fp: for chunk in resp.iter_content(chunk_size=1024 * 256): fp.write(chunk) downloaded += len(chunk) if total_length: percent = downloaded / total_length * 100 print(f"\r下载进度: {percent:.1f}%", end="") print()iter_content(chunk_size=1024*256)这里我选的是256KB,这个值兼顾了内存占用和写入效率。太小的chunk会导致频繁IO,太大会浪费内存。如果你下载的是高清长视频,可以适当调大chunk_size,但256KB在多数场景下表现都很好。文件名建议用视频ID加标题的组合,比如{video_id}_{title}.mp4,这样既不会重复,又能一眼看出是哪个视频。
3.4 批量抓取与数据落盘
批量抓取用户主页作品时,一个绕不开的环节是获取作品ID列表。抖音主页是滚动加载的,直接抓HTML只能拿到前几个视频。更可靠的方式是通过用户主页的sec_uid参数请求接口,但这个接口涉及签名校验,不太适合在入门案例里展开。
我给一个更稳妥的思路:对于个人学习和中小规模场景,先手动把目标账号主页的分享链接收集起来,放进一个文本文件,脚本读取后逐个解析。这样虽然不够“全自动”,但胜在稳定可靠,而且代码结构完全复用前面已经写好的环节。等你把整个链路跑通了,再去研究签名算法、构造接口请求,会有更清晰的方向感。
数据落盘用pandas一行代码就搞定:
import pandas as pd df = pd.DataFrame(result_list) df.to_csv("data/video_info.csv", index=False, encoding="utf-8-sig")注意编码选utf-8-sig而不是utf-8,否则CSV用Excel打开时中文会乱码。这个小坑我踩过,现在每次写CSV都会带上。存完CSV之后,如果需要做更多数据分析,还可以顺手存一份SQLite,后面查数据会非常方便。
4. 并发设计与效率优化
4.1 并发到底该不该上
很多人一上来就想写多线程,觉得速度就是一切。但爬虫场景里,并发越高,IP被限制的概率也越高。我用一个简单的类比来说:你一个人在图书馆连续借100本书,管理员不会觉得奇怪;但如果100个人同时冲进来借书,保安就会注意到。抖音的风控体系也是类似的逻辑,单位时间内来自同一IP的请求数量有一个隐形的上限。
所以我的结论是:并发要上,但要克制。个人学习场景,把并发控制在5以内,单请求之间的延迟设在1到2秒,既能保证抓取速度,又不会触发风控。如果你有多个代理IP可以做负载均衡,并发可以适当调高,但那就涉及代理池的建设和维护,不是入门项目该碰的东西。
4.2 并发方案对比:ThreadPoolExecutor、asyncio到底选哪个
Python里有三种常见并发方案,我分别说说我的判断:
- threading + ThreadPoolExecutor:最简单,适合IO密集型任务,视频下载这种场景效果很好。因为GIL的存在,CPU密集型任务用多线程没什么优势,但网络请求是IO密集型,线程在等待网络响应时会释放GIL,所以多线程是够用的。
- asyncio + aiohttp:更高性能,单线程内做异步IO,并发可以开得很大。但代码复杂度上升,需要对异步有足够的掌控力,出错时排查起来也更费劲。
- 分布式爬虫框架如Scrapy:适合生产级需求,自带去重、调度、并发控制,但对个人小项目来说,架构成本太高,属于杀鸡用牛刀。
我实际用的是ThreadPoolExecutor,配合semaphore限制并发数。核心代码就十几行:
from concurrent.futures import ThreadPoolExecutor, as_completed import threading semaphore = threading.Semaphore(5) # 最多同时5个请求 def safe_parse(item): with semaphore: try: return parse_video(item) except Exception as e: print(f"解析失败: {item}, 错误: {e}") return None with ThreadPoolExecutor(max_workers=5) as executor: futures = [executor.submit(safe_parse, item) for item in share_links] for future in as_completed(futures): result = future.result() if result: result_list.append(result)实测下来,下载100个视频,串行约15分钟,5线程并发能压到4分钟左右,效率提升了近4倍,同时没有触发任何风控。这个效果对于个人项目来说已经非常理想了。
4.3 限速、超时与异常处理
并发代码里最容易出问题的是异常处理。一个线程下载失败,不能让任务直接崩掉。我的做法是给每个task包一层try/except,失败后记录日志并重试最多3次,重试间隔用指数退避——第一次等1秒,第二次等2秒,第三次等4秒,这样能有效避免在服务端还在限流时反复硬撞。
另外,requests请求务必设置timeout。不设置timeout的请求可能在网络异常时挂死,线程池直接满员卡住,整个爬虫就假死了。这个坑我遇到过一次,那时候排查了半天,最后发现是某个视频地址响应超时导致的线程阻塞。后来我写了个规矩:所有HTTP请求必须带timeout=(connect_timeout, read_timeout),connect超时设3秒,read超时设10秒,这样即使网络异常,也能快速失败并重试。
还有一个小细节:下载视频文件时,如果遇到服务器返回206 Partial Content(断点续传),不要当作错误处理。requests在流式下载某些CDN资源时会自动处理这种情况,正常的200和206都应当视为下载成功。
5. 数据清洗、存储与可视化分析
5.1 结构化存储的设计
抓下来的数据通常包含:视频ID、标题、发布时间、时长、播放数、点赞数、评论数、分享数、视频本地路径。把这些字段整理成DataFrame后,可以做无数有意思的分析。我建议把原始数据统一存成CSV或者SQLite,一方面方便回看,另一方面方便后续分析时反复读取。
SQLite我觉得更好用,存储结构化数据的密度高,查询也方便:
import sqlite3 conn = sqlite3.connect("data/douyin.db") df.to_sql("t_video", conn, if_exists="append", index=False) conn.close()如果你担心重复运行脚本时数据重复入库,可以在处理前先查一下已有ID集合:
existing_ids = set() if check_first_run: existing_ids = set(pd.read_sql("SELECT video_id FROM t_video", conn)["video_id"]) # 在写入前通过video_id去重 df = df[~df["video_id"].isin(existing_ids)]这个去重逻辑看着简单,实际用起来非常高频。爬虫有一个重要原则:支持断点续跑。脚本跑到一半网络故障、中途停机,重启后不应该从头再来,去重逻辑就是实现这个能力的基础。
5.2 基础统计案例:200条视频透露了什么
有一次我抓了一个生活类账号的200条作品,简单统计了一下,得出几个有意思的结论:
- 平均播放量约5.2万,中位数只有1.8万,说明头部内容拉高了平均值,大部分视频的表现其实一般;
- 时长在30到45秒的视频平均播放量明显高于1分钟以上的长视频;
- 发布时间在晚上7点到10点的视频,互动率平均高出42%。
这些结论看起来很朴素,但如果不抓数据,光靠感觉是拿不到这些判断依据的。这也是抖音数据抓取最大的价值:让运营决策从拍脑袋变成有数据支撑。你甚至可以进一步做回归分析,看哪些因素(标题长度、话题数量、视频时长)对播放量有显著影响,这些分析在学术研究、新媒体运营领域都有实际应用场景。
5.3 可视化展示与进阶挖掘
用matplotlib画了播放量和发布时间的关系图之后,可以很直观地看到“黄金时段”效应。如果数据量足够大,还能做文本分析,比如对视频标题做jieba分词,统计高频词,分析账号的选题倾向。
我个人的进阶路线是这样的:第一阶段做描述性统计,柱状图、直方图、热力图;第二阶段做相关性分析,播放量和发布时间、标题关键词的关联;第三阶段引入机器学习,比如根据标题文本和发布时间预测播放量区间。每一步都建立在前一步的数据基础上,而爬虫就是这条链路的第一环。不要觉得这些分析“太简单”,能把简单的分析做扎实,已经能甩开大部分凭感觉写内容的人。
6. 高频问题与避坑实录
6.1 问题速查表
以下是做抖音视频数据抓取时最容易遇到的高频问题,我做成了速查表,方便你排错时直接对照:
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
| 请求返回403 | UA没设置或太旧 | 换成最新Chrome UA,参考Network面板的完整请求头 |
| 下载的地址失效 | 视频地址有时效性 | 下载前重新解析获取,不要缓存URL |
| CSV打开中文乱码 | 编码错误 | 写入时用utf-8-sig编码 |
| 线程池卡死 | 没设置timeout | requests统一加timeout参数 |
| 视频只有声音没画面 | 误用了纯音频地址 | 检查字段,确认是play_addr而不是play_addr_audio |
| 解析返回空数据 | 分享页结构变了 | 打开页面检查_ROUTER_DATA的字段路径是否更新 |
| IP疑似被限制 | 请求频率过高 | 停止运行12到24小时,降低并发数 |
6.2 几个容易踩的坑
第一个坑是依赖登录态的接口不要碰。抖音很多接口需要携带sessionid之类的Cookie才能调用,一旦涉及登录态,你的账号风险就会上升——轻则接口返回异常,重则账号被限制功能。个人学习场景里,所有的数据完全可以通过公开页面获取,根本不需要登录。如果你发现某个字段必须在登录后才能看到,我的建议是放弃这个字段,而不是想着怎么伪造登录态。
第二个坑是视频文件去重。批量下载时很容易重复下载同一个视频,尤其是同一个视频被多个话题收录时。我建议下载前先检查文件名——用视频ID做文件名的一部分,天然可以避免重复,因为同一个视频的ID是唯一的。这个简单的约定省了我很多事,至少不会出现一个视频下载两遍还占用存储空间的情况。
第三个坑是反爬的“软限制”。抖音不太会直接封IP,但会在一段时间内对某个IP返回空数据或者验证页。出现这种情况,最有效的办法是停下来等一段时间,而不是加大并发硬刚。我试过硬刚的结果,结果就是被限流的窗口越来越长。反而是老老实实等上半天,之后恢复正常的概率很高。
第四个坑是页面结构的频繁变动。抖音的Web端是典型的前后端分离架构,页面里的JSON结构和字段命名经常调整。今天能解析的字段,下个月可能就变了。应对方案是不要过度依赖精确定位,写解析函数时尽量做字段容错——用dict.get()而不是直接dict["key"],这样字段缺失时不会直接抛异常,而是返回默认值。同时保留原始的响应日志,方便改版后快速定位问题。
还有一个看似不起眼但实际上很影响体验的坑:视频下载的顺序。如果你的脚本是边解析边下载,那么下载耗时长的视频会阻塞后续解析任务的提交。我建议把流程分成两步:第一步全部解析并保存元数据,第二步根据元数据统一下载。这样即使某个视频下载失败,也不影响其他数据的抓取,重跑下载任务时只需要加载已保存的元数据即可。
提示:做数据抓取时始终保持敬畏心。技术本身是中性的,但使用技术的方式决定了它的价值。定期审视自己的爬虫是否给目标平台带来了过大的压力,是否侵犯了内容创作者的权益,这是每一个爬虫开发者的基本素养。
最后再分享一个小技巧:抓取之前先在浏览器里手动访问一次目标页面,把Network面板里的完整请求复制成cURL,再转换成Python代码。这样你能拿到最新的接口结构和参数格式,比对着旧教程硬凑Headers要省事得多。
这个项目做完之后,我的最大感触是:抖音数据抓取真正锻炼的不是“抓”本身,而是面对一个黑盒系统时的拆解能力。从一条短短的分享链接出发,一步步追踪跳转、解析页面、定位字段、下载文件,最后落到本地数据库里形成结构化资产——这套方法论完全适用于任何网站的数据获取需求。对我个人而言,做完这个项目之后,再去看其他网站的数据抓取需求,基本就是换汤不换药了。与此同时,我也更清楚技术使用的边界在哪里,踩过几次坑之后才意识到,克制和自律,才是让爬虫项目长期稳定运行的真正秘诀。