简介:这份资源是一套面向互联网营销与数据分析人员的抖音关键词搜索自动化脚本,适合具备一定Python基础、希望批量采集抖音视频与用户信息的开发者使用。压缩包共5个文件,约9.8MB,包含Python脚本、批处理文件、APK安装包及依赖清单等,分别用于搜索逻辑实现、MITM代理拦截调试、安卓端辅助运行与运行环境配置。脚本核心涉及关键词匹配算法、网络爬虫、HTML与JSON数据解析、API接口调用及中间人代理等技术点,可帮助读者理解从请求构造到数据提取的完整链路。已有922人学习下载,读者可借此掌握模拟登录、请求头与cookies处理、反爬规避及依赖管理等实操思路,并对照代码结构快速搭建自己的采集环境。使用时需注意遵守平台条款与隐私政策,控制抓取规模,并关注抖音接口变动对脚本运行的影响。
1. 抖音关键词搜索脚本:从手动刷到批量采集的工程化路径
做内容运营或者竞品分析的人,大概都经历过这样的场景:想看看某个关键词下最近冒出了哪些新账号、哪些视频在起量,于是打开抖音搜索框,输入关键词,一屏一屏往下滑,滑到第三十屏手酸了,眼睛也花了,最后只记下三五个觉得有用的。这种手动方式不仅效率低,而且根本没法做趋势对比——你今天看的数据和上周看的,口径完全对不上。抖音关键词搜索脚本要解决的就是这个问题:把「搜索→翻页→提取字段→落盘」这条链路自动化,让采集变成可重复、可对比、可积累的工程动作。它适合做内容选题、竞品监控、达人筛选的运营和开发,也适合想练手爬虫但不想一上来就啃硬骨头的工程师。需要提前说清楚的是,抖音的页面结构和接口签名一直在变,脚本不是一劳永逸的,你得有跟着调整的预期。
2. 先搞清楚搜什么:关键词搜索脚本的字段设计与请求构造
2.1 搜索结果页到底能拿到哪些字段
很多人一上来就写代码,结果跑通了才发现拿到的字段根本不是自己想要的。我一般会先花十分钟把搜索结果页的卡片结构拆一遍。抖音关键词搜索的结果列表里,每条视频卡片通常包含这些可提取的信息:视频标题或文案、作者昵称、作者主页链接、视频链接、点赞数、评论数、发布时间、视频封面图地址。有些版本还会显示「相关推荐」标签或者话题标签。你要根据业务目标决定采哪些字段——如果做选题分析,标题和点赞数是核心;如果做达人筛选,作者昵称、主页链接和近期视频表现更重要。
字段确定之后,要理解请求是怎么发的。抖音搜索页在浏览器里看起来是一个长列表,但底层是分页接口在支撑。常见做法是打开开发者工具,切到 Network 面板,筛选 XHR 或 Fetch 请求,然后在页面上触发一次「加载更多」,观察新出现的请求。你会看到一个带有 search 关键字的接口,返回的是 JSON 结构,里面包含 data 数组,每个元素对应一条视频卡片。关键参数通常包括 keyword(搜索词)、offset 或 cursor(翻页游标)、count(每页数量)。不同时期参数名可能不同,但逻辑是一致的:用游标或偏移量控制翻页,用关键词控制搜索范围。
提示:不要一上来就追求「全量采集」。先跑通「一个关键词、一页数据、五个字段」的最小闭环,再逐步加翻页和字段。
2.2 用 Python 构造一次最小可用的搜索请求
下面这段代码演示的是最基础的请求构造思路。注意,我在这里用的是通用的 requests 库来发请求,实际运行时你需要把 URL 和参数替换成你当前抓到的真实值。代码里保留了关键的注释,方便你对照自己的抓包结果修改。
import requests import json import time # 搜索接口地址,实际使用时替换为你在开发者工具里看到的真实 URL SEARCH_URL = "https://www.douyin.com/aweme/v1/web/search/item/" # 请求头,User-Agent 和 Referer 是必须的,Cookie 根据实际情况补充 HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.douyin.com/", "Accept": "application/json, text/plain, */*", } def search_once(keyword, offset=0, count=10): """ 发起一次搜索请求 keyword: 搜索关键词 offset: 翻页偏移量,第一页为 0 count: 每页返回数量,通常为 10 或 20 """ params = { "keyword": keyword, "offset": offset, "count": count, "search_channel": "aweme_general", # 搜索频道,通用视频 "sort_type": 0, # 0 综合排序,1 最新,2 最热 "publish_time": 0, # 0 不限,1 一天内,7 一周内等 } try: resp = requests.get(SEARCH_URL, headers=HEADERS, params=params, timeout=10) resp.raise_for_status() data = resp.json() return data except requests.RequestException as e: print(f"请求失败: {e}") return None if __name__ == "__main__": result = search_once("露营装备", offset=0, count=10) if result: # 打印返回的顶层键,方便确认数据结构 print(json.dumps(result, ensure_ascii=False, indent=2)[:2000])这段代码的逻辑很直白:拼参数、发请求、拿 JSON。参数里sort_type和publish_time是两个很实用的筛选维度,做趋势分析时把publish_time设成 7,就能只看最近一周的内容。offset每次增加count的数量,就能实现翻页。代码跑通后,你会看到返回的 JSON 里有一个data字段,里面是列表,每个元素就是一条视频信息。下一步就是从这些元素里把标题、作者、点赞数抠出来。
2.3 翻页逻辑与频率控制的参数怎么设
翻页不是简单地把 offset 一直加下去。抖音的搜索接口对翻页深度是有限制的,通常翻到几十页之后就会返回空数据或者要求验证。我一般会把单次任务的翻页上限设在 10 到 20 页之间,也就是 100 到 200 条视频,这个量级对于大多数选题分析已经够用了。如果你确实需要更多数据,更稳妥的做法是换关键词、换时间切片,而不是死磕一个关键词往深了翻。
频率控制是另一个必须认真对待的参数。我见过有人把time.sleep设成 0.5 秒,跑了几百次请求之后直接被封 IP。比较安全的做法是:每次请求之间随机间隔 2 到 5 秒,每跑完 5 到 10 页就休息 30 秒以上。下面是一个带频率控制的翻页示例:
import random def search_pages(keyword, max_pages=10, count=10): """ 翻页采集,带随机间隔 max_pages: 最大翻页数 count: 每页数量 """ all_items = [] for page in range(max_pages): offset = page * count print(f"正在采集第 {page + 1} 页,offset={offset}") data = search_once(keyword, offset=offset, count=count) if not data or not data.get("data"): print("返回为空,停止翻页") break items = data["data"] all_items.extend(items) # 随机间隔 2 到 5 秒 sleep_time = random.uniform(2, 5) time.sleep(sleep_time) # 每 5 页多休息一会儿 if (page + 1) % 5 == 0: print("已采集 5 页,休息 30 秒") time.sleep(30) return all_items这里的max_pages和count是两个需要根据实际情况调的参数。count设大了单页返回多,但被风控的概率也高;设小了请求次数多,总耗时更长。我一般用count=10,配合 2 到 5 秒的间隔,在稳定性和效率之间取一个平衡。max_pages则根据你的采集目标来定,做日常监控 5 页足够,做深度分析可以到 20 页,但再往上就建议换策略了。
3. 从 JSON 到表格:字段提取、清洗与落盘
3.1 把嵌套 JSON 拍平成一行行记录
搜索接口返回的 JSON 是嵌套结构,一条视频的信息可能分散在aweme_info、author、statistics这几个层级里。直接存 JSON 当然可以,但后续做分析时很不方便。我一般会写一个提取函数,把每条视频拍平成一行记录,字段包括:视频 ID、标题、作者昵称、作者 ID、点赞数、评论数、分享数、发布时间戳、视频链接。下面是一个提取函数的示例:
def extract_item(item): """ 从单条搜索结果中提取关键字段 item: 搜索结果列表中的一个元素 """ aweme = item.get("aweme_info", {}) author = aweme.get("author", {}) stats = aweme.get("statistics", {}) # 视频 ID 用于拼接链接 aweme_id = aweme.get("aweme_id", "") record = { "aweme_id": aweme_id, "title": aweme.get("desc", "").replace("\n", " ").strip(), "author_name": author.get("nickname", ""), "author_id": author.get("uid", ""), "digg_count": stats.get("digg_count", 0), "comment_count": stats.get("comment_count", 0), "share_count": stats.get("share_count", 0), "create_time": aweme.get("create_time", 0), "video_url": f"https://www.douyin.com/video/{aweme_id}" if aweme_id else "", } return record这个函数的关键在于防御性取值:每一层都用.get()并给默认值,避免某个字段缺失导致整个脚本崩溃。title里把换行符替换成空格,是为了后续写入 CSV 时不出现错行。create_time是 Unix 时间戳,后面可以转成可读日期。video_url用视频 ID 拼接,方便直接点开核对。
3.2 用 pandas 落盘成 CSV 和 Excel
提取出来的记录列表,用 pandas 转成 DataFrame 再落盘是最省事的。CSV 适合后续用脚本处理,Excel 适合直接拿给运营看。下面这段代码同时输出两种格式:
import pandas as pd from datetime import datetime def save_records(records, keyword): """ 将记录列表保存为 CSV 和 Excel records: extract_item 返回的记录列表 keyword: 搜索关键词,用于文件名 """ if not records: print("没有记录可保存") return df = pd.DataFrame(records) # 时间戳转可读日期 df["create_time_readable"] = df["create_time"].apply( lambda x: datetime.fromtimestamp(x).strftime("%Y-%m-%d %H:%M:%S") if x else "" ) # 按点赞数降序排列 df = df.sort_values("digg_count", ascending=False) # 生成带时间戳的文件名 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") csv_file = f"douyin_search_{keyword}_{timestamp}.csv" excel_file = f"douyin_search_{keyword}_{timestamp}.xlsx" df.to_csv(csv_file, index=False, encoding="utf-8-sig") df.to_excel(excel_file, index=False) print(f"已保存 {len(df)} 条记录到 {csv_file} 和 {excel_file}") return df这里有几个细节值得注意。encoding="utf-8-sig"是为了让 Excel 打开 CSV 时不乱码,这个坑我踩过好几次。按digg_count降序排列,是为了让你打开文件第一眼就能看到表现最好的内容。文件名里带关键词和时间戳,方便多次采集后做对比。如果你要长期监控同一个关键词,可以每天跑一次,文件名里的日期就是天然的时间切片。
3.3 去重与增量采集的简单策略
同一个关键词反复采集,一定会遇到重复视频。最简单的去重方式是用aweme_id做唯一键,每次保存前先读一下历史文件,把已经存在的 ID 过滤掉。下面是一个增量采集的示例:
import os def load_existing_ids(csv_file): """读取已有 CSV 中的 aweme_id 集合""" if not os.path.exists(csv_file): return set() df = pd.read_csv(csv_file) return set(df["aweme_id"].astype(str).tolist()) def incremental_save(new_records, keyword): """增量保存,跳过已存在的视频""" # 找到最新的历史文件 existing_files = [f for f in os.listdir(".") if f.startswith(f"douyin_search_{keyword}_") and f.endswith(".csv")] existing_ids = set() if existing_files: latest = sorted(existing_files)[-1] existing_ids = load_existing_ids(latest) print(f"历史文件 {latest} 中有 {len(existing_ids)} 条记录") # 过滤新记录 fresh = [r for r in new_records if str(r["aweme_id"]) not in existing_ids] print(f"新记录 {len(fresh)} 条,重复 {len(new_records) - len(fresh)} 条") if fresh: save_records(fresh, keyword) else: print("没有新记录需要保存")这个策略的核心是:每次采集前先加载历史 ID 集合,只保存新的。对于日常监控场景,你甚至可以把这个脚本挂到定时任务里,每天跑一次,只关注新增内容。load_existing_ids里用astype(str)是为了避免 ID 被 pandas 读成数字后精度丢失,这个坑在 ID 较长时特别容易翻车。
4. 避坑与排查:关键词搜索脚本最常见的五个翻车点
4.1 请求返回空数据或 403
现象:脚本跑了几页之后,突然返回空列表或者直接 403,换关键词也不行。
原因:最常见的是触发了频率限制,其次是请求头里的 Cookie 过期或者签名参数缺失。抖音的接口对没有有效签名的请求会直接拒绝,而签名算法是动态变化的。
解决:先降低频率,把间隔调到 5 秒以上,每 3 页休息 60 秒。如果还是不行,检查请求头里的User-Agent和Referer是否完整,Cookie 是否过期。对于签名问题,常见做法是使用浏览器自动化工具(如 Playwright)来维持会话,让浏览器自己处理签名,脚本只负责提取数据。这样虽然慢一些,但稳定性高很多。
4.2 翻页到一定深度后数据重复
现象:翻到第 8 页之后,返回的视频和前面几页有大量重复,甚至完全一样。
原因:抖音的搜索接口在翻页深度较大时,会返回推荐内容而不是严格的下一页结果。这是接口设计上的限制,不是脚本 bug。
解决:把翻页上限控制在 10 页以内,用aweme_id去重后实际有效数据可能只有 60 到 80 条。如果需要更多数据,换关键词或者换时间切片,而不是继续往深了翻。我一般会在脚本里加一个重复率检测,如果连续两页的重复率超过 50%,就自动停止翻页。
4.3 字段提取时 KeyError 频发
现象:脚本跑着跑着突然报KeyError: 'aweme_info',整个任务中断。
原因:搜索结果里不全是视频卡片,还可能混有广告、话题、用户推荐等不同类型的元素,它们的结构不一样。
解决:在提取函数里做类型判断,只处理包含aweme_info的元素。下面是一个简单的过滤逻辑:
def filter_video_items(items): """只保留视频类型的搜索结果""" videos = [] for item in items: if "aweme_info" in item and item["aweme_info"].get("aweme_id"): videos.append(item) return videos在翻页循环里,先调filter_video_items再调extract_item,就能避免大部分 KeyError。
4.4 中文乱码与 Excel 打开异常
现象:CSV 文件用 Excel 打开后中文全是乱码,或者标题里的换行导致行错位。
原因:Excel 默认用 GBK 编码打开 CSV,而脚本写的是 UTF-8。标题里的换行符没有清理干净。
解决:写 CSV 时用encoding="utf-8-sig",这个编码会在文件头加 BOM,Excel 能正确识别。标题字段在提取时就做replace("\n", " ").strip()处理。如果还是有问题,直接输出 Excel 格式(.xlsx),pandas 的to_excel对中文支持很好。
4.5 脚本跑久了内存暴涨
现象:采集几百页之后,脚本占用内存越来越大,最后被系统杀掉。
原因:所有记录都堆在内存里的all_items列表里,没有及时落盘。
解决:改成边采集边写入的模式。每采集一页,就提取记录并追加到 CSV 文件里,而不是全部攒着最后写。下面是一个流式写入的示例:
def stream_save(records, csv_file): """追加写入 CSV,适合长时间采集""" df = pd.DataFrame(records) # 第一次写入带表头,后续追加不带表头 header = not os.path.exists(csv_file) df.to_csv(csv_file, mode="a", header=header, index=False, encoding="utf-8-sig")这样内存里始终只有当前页的数据,跑再久也不会爆。
5. 进阶技巧:用 Playwright 绕过签名难题并做可视化验证
如果你已经跑通了前面的 requests 版本,但发现签名问题越来越难搞,那下一步可以考虑用 Playwright 来做浏览器自动化。它的思路不是去逆向签名算法,而是让真实的浏览器去发请求,脚本只负责控制翻页和提取数据。这样做的好处是稳定性高,缺点是速度慢一些,资源占用大一些。
我一般会这样组织 Playwright 脚本:先启动浏览器,打开抖音搜索页,输入关键词,然后模拟滚动加载。每加载一批,就用page.evaluate从 DOM 里提取视频卡片的信息。下面是一个最小示例:
from playwright.sync_api import sync_playwright import time def search_with_playwright(keyword, max_scroll=5): """ 用 Playwright 打开搜索页并滚动加载 keyword: 搜索关键词 max_scroll: 最大滚动次数 """ with sync_playwright() as p: browser = p.chromium.launch(headless=False) # 调试时用有头模式 page = browser.new_page() # 打开搜索页 page.goto(f"https://www.douyin.com/search/{keyword}", timeout=30000) page.wait_for_timeout(5000) # 等待首屏加载 for i in range(max_scroll): # 滚动到底部 page.evaluate("window.scrollTo(0, document.body.scrollHeight)") page.wait_for_timeout(3000) print(f"已滚动 {i + 1} 次") # 提取视频卡片信息 items = page.evaluate(""" () => { const cards = document.querySelectorAll('[data-e2e="scroll-list"] li'); return Array.from(cards).map(card => { const titleEl = card.querySelector('[data-e2e="video-desc"]'); const authorEl = card.querySelector('[data-e2e="video-author"]'); const likeEl = card.querySelector('[data-e2e="video-like"]'); return { title: titleEl ? titleEl.innerText : '', author: authorEl ? authorEl.innerText : '', like: likeEl ? likeEl.innerText : '' }; }); } """) browser.close() return items这段代码的关键在于page.evaluate里的 DOM 选择器。style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />