资讯动态

抖音视频搜索数据采集实战:基于Playwright的爬虫方案解析

发布时间:2026/10/3 15:43:28 来源:尧图企业网站定制
做自媒体账号运营和竞品分析绕不开抖音这个流量池。我自己的团队在做内容选题和热点追踪时经常需要批量获取某个关键词下的视频数据——标题、作者、点赞量、评论数这些公开信息。一开始手工复制粘贴几十条还能忍几百条就开始崩溃。后来我用Python写了一套抖音视频搜索数据采集脚本把之前的采集流程完全自动化了。今天就把这套方案完整拆开讲清楚从抓包分析、核心源码到并发优化和反爬排查全都覆盖代码可直接运行希望帮你在做数据采集时少踩几个坑。先说清楚边界本文只采集抖音平台公开的搜索结果元数据标题、作者、统计量等不涉及视频文件下载、不碰任何私密数据、不做绕过登录态的越权操作。这套爬虫主打低频、稳定、合规适合个人学习、学术研究和正当的数据分析场景。1. 项目概述与整体设计1.1 抖音视频搜索数据到底能用来做什么抖音搜索数据天然是“用户需求”的指向标。我这边实际用下来有三个非常典型的使用场景。第一是内容选题辅助。比如你运营的是美食账号用爬虫去搜“家常菜”“减脂餐”“空气炸锅”这些关键词按点赞数排序拉出热门前10页就能发现当前什么做法、什么呈现方式最受欢迎。这比凭感觉选题靠谱得多因为数据不会骗人。第二是竞品监控。把你关注的竞品账号名称作为关键词去搜得到他们发布视频的点赞、评论、收藏数据按时间排序就能看到他们的增长趋势和爆款节奏。这类数据对运营策略调整的价值极高。第三是舆情与热点追踪。当一个突发事件或者新梗出现时抖音搜索结果和视频互动量会迅速变化。定期抓取同一关键词的数据可以量化一个话题的升温速度和持续时间。当然你也可以把数据接进自己的可视化系统。我见过有人把采集结果导入FineBI或者做成Excel仪表盘直接通过图表观察变化趋势。数据量不大按关键词、按时间维护一套CSV或SQLite就够用了。1.2 技术选型Requests 还是 Playwright聊抖音爬虫第一个绕不开的问题就是用纯 Requests 模拟请求还是用 Playwright 这类浏览器自动化框架我把两种方案的实际差异列出来比较过各有适用场景。我早期也尝试过纯 Requests 方案。抖音 Web 端搜索接口是https://www.douyin.com/aweme/v1/web/general/search/single/返回的是 JSON 数据抓包看非常清爽。但关键问题在于这个接口的请求头里必须携带签名参数早期是X-Bogus现在已经演进为a_bogus这类动态加密参数。这个签名是由页面内部 JS 动态计算出来的如果要用 Requests 复现就必须对 JS 做逆向分析把加密逻辑用 Python 重写或者用execjs/PyMiniRacer之类的库去调用网页里的 JS 函数。这个方案我后来放弃了原因有两点。第一JS 逆向工作量大而且抖音的加密逻辑会不定期更新每次更新你都要重新分析一遍维护成本太高。第二纯 Requests 请求缺少浏览器环境特征即使签名算对了也容易因为 TLS 指纹、Cookie 完整性等问题触发风控。所以我最终选择 Playwright 作为主力方案。它的思路完全不同直接用真实 Chromium 内核打开抖音搜索页页面里所有的 JS 正常执行签名参数由网页自己生成流量也是真实浏览器发出的。我们只需要监听网络请求和响应把搜索接口返回的 JSON 数据截获下来即可。这套方案极其稳健不需要碰任何逆向逻辑。用表格总结一下对比项Requests JS逆向Playwright 浏览器自动化开发成本高需要逆向签名算法低监听网络响应即可稳定性低接口改动需重写高页面能跑就能采风控触发概率高低但频率高了同样会触发资源消耗低中高需要完整浏览器数据完整度取决于签名算法还原程度与浏览器看到的一致结论很明确如果你的目标是“快速、稳定拿到数据”Playwright 是目前最务实的选型。1.3 整体架构与流程设计这套系统的运行流程可以分为六个环节初始化浏览器 → 加载持久化登录态 → 打开关键词搜索页 → 监听搜索接口响应 → 滚动加载触发翻页 → 数据解析与落库。我这里所说的“登录态”很关键。抖音网页端不登录也能搜索但未登录状态下Cookie 中缺少关键的标识信息触发滑块验证的概率会明显上升。我的做法是第一次手动登录一次把登录信息持久化保存为本地文件后续每次运行都直接复用这样既不用反复登录也能显著提高会话的稳定性。架构上我用“监听响应”而不是“解析DOM”。原因是搜索接口返回的是纯 JSON结构非常规整解析成本极低。而 DOM 结构会随前端改版频繁变动用page.query_selector去解析页面元素指不定哪天就失效了。监听网络响应相当于绕过了页面渲染层直接拿第一手数据这在稳定性上是最优解。2. 前置准备环境搭建与搜索接口分析2.1 Python环境与Playwright安装开发环境建议 Python 3.9 以上版本。如果你从没装过 Python去官网下载安装包时注意勾选“Add Python to PATH”选项这一步经常有人漏掉导致命令行里python命令不可用。安装依赖非常直接pip 安装两个包就够了pip install playwright然后在命令行执行playwright install chromium这一步会下载 Chromium 浏览器内核。注意国内网络环境下下载可能比较慢多试几次或者换一个网络环境基本能解决。如果安装过程卡住也可以手动指定镜像源但这里不展开。验证环境是否装好可以跑一个最小化的 Playwright 脚本from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(https://www.baidu.com) print(page.title()) browser.close()如果控制台能正常打印出网页标题说明环境没问题。这里特意用有头模式headlessFalse因为无头模式在抖音这种风控严格的平台上更容易被识别开发调试阶段更建议直接看真实的浏览器行为。2.2 抓包定位搜索接口用 Playwright 的page.on(response)可以拦截所有网络请求但在写代码之前我强烈建议你先手动用浏览器开发者工具看一眼接口长什么样子这会让你后续理解代码容易得多。手动操作流程打开 Chrome登录抖音网页版按 F12 打开开发者工具切到 Network网络面板在过滤器中选择 Fetch/XHR然后访问一个搜索页比如 https://www.douyin.com/search/美食。你会看到页面发出大量请求其中重点观察 URL 中包含general/search/single的那个请求。点开它在 Payload负载Tab 下能看到一堆请求参数在 Preview预览Tab 下能看到返回的 JSON 数据结构。这个接口就是我们的核心数据来源。它支持keyword关键词、offset偏移量翻页用、count每页数量、sort_type排序方式0综合、1最多点赞、2最新发布等参数。但请注意参数列表里还有一堆签名相关的自动生成字段这些不用自己构造浏览器会自动带上。响应结构中核心字段是data数组数组里每个元素对应一条搜索结果。当元素类型为type1时它是一条视频数据type8时可能是用户。视频详情被包在aweme_info字段里里面包含视频标题desc、作者昵称author.nickname、点赞数statistics.digg_count、评论数statistics.comment_count等。2.3 接口关键参数与字段解读为了让你对即将解析的数据有个清晰认知我把最常用的字段整理成一张速查表JSON路径含义类型aweme_id视频唯一ID也是去重的关键字段stringdesc视频标题/文案stringcreate_time发布时间时间戳秒numberauthor.nickname作者昵称stringauthor.uid作者唯一IDstringstatistics.digg_count点赞数numberstatistics.comment_count评论数numberstatistics.share_count转发数numberstatistics.collect_count收藏数numbervideo.duration视频时长毫秒numberaweme_info视频详情外壳object这里有一个细节要提醒如果你在搜索页 URL 上加了?typevideo参数比如https://www.douyin.com/search/美食?typevideo那么搜索结果会只展示视频类型type1的比例会很高解析时筛选数据的逻辑也会更简单。不加这个参数返回结果里会混入用户、话题等实体需要在代码里做类型判断。响应里还有一个has_more字段和一个cursor字段。has_moretrue表示还有下一页数据cursor是下一次请求的偏移量标记。滚动加载时浏览器会自动拼接这些参数发出翻页请求我们不需要手动改任何东西只需要控制滚动动作就行。3. 核心代码实现从请求拦截到数据落库3.1 初始化浏览器与登录态复用现在我们正式进入代码部分。整个脚本我分成几个模块来写这样结构清晰后续扩展和调试都方便。登录态持久化是稳定性保障的第一道关卡。第一次运行时我手动打开浏览器窗口做了一次扫码登录然后把登录态保存成一个 JSON 文件。之后的每次运行都从这个文件恢复登录态直接跳过登录环节。代码如下import os from playwright.sync_api import sync_playwright STATE_FILE douyin_state.json BASE_URL https://www.douyin.com/search/{}?typevideo def create_context(browser): if os.path.exists(STATE_FILE): context browser.new_context( storage_stateSTATE_FILE, viewport{width: 1400, height: 900}, 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 ), localezh-CN, ) else: context browser.new_context( viewport{width: 1400, height: 900}, 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 ), localezh-CN, ) return context这里有几个细节说明了为什么这么写storage_state保存的就是 Cookie 和 localStorage 数据。抖音的用户身份标识和部分风控特征都在里面。user_agent手动指定为真实 Chrome 的 UA避免 Playwright 默认 UA 被识别。默认 UA 里带HeadlessChrome或缺少正常浏览器特征容易被反爬系统杀掉。viewport设置为 1400x900模拟正常笔记本分辨率。过小的窗口或者很奇怪的尺寸也可能触发检测。首次运行时STATE_FILE不存在代码走 else 分支直接打开浏览器。此时你需要手动在浏览器页面里完成抖音登录扫码或手机验证然后调save_state保存登录态。我的做法是单独写了一个小脚本完成首次登录和保存比在采集主流程中处理更干净def first_login_and_save_state(): with sync_playwright() as p: browser p.chromium.launch(headlessFalse) context create_context(browser) page context.new_page() page.goto(https://www.douyin.com/) input(请在浏览器中完成登录然后按回车继续...) context.storage_state(pathSTATE_FILE) browser.close() print(登录态已保存到, STATE_FILE)把登录和采集分成两个流程避免每次跑采集都要人工介入登录。登录一次之后只要不被平台踢下线这个 Cookie 文件可以一直复用我实测下来一般能稳定用几天到一两周。3.2 监听响应并解析搜索数据登录态准备好后核心采集逻辑就清晰了。整个流程是打开搜索页 → 监听搜索接口响应 → 触发滚动加载 → 递归翻页。我用拦截响应来判断翻页是否完成不用wait_for_timeout硬等。滚动一次等待新的搜索接口响应返回收到后再决定是否继续滚动这样既不会漏数据也不会为了等数据白白卡时间。来看核心代码import json import time from datetime import datetime from urllib.parse import quote def handle_search_response(response, results, stop_flag): url response.url if /aweme/v1/web/general/search/single/ not in url: return try: data response.json() except Exception: return items data.get(data) or [] for item in items: if item.get(type) ! 1: continue info item.get(aweme_info) or {} author info.get(author) or {} stats info.get(statistics) or {} results.append({ aweme_id: info.get(aweme_id), desc: info.get(desc), create_time: convert_time(info.get(create_time)), nickname: author.get(nickname), author_uid: author.get(uid), digg_count: stats.get(digg_count), comment_count: stats.get(comment_count), share_count: stats.get(share_count), collect_count: stats.get(collect_count), }) has_more data.get(has_more) if not has_more: stop_flag[stop] True这里我用了一个字典stop_flag来传递“是否还有下一页”的状态。因为监听器是异步被调用的直接用普通变量在闭包里修改会有作用域问题字典是可变对象能可靠地在回调中更新状态。convert_time的作用把时间戳转成可读字符串def convert_time(ts): if not ts: return return datetime.fromtimestamp(ts).strftime(%Y-%m-%d %H:%M:%S)然后主采集流程def search_keyword(keyword, max_scroll5): results [] stop_flag {stop: False} with sync_playwright() as p: browser p.chromium.launch( headlessFalse, args[--disable-blink-featuresAutomationControlled], ) context create_context(browser) page context.new_page() page.on(response, lambda resp: handle_search_response(resp, results, stop_flag)) search_url BASE_URL.format(quote(keyword)) page.goto(search_url) page.wait_for_load_state(networkidle) scroll_count 0 while not stop_flag[stop] and scroll_count max_scroll: page.mouse.wheel(0, 3000) scroll_count 1 page.wait_for_timeout(1800 scroll_count * 200) # 每次滚动多等一点给接口响应留时间 browser.close() return results滚动加载这部分我故意做了一个动态等待1800 scroll_count * 200毫秒。原因是随着滚动次数增加页面中累积的元素变多浏览器需要更多时间去加载和渲染。用递增等待时间来换稳定性比固定等待效果要好得多。wait_for_load_state(networkidle)是等页面所有请求都发完。但抖音这个搜索页的接口不一定在 networkidle 时就已经返回所以后面的滚动和等待逻辑才是真正拿到数据的关键。另外注意args[--disable-blink-featuresAutomationControlled]这个参数。它主要作用是去掉部分自动化特征标记降低被识别的概率。配合有头模式稳定性会有明显提升。3.3 数据去重与CSV存储拿到results列表后肯定不能直接写入文件完事。搜索结果在翻页时可能会有重复数据比如抖音的“推荐”机制会让不同页出现同一条视频所以去重是必须的。去重逻辑很简单用aweme_id作为唯一标识def deduplicate(results): seen set() unique [] for item in results: vid item.get(aweme_id) if vid and vid not in seen: seen.add(vid) unique.append(item) return unique去重后的数据写入 CSV。这里有一个细节文件编码必须用utf-8-sig而不是普通的utf-8。原因是直接utf-8编码后用 Excel 打开中文会乱码。加上 BOM 头的utf-8-sig是兼容性最好的选择。import csv def save_to_csv(results, filename): if not results: print(没有可写入的数据) return fieldnames [ aweme_id, desc, create_time, nickname, author_uid, digg_count, comment_count, share_count, collect_count ] with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(results) print(f已保存 {len(results)} 条数据到 {filename})一个完整的调用示例if __name__ __main__: keyword 家常菜 data search_keyword(keyword, max_scroll4) data deduplicate(data) save_to_csv(data, fsearch_{keyword}_{int(time.time())}.csv)3.4 字段清洗与扩展实际采集到的数据往往比接口字段定义的更“脏”。比如desc里可能包含#话题#这种标签标记统计字段可能为null作者昵称里可能有奇怪的空白字符。我在存储前会做一轮简单的清洗让数据更规范def clean_desc(text): if not text: return # 去掉话题标签中的#号保留话题文字 text text.replace(#, ).strip() # 合并多个空格 return .join(text.split()) def clean_item(item): item[desc] clean_desc(item.get(desc)) for field in [digg_count, comment_count, share_count, collect_count]: if item.get(field) is None: item[field] 0 return item这一轮清洗很重要。很多做数据分析的读者拿到数据后会直接算平均数、中位数如果统计字段里混进null计算结果会直接报错或者失真。把这个环节放在入库前后面分析就能省很多事。还有个扩展方向如果你想按“最多点赞”排序查看热门内容可以在搜索页 URL 参数里增加sort_type1。抖音接口层面对排序方式做了区分0 是综合排序1 是最多点赞2 是最新发布。实际做热门分析时我通常用sort_type1拉取点赞最高的数据对做选题参考更有价值。4. 并发采集设计在效率与稳定之间找平衡4.1 为什么爬虫需要并发设计如果只是抓一个关键词的几十条数据串行完全够用。但实际业务中你可能面临的是几十上百个关键词比如把所有竞品账号名都过一遍或者把一个大类目下的所有热门词都采一遍。这时候串行的耗时是难以接受的。我举个例子一个关键词滚动5页稳定采集大约需要 20 到 30 秒。如果关键词数量是 50 个串行跑完需要 20 多分钟。这还只是纯采集时间不包括中间可能出现的等待和异常重试。所以在关键词数量大时并发是必须考虑的问题。但并发设计有一个大前提抖音的风控不是吃素的。并发数不是开得越高越好盲目开 20 个浏览器实例同时采集大概率几分钟内就集体触发滑块验证反而一个数据都采不到。我在实践中摸索下来的平衡点是并发数控制在 3 到 5 个然后配合随机延时。4.2 线程池 独立Context方案Playwright 的sync_api和asyncio各有各的玩法但异步方案async_api在抖音这种强风控场景下优势不大反而对代码结构要求更高。我用得最顺的是 ThreadPoolExecutor 每个线程分配独立 context 的组合。核心思路是每个线程创建一个独立的浏览器 context相当于一个独立的无痕窗口互不共享 Cookie 和会话状态。这样既隔离了请求又能在线程池层面统一管理并发数量。实现代码from concurrent.futures import ThreadPoolExecutor, as_completed def worker(keyword, max_scroll3): try: data search_keyword(keyword, max_scrollmax_scroll) time.sleep(random.uniform(1.0, 2.0)) return keyword, data except Exception as exc: print(f关键词 [{keyword}] 采集失败: {exc}) return keyword, [] def batch_search(keywords, max_workers3, max_scroll3): all_results {} with ThreadPoolExecutor(max_workersmax_workers) as executor: futures {executor.submit(worker, kw, max_scroll): kw for kw in keywords} for future in as_completed(futures): keyword, data future.result() all_results[keyword] data print(f完成 [{keyword}]: {len(data)} 条) return all_results注意worker里的sleep(random.uniform(1.0, 2.0))这个随机延时非常关键。如果每个线程结束后立刻开始下一个任务请求节奏太整齐反而容易被频率检测盯上。人为加一点抖动把请求间隔变成不规则的更接近真实用户行为。4.3 并发过程中的限速与错误处理线程池内部的max_workers3是一种限速但光靠这一层还不够。每个线程内部的滚动间隔也要做随机化。我通常不会直接写死page.wait_for_timeout(1800)而是写成delay random.uniform(1500, 2500) page.wait_for_timeout(delay)这样每个页面、每次滚动的间隔都不一样。虽然差别不大但在大量请求累积后这种随机性对降低风控命中率是有实际帮助的。错误处理也要做得足够健壮。并发场景下单个线程失败不能影响整个批次。我在worker里捕获了所有异常并返回空列表这样即使某个关键词采集失败主流程也会继续只是这个关键词没有数据可以稍后重采。还有一个细节线程池不是创建越多越好。Playwright 的每个 context 至少需要几十 MB 内存如果同时开 10 个 context内存占用轻松上 GB反而拖慢整体速度。3 到 5 个线程是我在 16GB 内存机器上测试比较舒服的区间。5. 常见反爬问题与排查实录5.1 滑块验证码的触发与处理这是遇到最多的一个坑。运行一段时间后页面会突然跳出滑块验证此时网络响应返回的数据要么为空要么data数组里全是无效元素。我的处理顺序是第一立即停止当前关键词的采集不要让脚本继续滚动触发更多验证请求。继续滚只会让风控等级升级。第二在收集到的错误信息里记录触发验证的关键词和页码后续单独重跑。第三最优解是“人工介入一次”。因为用的是有头模式滑块弹出来时我直接手动拖一下滑块验证通过后脚本继续工作。手动过一次之后当前 context 的 Cookie 会带上验证通过的状态后续连续很多个请求都不会再触发。如果脚本是无人值守运行滑块弹出后没人处理那就要靠以下策略降低触发概率限速把滚动间隔从 2 秒提到 4-5 秒。减少单个关键词的滚动页数不要一拉到底几十页。检查 Cookie 是否有效有些情况下重新登录一次比硬扛更有效。5.2 返回空数据或 has_more 始终为 false有时候接口正常返回但data字段是空数组或者明明还有更多内容has_more却是 false。这种问题排查优先级最高的前三个原因第一个是风控介入。平台没有明文告诉你被封而是返回“看似正常但无数据”的响应。特点是所有请求都 200但没有实际内容。这种情况和滑块验证是一体两面按第 5.1 节的方式处理即可。第二个是排序方式影响。你用的关键词本身搜索结果就不多或者sort_type参数不值得没有对应数据。换一个更宽泛的关键词测试一下就能判断。第三个是 Cookie 过期。长时间未登录Cookie 中的某些字段失效接口开始返回空壳数据。重新执行登录流程保存新的storage_state通常能解决。5.3 登录态失效与封号风险评估登录态失效的表现是第一次运行正常第二次、第三次开始频繁出验证。这时候不要死磕果断重新走一次登录流程。关于账号风险我特别想提醒一点不要用自己日常使用的主账号来跑爬虫。建议单独注册一个小号即使风控升级也只影响这个测试号。我自己的项目里专门准备了一个低权重账号用来采集主账号完全不受影响。这是个成本极低、收益极高的安全策略。另外采集频率必须控制。我给自己的实践定了一个简单的规矩单关键词连续滚动不超过 5 页一个批次所有关键词总量不超过 2000 条然后强制休眠 5 到 10 分钟再跑下一批。这套规矩执行以来严重风控事件几乎没再遇到过。5.4 Playwright 被识别为自动化工具虽然 Playwright 比 Requests 隐蔽得多但也不是完全隐身。网页可以通过navigator.webdriver属性等特征识别自动化环境。我常用的加固手段有三个启动参数加--disable-blink-featuresAutomationControlled移除部分自动化的 Blink 特性。使用真实桌面浏览器的 User-Agent而不用 Playwright 默认的 UA。有头模式运行。虽然比无头模式慢但被识别的概率明显更低。无头模式对风控严格的平台来说特征太明显。如果还需要更强的伪装可以引入playwright-stealth这类补丁库。但我的经验是对抖音这种平台前三条基础措施配合低频访问已经能满足绝大多数数据采集需求没有必要过度工程化。6. 合规边界与给新手的最后提醒6.1 什么样的采集是安全的爬虫合规是个大话题这里我只从工程实践角度给几条已经验证过的安全准则。第一只采集公开数据。抖音搜索结果页上的标题、作者、点赞数对任何登录用户都是可见的属于公开数据范畴。不要去碰用户私信、通讯录、非公开的统计接口这类数据哪怕技术上能拿到也绝对不能碰。第二控制访问频率。爬虫的本质是自动化请求但如果请求频率远超人类操作极限就必然对平台服务器造成压力。做一个“有礼貌的爬虫”限制请求速率不影响平台正常服务这是底线。第三采集的数据不要用于侵权用途。数据分析、学术研究、个人学习可以但拿别人视频数据去搬运、去抹黑、去批量注册等行为都是红线。本文一开始就说明不涉及视频文件下载正是因为视频文件本身属于创作者的内容资产下载和再分发涉及版权问题这是做技术的人不应该去踩的坑。6.2 代码维护与后续扩展建议这篇文章给出的代码是一个基础可用版实际项目运行中还可以从这几个方向扩展。存储方面如果数据量增长到几万条CSV 就不够用了。建议迁移到 SQLite 或者 MySQL按关键词和时间建索引查询效率会高很多。SQLite 是零配置的适合单机使用我个人的过渡方案就是 SQLite。调度方面可以引入定时任务比如每天早上自动采集一次昨天的新增数据同步到数据仓库。Linux 上用 crontabWindows 上用计划任务都很方便。监控方面可以给采集脚本加一个日志模块把每次运行的成功数、失败数、触发验证的次数都记录下来。这些日志一方面能帮你定位问题另一方面也能量化风控策略的有效性。我自己的习惯是维护一个“运行记录表”每次采集完都追加一行时间、关键词、采集量、验证次数、耗时。跑一两周之后回头看一眼哪些时段容易被风控、哪些关键词更容易触发验证心里就非常有数了。最后再分享一个小技巧抖音的接口返回里其实有很多额外信息比如视频的video.play_addr.url_list字段里有时会直接附带视频的播放地址但采集这个字段涉及到范围更广的数据使用问题而且打开频率过高会显著增加被风控的概率。我个人建议除非有明确的合法授权否则不要碰这个字段。守住自己的数据使用边界这个项目才能长期稳定地跑下去。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑