抖音用户视频批量下载:基于Sec_UID的稳定爬虫方案与工程实践
1. 项目概述从用户ID到本地视频的路径解析最近在和一些做内容分析的朋友聊天发现大家有个共同的痛点想研究某个特定抖音创作者的视频风格、内容演变或者进行一些合规的本地存档但面对平台上浩如烟海的视频手动一个个保存效率太低而且很多工具并不稳定。他们最常问我的就是“如果我只知道这个博主的抖音ID有没有办法能把他/她所有的视频都下载下来” 这确实是个高频且实际的需求无论是用于个人学习、内容备份还是在不侵犯版权的前提下进行有限的本地数据分析比如研究视频标题的用词规律、发布频率等一个可靠的根据用户ID下载视频的方法都显得非常必要。简单来说这个项目的目标就是实现一个自动化流程输入一个有效的抖音用户ID也就是常说的UID或Sec_UID程序能够自动识别该用户遍历其发布的所有视频作品并将这些视频文件以无水印、高质量的形式保存到本地。这听起来像是一个简单的爬虫任务但实际操作过的人都知道抖音的反爬机制相当复杂接口变动频繁单纯靠一个静态的请求很难长期稳定工作。因此我们需要构建一个相对健壮的方案它不仅要能拿到视频地址还要能应对页面结构变化、签名验证等问题。接下来我会把自己在实际操作中验证过的一套相对稳定的方法拆解给你包括核心思路、工具选择、具体步骤以及必然会遇到的坑和解决办法。2. 核心思路与技术方案选型实现根据用户ID下载视频核心逻辑链条可以概括为获取用户信息 - 获取作品列表 - 提取单个作品信息 - 获取视频播放地址 - 下载视频文件。但每一步都暗藏玄机不同的技术选型直接决定了方案的稳定性、效率和可维护性。2.1 方案对比Web端、移动端与混合模式目前主要有三种技术路径各有优劣模拟Web端请求这是最直接的想法通过分析抖音网页版douyin.com的接口。它的优势是开发调试方便在浏览器开发者工具里就能看到所有网络请求。但缺点极其明显抖音对Web端的反爬最为严格接口参数加密复杂如_signature且更新非常频繁。你可能今天跑通的代码明天就因为一个签名算法变动而完全失效。这对于需要长期稳定运行的任务来说维护成本太高。模拟移动端App请求这是目前相对更稳定的方案。通过抓包分析抖音官方AppAndroid/iOS发出的网络请求模拟其请求头、参数和签名。App端的接口虽然也有加密但其协议相对Web端更为稳定变更周期稍长。核心难点在于如何获取到有效的请求令牌如msTokenX-Bogus等以及处理其他反爬参数。这种方法需要一定的逆向工程能力。无头浏览器自动化使用像 Puppeteer 或 Playwright 这样的工具完全模拟一个真实用户的操作打开用户主页滚动页面让浏览器正常加载和渲染。然后从页面元素中提取视频地址。这种方法的优点是“所见即所得”完全绕过接口加密因为你是从最终渲染的页面里拿数据。缺点是速度慢、资源消耗大每个页面都要加载完整的JS、图片等并且同样要应对抖音对自动化工具的检测如WebDriver检测。我的选择与理由 经过多次踩坑我倾向于采用一种“混合模式”。即以移动端接口为基础辅以轻量级的浏览器自动化作为备用和验证手段。具体来说主要逻辑通过逆向分析App接口实现以追求效率和稳定性。同时编写一个备用的Selenium脚本当主接口因变动暂时失效时可以快速切换通过模拟滑动来获取数据保证任务不中断。这是一种务实的选择既考虑了效率也兼顾了鲁棒性。2.2 关键工具与库的选择工欲善其事必先利其器。以下是核心工具栈我会解释为什么选它们编程语言Python 3.8。生态丰富网络请求、数据处理、自动化相关的库非常齐全是完成此类任务的绝佳选择。HTTP请求库requests搭配httpx(可选)。requests简单易用足以应对大部分场景。如果遇到需要处理更复杂异步或HTTP/2请求的情况httpx是更好的选择。浏览器自动化selenium和undetected-chromedriver。selenium是标准。关键在于undetected-chromedriver它能有效规避一些网站对自动化驱动的检测对于抖音这种有反爬的站点至关重要。数据解析json(内置)re(正则表达式内置)BeautifulSoup4或lxml。接口返回的数据通常是JSON直接用内置库解析。备用方案从HTML页面提取时需要用到HTML解析器。代理IP池重要无论是调用接口还是自动化浏览器高频访问单一目标必定会触发风控导致IP被封。一个可靠的代理IP池是项目能持续运行的生命线。可以选择付费的代理服务或者自建基于ADSL拨号切换的动态IP池。参数生成与逆向工具进阶如果需要深入破解移动端加密可能会用到frida(动态插桩)、Charles/Fiddler(抓包)、以及一些反编译工具。这对初学者门槛较高初期可以尝试寻找和维护开源的签名算法库。注意任何涉及爬取公开数据的行为都必须严格遵守网站的robots.txt协议尊重版权仅将数据用于个人学习、研究或法律允许的合理使用范畴不得用于商业侵权、 spam 或其他非法用途。频繁、大量的请求会对目标服务器造成压力务必设置合理的请求间隔如每次请求后 sleep 2-5秒。3. 核心环节实现与步骤拆解下面我将按照“混合模式”的思路详细拆解每一步的操作。我们会先实现主方案移动端接口再准备备用方案浏览器自动化。3.1 第一步如何获取目标用户的Sec_UID这是整个流程的起点。你在抖音上看到的用户主页链接或者分享出来的名片里面包含的并不是直接的“用户ID”而是一个叫sec_uid的关键参数。这是一个长字符串是抖音内部用于标识用户的唯一ID。获取方法有多种从分享链接中提取最常用让用户分享他的抖音主页给你。链接格式通常像https://v.douyin.com/xxxxxxx/。访问这个短链接它会跳转到一个长链接长链接中就会包含sec_uid。例如跳转后URL可能为https://www.douyin.com/user/MS4wLjABAAAA...?sec_uidMS4wLjABAAAAxxxxxxxxxxxxxxxx 这个MS4wLjABAAAAxxxxxxxxxxxxxxxx就是我们要的sec_uid。实操技巧使用requests访问短链接时设置allow_redirectsFalse来禁止自动跳转然后从返回的响应头Location字段中提取跳转后的长URL再用正则表达式解析出sec_uid。import re import requests def get_sec_uid_from_share_url(share_url): headers { User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1 } # 禁止重定向获取跳转地址 resp requests.get(share_url, headersheaders, allow_redirectsFalse) if resp.status_code in [301, 302]: redirect_url resp.headers[Location] # 使用正则匹配 sec_uid match re.search(rsec_uid([^]), redirect_url) if match: return match.group(1) return None # 示例用法 share_url https://v.douyin.com/l0mkff1aews/ sec_uid get_sec_uid_from_share_url(share_url) print(f获取到的 sec_uid: {sec_uid})通过网页搜索接口备用如果你只知道用户的昵称可以尝试用抖音的搜索接口。但搜索接口同样有反爬且结果可能不精确。更推荐使用第一种方法。3.2 第二步模拟移动端接口获取作品列表拿到sec_uid后下一步是获取该用户的所有作品列表。抖音移动端有一个用于查询用户发布作品的接口。通过抓包分析我们可以找到这个接口的URL模式和必要参数。一个常见的接口模式是https://www.iesdouyin.com/web/api/v2/aweme/post/?sec_uid{sec_uid}count20max_cursor0aid1128_signaturexxxxxxsec_uid: 我们上一步获取的。count: 每次请求返回的作品数量通常最大为20。max_cursor: 分页游标第一次请求为0下次请求使用接口返回的max_cursor值。_signature: 这是一个动态生成的签名是最大的难点。它由客户端生成用于验证请求的合法性。签名算法可能会被封装在App的JavaScript或原生代码中。如何应对签名_signature这是本项目最大的技术壁垒。有几种应对策略按难度递增策略A使用现成的开源库。在GitHub等平台搜索douyin signature或douyin x-bogus可能会有一些社区维护的算法实现例如用Python重新实现的JavaScript加密逻辑。这是最快的方式但依赖社区更新一旦抖音算法变更需要等待库作者更新或自己修复。策略BRPC远程过程调用或外部服务。有些方案是将签名生成部分部署在一个独立的服务甚至是一个无头浏览器环境中主程序通过调用这个服务来获取签名。这样可以将易变的加密逻辑隔离。策略C完全逆向。这是最硬核的方式需要逆向抖音App定位到签名生成函数并用Python移植。这需要深厚的逆向工程功底不适合大多数人。对于大多数开发者我建议从策略A开始寻找并测试可用的开源方案。同时一定要为签名失效准备好降级方案即我们的备用浏览器自动化方案。假设我们通过某种方式获得了有效的签名那么获取作品列表的代码框架如下import requests import json import time def fetch_user_aweme_list(sec_uid, signature_generator, max_count100): 获取用户作品列表 :param sec_uid: 用户sec_uid :param signature_generator: 一个能生成_signature的函数或对象 :param max_count: 最大获取作品数 :return: 作品信息列表 aweme_list [] max_cursor 0 has_more True # 移动端请求头 headers { User-Agent: Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36, Accept: application/json, Accept-Language: zh-CN,zh;q0.9, Referer: https://www.douyin.com/, } while has_more and len(aweme_list) max_count: # 构建请求参数 params { sec_uid: sec_uid, count: 20, max_cursor: max_cursor, aid: 1128, _signature: signature_generator.generate(sec_uid, max_cursor) # 假设的签名生成方法 } try: resp requests.get( https://www.iesdouyin.com/web/api/v2/aweme/post/, paramsparams, headersheaders, proxiesyour_proxies, # 务必使用代理 timeout10 ) resp.raise_for_status() data resp.json() if data.get(status_code) 0: aweme_list.extend(data.get(aweme_list, [])) has_more data.get(has_more, 0) 1 max_cursor data.get(max_cursor, 0) print(f已获取 {len(aweme_list)} 个作品has_more: {has_more}) else: print(f接口返回错误: {data}) break except Exception as e: print(f请求失败: {e}) break time.sleep(2) # 重要请求间隔避免过快 return aweme_list[:max_count]3.3 第三步从作品信息中提取无水印视频地址成功获取作品列表aweme_list后每个作品aweme都是一个包含大量信息的JSON对象。我们的目标是找到视频的最高质量、无水印的下载地址。关键字段解析aweme_id: 作品的唯一ID。desc: 作品描述标题。video: 视频信息对象。play_addr: 播放地址通常带水印。这是一个URL列表里面有不同的清晰度。download_addr: 下载地址也可能带水印。同样是列表。无水印地址的奥秘经过分析抖音的无水印视频地址其实隐藏在play_addr或download_addr的URL中。你需要将URL中的某个特定路径片段进行替换。例如一个常见的带水印地址可能是https://aweme.snssdk.com/aweme/v1/playwm/?video_idxxxxxx。将/playwm/替换为/play/ 即https://aweme.snssdk.com/aweme/v1/play/?video_idxxxxxx 用这个新地址去请求返回的往往就是无水印的视频流。但请注意这个规则并非永恒不变抖音可能会调整。另一种更可靠的方式在video对象下寻找play_addr的url_list 其中可能直接包含一个没有playwm的URL。需要仔细遍历和检查数据结构。提取函数示例def extract_video_url(aweme_info): 从单个作品信息中提取最佳的无水印视频URL video_info aweme_info.get(video, {}) # 优先级1: 尝试从play_addr中构造无水印URL play_addr video_info.get(play_addr, {}) url_list play_addr.get(url_list, []) if url_list: # 取第一个URL尝试替换 url url_list[0] # 替换 playwm 为 play (这是一个经验规则可能失效) no_watermark_url url.replace(/playwm/, /play/) # 有时需要替换整个域名或路径这里只是一个示例 # 更稳健的做法是直接寻找包含‘/play/’的url return no_watermark_url # 优先级2: 尝试download_addr download_addr video_info.get(download_addr, {}) url_list download_addr.get(url_list, []) if url_list: # 同样尝试处理但download_addr本身也可能带水印 url url_list[0] # 可能需要不同的替换规则或直接使用 return url # 如果以上都没找到返回None或尝试其他字段 return None3.4 第四步下载视频文件并组织存储拿到最终的视频URL后下载就相对简单了。使用requests流式下载即可。这里需要注意设置请求头模拟手机访问否则可能返回错误或低质量视频。import os from urllib.parse import quote def download_video(video_url, aweme_info, save_dirdownloads): 下载视频到本地 if not video_url: print(f无效的视频URL跳过作品: {aweme_info.get(aweme_id)}) return False # 创建保存目录 os.makedirs(save_dir, exist_okTrue) # 生成文件名使用描述和aweme_id避免特殊字符 desc aweme_info.get(desc, 无标题)[:50] # 截取前50字符 aweme_id aweme_info.get(aweme_id, unknown) # 清理文件名中的非法字符 safe_desc .join([c for c in desc if c.isalnum() or c in ( , -, _)]).rstrip() filename f{safe_desc}_{aweme_id}.mp4 filepath os.path.join(save_dir, filename) # 移动端请求头 headers { User-Agent: Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36, Accept: */*, Accept-Encoding: identity, # 注意避免压缩否则文件大小不对 Connection: keep-alive, } try: print(f开始下载: {filename}) resp requests.get(video_url, headersheaders, streamTrue, proxiesyour_proxies, timeout30) resp.raise_for_status() total_size int(resp.headers.get(content-length, 0)) downloaded 0 with open(filepath, wb) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk) downloaded len(chunk) # 可以在这里添加进度显示 print(f下载完成: {filename} ({downloaded / 1024 / 1024:.2f} MB)) return True except Exception as e: print(f下载失败 {filename}: {e}) # 如果文件可能不完整删除 if os.path.exists(filepath): os.remove(filepath) return False3.5 第五步备用方案——Selenium浏览器自动化当主接口因签名失效无法工作时备用方案必须能立刻顶上。这个方案不依赖接口而是模拟真人操作。核心步骤使用undetected-chromedriver启动一个“隐身”的Chrome浏览器。访问目标用户主页例如https://www.douyin.com/user/{sec_uid}。通过执行JavaScript脚本不断模拟滚动页面触发视频加载。从页面源码中解析出视频数据。抖音网页版会将视频数据放在一个idRENDER_DATA的script标签中内容是一个URL编码后的JSON字符串解码后即可获得类似接口返回的数据。同样从中提取视频地址并下载。示例代码框架from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import undetected_chromedriver as uc import json import urllib.parse import time def fetch_via_selenium(sec_uid, max_scroll10): driver None try: options uc.ChromeOptions() options.add_argument(--disable-blink-featuresAutomationControlled) driver uc.Chrome(optionsoptions) url fhttps://www.douyin.com/user/{sec_uid} driver.get(url) time.sleep(5) # 等待页面加载 all_aweme_infos [] for i in range(max_scroll): # 1. 尝试从RENDER_DATA中提取 try: script_element driver.find_element(By.ID, RENDER_DATA) encoded_data script_element.get_attribute(textContent) decoded_data urllib.parse.unquote(encoded_data) page_data json.loads(decoded_data) # 这里需要根据实际JSON结构导航到作品列表路径可能类似 page_data[app][videoList] # 提取 aweme_info 并添加到 all_aweme_infos # (具体路径需要实时分析页面结构) except Exception as e: print(f从RENDER_DATA解析失败: {e}) # 2. 模拟滚动加载更多 driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) time.sleep(3) # 等待新内容加载 # 3. 也可以尝试直接从页面上的视频卡片元素中获取 data 属性 # video_elements driver.find_elements(By.XPATH, //div[data-e2euser-post-item]) # for elem in video_elements: # video_info_str elem.get_attribute(data-video-info) # if video_info_str: # all_aweme_infos.append(json.loads(video_info_str)) print(f已滚动 {i1} 次累计发现 {len(all_aweme_infos)} 个作品) # 简单去重 unique_ids set() unique_infos [] for info in all_aweme_infos: aweme_id info.get(aweme_id) if aweme_id and aweme_id not in unique_ids: unique_ids.add(aweme_id) unique_infos.append(info) all_aweme_infos unique_infos # 判断是否还有更多内容可以通过检查页面元素 # ... return all_aweme_infos finally: if driver: driver.quit()重要提示Selenium方案速度慢且受页面改版影响大。RENDER_DATA的结构和视频卡片的属性名 (>import random class ProxyPool: def __init__(self, proxy_list): self.proxies proxy_list def get_random_proxy(self): return random.choice(self.proxies) if self.proxies else None # 在请求中使用 proxy_pool ProxyPool([http://user:passip1:port, http://ip2:port, ...]) proxy proxy_pool.get_random_proxy() proxies {http: proxy, https: proxy} if proxy else None resp requests.get(url, headersheaders, proxiesproxies, timeout10)4.2 错误处理与重试机制网络请求充满不确定性必须有完善的错误处理和重试。识别错误类型连接超时、请求被拒(403/429)、服务器错误(5xx)、数据解析错误等。实现重试使用tenacity或retrying库或者自己实现一个带指数退避的重试循环。对于403/429频率限制错误应延长重试等待时间。日志记录详细记录每一次请求的成功/失败、使用的代理、响应状态码等便于后期排查问题。import tenacity tenacity.retry( stoptenacity.stop_after_attempt(3), # 最多重试3次 waittenacity.wait_exponential(multiplier1, min2, max10), # 指数退避 retry(tenacity.retry_if_exception_type(requests.RequestException) | tenacity.retry_if_result(lambda x: x is None)), # 请求异常或结果为None时重试 before_sleeplambda retry_state: print(f第{retry_state.attempt_number}次重试...) ) def safe_fetch_aweme_list(sec_uid, max_cursor): # 这里是你的获取列表的函数核心逻辑 # 如果失败返回None则会触发重试 pass4.3 数据去重与增量更新如果我们需要定期更新某个用户的视频库增量下载就很重要。去重使用aweme_id作为唯一标识。在下载前检查本地是否已存在相同aweme_id的文件。增量逻辑首次运行全量下载。之后运行时从接口获取最新列表与本地记录的已下载ID集合对比只下载新的作品。可以将已下载的ID列表保存在一个JSON文件或小数据库中如SQLite。4.4 配置与模块化将关键配置如代理列表、请求头、签名服务地址、下载路径、请求间隔等抽离到配置文件如config.yaml或config.ini中。将不同功能如签名生成、请求获取、HTML解析、文件下载封装成独立的模块或类使代码结构清晰易于维护和扩展。5. 常见问题排查与实战心得在实际操作中你会遇到各种各样的问题。下面是我踩过的一些坑和解决办法。5.1 请求返回“验证码”或“访问被拒绝”现象返回的HTML页面包含验证码如滑块验证或直接提示访问被拒绝。原因你的请求特征被识别为爬虫。可能因为1) IP被风控2) 请求头特别是User-Agent太假或不完整3) 请求频率过高4) 缺少必要的Cookie或签名。解决换IP立即切换代理IP最好是高质量的住宅IP。完善请求头从真实浏览器或App抓取完整的请求头包括Accept,Accept-Language,Referer,Cookie如果可用等。User-Agent要使用常见的移动端或PC端字符串。降低频率大幅增加请求间隔比如每次请求后随机sleep 5-15秒。检查签名确认_signature、X-Bogus等参数是否有效生成。如果使用开源库检查其是否已过期。5.2 获取到的视频地址无法下载或下载后是空白/损坏现象能拿到URL但下载下来的文件大小异常如只有几KB无法播放。原因URL已过期抖音的视频地址通常有有效期可能是几分钟到几小时。拿到地址后应立即下载不要长时间存储后再用。请求头不正确下载视频时也需要携带正确的请求头特别是Referer通常需要设置为抖音域名User-Agent需为移动端。地址非直链有些地址可能是一个跳转链接302需要跟随跳转。使用requests.get(url, streamTrue, allow_redirectsTrue)确保跟随重定向。无水印规则失效之前提到的/playwm/替换为/play/的规则可能已改变。解决确保下载逻辑紧跟在获取地址之后。下载时使用与获取列表时相似的移动端请求头。打印出最终的下载URL手动在浏览器或下载工具如curl中测试一下看是否能正常下载。重新分析当前可用的无水印地址规律可能需要从play_addr的url_list中寻找不含watermark字样的URL。5.3 Selenium方案无法定位到元素或数据现象driver.find_element(By.ID, RENDER_DATA)抛出NoSuchElementException或者找到的元素内容为空。原因页面未加载完成网络慢或页面复杂需要增加等待时间或使用显式等待 (WebDriverWait)。元素ID或结构已变更抖音前端更新了。被检测为自动化工具即使使用undetected-chromedriver在特定操作模式下仍可能被检测。解决使用显式等待WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.ID, RENDER_DATA)))。如果ID变了需要重新分析页面HTML结构。可以print(driver.page_source)查看当前页面源码搜索包含视频数据的脚本标签。尝试添加更多反检测选项或者模拟更真实的人类操作如随机移动鼠标、随机滚动幅度。5.4 关于“用户ID”的混淆UID、Sec_UID、短ID澄清抖音有多种用户标识。sec_uid长字符串是接口中使用的核心ID最稳定。我们主要用它。uid纯数字ID在一些旧接口中使用但很多新接口已转向sec_uid。短ID/自定义ID用户自己设置的如username或主页显示的那串短号。这个不能直接用于接口需要先通过搜索或解析主页链接转化为sec_uid。心得始终以sec_uid为最终目标。从分享链接、或通过网页端接口如搜索用户来获取它是最可靠的途径。5.5 法律与道德风险再强调这是我反复强调也是最重要的部分。技术是中立的但使用技术的方式有对错。版权抖音上的视频是创作者的作品受著作权法保护。未经允许严禁将下载的视频用于重新上传、剪辑后商用、传播等侵权用途。服务条款抖音的用户协议明确禁止未经授权的自动化抓取。你的行为可能违反协议导致账号被封、IP被禁甚至承担法律责任。合理使用本项目分享的技术思路仅适用于在法律允许的范围内进行极小规模的、个人学习研究性质的抓取。例如下载自己发布的视频进行备份或分析某个公共领域话题下经创作者同意的视频趋势。控制频率无论目的如何务必实施严格的速率限制如每秒/每分钟请求数避免对抖音服务器造成干扰。最后的建议这类项目具有很强的时效性。抖音的防御策略在不断升级。今天有效的方法明天可能就失效了。因此核心能力不是记住某段代码而是掌握分析问题、抓包调试、逆向思考和快速适配的能力。保持对网络协议和前端技术的关注当工具失效时你才能自己找到新的路径。