做App自动化测试这几年,经常有人问我:能不能用Python和Appium直接操作抖音,把上滑刷视频、记录视频信息、清理弹窗这些重复事交给脚本。我的答案是能,但整个过程会比操作普通App更考验细节。抖音页面是一个无限滑动的动态信息流,控件树里大量节点没有文本、资源ID重复,加上各种弹窗和权限申请,脚本想一次跑通并不容易。这篇文章是我踩了一圈坑之后整理出来的完整思路和可落地代码,从环境配置讲到元素定位,再讲到完整的自动刷视频脚本,适合正在学App自动化、或者想用Python处理日常重复App操作的朋友。需要先说明的是,本文所有内容仅用于自动化测试技术学习、个人效率工具等合规场景,不建议也不鼓励拿去做刷量、刷评论、批量关注这类违规操作。
1. 项目定位与整体思路拆解
1.1 为什么选择Appium而不是其他方案
市面上能驱动安卓App做自动化的方案有好几种,最常见的是Appium、Airtest、uiautomator2,往下还有纯ADB命令。选择Appium做抖音自动化,主要是因为它生态成熟,背后是WebDriver标准,几乎所有元素交互都有一套通用API,Python、Java、JavaScript都能写。
拿Airtest来说,它偏向图像识别,不需要控件信息也能操作,看起来很省事。但实际跑抖音这种信息流会发现,一旦视频封面颜色相近,图像识别很容易发生误判,而且同一张截图在不同分辨率设备上坐标完全不同,脚本换一台手机就要重新调。uiautomator2虽然轻量,只支持Android,但对于需要处理复杂等待、多弹窗、输入中文这类场景,它的封装没有Appium那么完整。纯ADB命令更是只能在坐标层面点击滑动,没办法根据页面内容做判断,遇到弹窗就只能盲点,不实用。
Appium的优势在于它把控件的查找、等待、点击、滑动都抽象成了统一接口,既能拿到页面XML里的真实节点,也能通过WebDriverWait做条件等待。对于抖音这种动态加载页面,这一点非常关键:我们可以等待某个控件出现再操作,而不是盲目sleep若干秒。而且Appium同时支持iOS和Android,今天学会控制安卓App,以后换到iOS端,思路基本可以复用。
1.2 抖音自动化要解决的三个核心问题
抖音和其他普通App相比,有几个非常明显的自动化难点,做之前必须想清楚。
第一个问题是控件树信息稀薄。很多图标的resource-id是同一个,text属性是空的,只看单个节点根本分辨不出来它到底是点赞、评论还是分享。这种情况占很大比例,所以定位策略不能只靠ID。
第二个问题是页面无限下滑。你滑一下,新的视频会在几百毫秒到几秒内加载出来,加载期间如果脚本急着抓取页面信息,很可能抓到上一屏的残留内容。怎么判断当前屏幕是否已经换成新视频,是整个脚本能否顺畅跑起来的关键。
第三个问题是弹窗和权限干扰。首次启动App可能弹隐私协议、通知授权,中间还会时不时出现青少年模式提醒、活动弹窗、运营广告。这些弹窗如果不处理,元素定位就会被遮住,点击也会点到错误位置。
把这三个问题作为核心设计点,后面写的每一步代码都依赖这个思路:能等就等、能判断就判断、弹窗统一拦截。脚本结构清晰,后期维护也就不会乱。
2. 环境搭建与关键配置
2.1 工具清单与版本选择
正式开始写脚本之前,先把环境准备好。我推荐的一套组合是:
- Python 3.8以上,建议直接用3.10或3.11,省得处理旧版本库冲突。
- Node.js 12以上,因为Appium Server本身是Node包。
- Appium Server 2.x,安装命令是
npm install -g appium。 - Appium Python客户端,安装命令是
pip install Appium-Python-Client。 - Android SDK Platform-Tools,里面包含
adb命令。 - 一台闲置Android手机,Android 7及以上版本都可以,系统版本太老可能对UiAutomator2支持不好。
Appium 2.x和1.x最大的区别是驱动机制变了。1.x还内置了各种端驱动,2.x需要单独安装Android驱动:
appium driver install uiautomator2安装完可以用appium driver list确认驱动状态。如果不装这个驱动,连接Android设备时会报“Could not find a driver for automationName UiAutomator2”之类的错误。
另外要注意Appium Server和Python客户端的大版本尽量匹配。如果Server用的是2.x,Appium-Python-Client最好也用2.x以上,避免老API写法失效。比如旧版常用的appium.webdriver.common.appiumby.AppiumBy在2.x依然保留,但一些TouchAction类已经被标记废弃,新代码里我会尽量避开。
2.2 真机连接与ADB调试的三步配置
连接真机是三步固定流程,每一步都不能省。
第一步,开启开发者模式。在Android手机的“设置-关于手机”里连续点击“版本号”七次,直到系统提示已经进入开发者模式。
第二步,打开USB调试。回到设置找到“开发者选项”,打开“USB调试”。如果手机有“仅充电模式下允许ADB调试”之类的选项,也顺手打开。
第三步,用数据线连接电脑。手机弹窗出现“允许USB调试吗”时,勾选“始终允许”,然后确认。接着在电脑终端执行:
adb devices看到类似ABCDEF12345 device的输出,就说明连接正常。如果显示unauthorized,说明没有点允许弹窗;如果显示offline,一般重插数据线或者重启adb服务就能解决。
拿到设备连接状态之后,还要确认抖音对应的包名和入口Activity。因为不同渠道包的包名可能不一样,我不建议直接抄网上的固定值。最可靠的办法是自己在设备上查:
adb shell pm list packages -3这个命令会列出第三方应用包名,找到你的抖音应用。如果想确认当前正在运行的Activity,可以先在手机上手动打开抖音,然后执行:
adb shell dumpsys window | grep mCurrentFocus输出里会有一行类似mCurrentFocus=Window{... 你的包名/你的入口Activity}的内容,把对应的包名和Activity记下来,后面写到desired_caps里用。
2.3 第一个最小化启动脚本
环境就绪之后,先用一个最简单的脚本验证能不能启动抖音页面。这一步排错最方便,不要一上来就堆几百行逻辑。
from appium import webdriver from appium.webdriver.common.appiumby import AppiumBy desired_caps = { "platformName": "Android", "automationName": "UiAutomator2", "deviceName": "你的设备名", "appPackage": "你的抖音包名", "appActivity": "你查到的入口Activity", "noReset": True, "autoGrantPermissions": True, "unicodeKeyboard": True, "resetKeyboard": True, "newCommandTimeout": 120, } driver = webdriver.Remote("http://127.0.0.1:4723/wd/hub", desired_caps) print(driver.current_package) print(driver.page_source[:500]) driver.quit()先解释几个重点配置。noReset设为True,意思是每次启动不清除App数据,这样权限授权、登录状态都能保留。autoGrantPermissions设为True,可以自动同意弹出来的系统权限申请,比如存储权限、麦克风权限,不用每次手动点。unicodeKeyboard和resetKeyboard是专门给中文输入准备的,后面如果需要输入评论内容,这两个配置能帮你省掉很多麻烦。
执行脚本前,先在终端启动Appium Server:
appium确认Server监听在4723端口,再运行Python脚本。如果driver.current_package能打印出你的抖音包名,说明启动成功。如果报错说找不到Activity,就回到上一步,用dumpsys window重新确认入口Activity。
3. 抖音页面元素定位与输入操作
3.1 用UIAutomator2拿到控件树
编写定位代码前,一定要先看看抖音当前页面的控件树长什么样。不要凭感觉写XPath,那样大概率找不到。
在命令行执行:
adb shell uiautomator dump /sdcard/ui.xml adb pull /sdcard/ui.xml ./ui.xml然后用文本编辑器打开ui.xml,可以看到完整的控件层级。每个节点都有text、resource-id、class、content-desc、bounds这些属性。例如:
<android.widget.TextView text="首页" resource-id="com.xxx:id/xxx" bounds="[0,1800][270,1980]"/>观察几次之后你会发现,抖音信息流里大量节点的resource-id是一样的,而且text往往是空字符串。真正能用的信息可能只有content-desc,或者某些文字节点的text。所以定位策略不能只依赖ID。
如果觉得命令行dump不够直观,也可以打开Appium Server自带的Inspector,输入设备信息和desired_caps,它会把你手机当前界面的控件树渲染出来,支持点击查看节点属性,还能直接生成XPath,对新手非常友好。
3.2 三种定位优先级
我写抖音自动化时,定位控件的优先级一般是这样排的。
第一优先是text或content-desc。比如底部导航的“首页”“朋友”“消息”“我”这些文字节点,直接用AppiumBy.XPATH配合文本匹配就能稳定拿到。
driver.find_element(AppiumBy.XPATH, "//android.widget.TextView[@text='首页']")第二优先是resource-id。如果节点ID在页面里是唯一的,用它最省事。但抖音经常出现几十个相同ID的节点,这时单独用ID就会落到一个无法预测的位置,需要配合index或者父级节点缩小范围。
第三优先是XPath层级关系。比如某个按钮没有文本,但它所在的父容器在页面的固定位置,可以通过层级路径定位。这里要注意,XPath里不要写死过多的绝对路径,因为App改版或者视频卡片结构变化都会导致脚本挂掉。尽量用contains这类模糊匹配:
driver.find_element(AppiumBy.XPATH, "//*[contains(@content-desc, '点赞')]")如果页面刷新比较慢,还需要给定位加上显式等待。我习惯封装一个通用方法:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def wait_click(driver, by, locator, timeout=10): el = WebDriverWait(driver, timeout).until( EC.element_to_be_clickable((by, locator)) ) el.click() return el这样每次点击前都会等到元素可点击,能明显减少因为页面加载慢导致的失败。
3.3 点击、滑动、输入文本示例
抖音信息流的核心操作就是三个:点击、滑动、输入。
先写一个通用的滑动手势函数。因为不同手机屏幕分辨率不同,直接写绝对坐标不合适,最好按屏幕比例的相对坐标来操作:
def swipe_up(driver, duration=300): width = driver.get_window_size()["width"] height = driver.get_window_size()["height"] start_x = width * 0.5 start_y = height * 0.8 end_y = height * 0.2 driver.swipe(start_x, start_y, start_x, end_y, duration)这里把起始点放在屏幕下方20%的位置,结束点放在屏幕上方20%,模拟常见的上滑手势。duration控制滑动速度,默认300毫秒比较贴近真实手感。
点击操作尽量用元素定位,不要用坐标。比如要点击评论按钮,可以先拿到控件树里评论节点的content-desc,再点击。如果确实遇到只有坐标能点的情况,可以用:
driver.tap([(x, y)], duration=100)不过需要提醒一下,tap的写法在不同版本Appium Python Client里略有差异,新版如果提示方法签名变化,优先改用TouchAction或者干脆用driver.execute_script来执行原生的mobile: clickGesture。这些底层接口虽然繁琐,但很实用。
输入文本主要用于评论框场景。点击评论区后,输入框才会出现。然后再用android.widget.EditText定位输入框并输入:
driver.find_element(AppiumBy.XPATH, "//*[contains(@text,'评论')]").click() time.sleep(1) edit_box = driver.find_element(AppiumBy.CLASS_NAME, "android.widget.EditText") edit_box.send_keys("这是一段自动化测试文本")这里我故意用了sleep,因为弹起输入法本身不受元素加载控制,短时间等待是合理的。unicodeKeyboard配置会保证中文能正常输入,否则很多设备上中文会丢失。
4. 自动刷视频脚本的完整实现
4.1 目标场景与脚本设计
我先说清楚这个脚本的目标:模拟真实用户逐条向上滑,每滑到新视频就抓取当前屏幕上的文字信息,并保存一份截图。它不做点赞、不写评论、不关注任何人,只是“看”和“记录”,用于自动测试UI和了解信息流结构。
设计思路分五步:
- 启动抖音,等待首页加载出来。
- 打开一个CSV文件,用来记录每条时间戳和页面提取到的文本。
- 循环指定次数,每次先处理弹窗,再提取文本和截图。
- 上滑切到下一个视频,等待几秒让页面加载完成。
- 结束后回到桌面并退出驱动。
这个结构是抖音信息流自动化的最小框架,后面不管扩展成什么样,核心都在循环里。
4.2 怎么判断当前视频已经切换
上滑动作做完后,短视频不会立刻出现完整内容。如果脚本马上抓取页面文本,很可能抓到的是上一屏的残留节点。解决办法是记录滑动前后的页面文本特征,进行对比轮询。
可以从driver.page_source里提取所有可见文本:
import re def visible_texts(driver): page_source = driver.page_source matches = re.findall(r'text="([^"]*)"', page_source) return [t for t in matches if t.strip()]然后在上滑前保存当前文本列表,上滑后每隔1秒重新提取一次,直到文本列表发生变化,或者超过最大等待时间。
def wait_new_page(driver, old_texts, timeout=8): start = time.time() while time.time() - start < timeout: new_texts = visible_texts(driver) if new_texts != old_texts: return True time.sleep(1) return False当然,如果某些视频信息没有文字,文本列表可能不变,这时候可以用截图对比或者OCR辅助。但作为最基础的方案,文本变化判断已经能覆盖大部分场景。
4.3 一个完整可运行的脚本
下面这个脚本是核心参考版本,我把代码量控制在容易理解的范围内,实际生产环境可以在这个基础上封装成类。
import csv import re import time from appium import webdriver from appium.webdriver.common.appiumby import AppiumBy from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC desired_caps = { "platformName": "Android", "automationName": "UiAutomator2", "deviceName": "你的设备名", "appPackage": "你的抖音包名", "appActivity": "你查到的入口Activity", "noReset": True, "autoGrantPermissions": True, "unicodeKeyboard": True, "resetKeyboard": True, } driver = webdriver.Remote("http://127.0.0.1:4723/wd/hub", desired_caps) wait = WebDriverWait(driver, 15) def visible_texts(): page_source = driver.page_source matches = re.findall(r'text="([^"]*)"', page_source) return [t for t in matches if t.strip()] def close_popup(): for keyword in ("同意", "我知道了", "以后再说", "取消", "确定"): try: el = driver.find_element(AppiumBy.XPATH, f"//*[@text='{keyword}']") el.click() return True except Exception: continue return False def swipe_up(): width = driver.get_window_size()["width"] height = driver.get_window_size()["height"] driver.swipe(width * 0.5, height * 0.8, width * 0.5, height * 0.2, 300) def wait_new_page(old_texts, timeout=8): start_time = time.time() while time.time() - start_time < timeout: new_texts = visible_texts() if new_texts != old_texts: return True time.sleep(1) return False with open("video_log.csv", "a", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["时间", "屏幕文本摘要"]) wait.until(EC.presence_of_element_located((AppiumBy.XPATH, "//*[@text='首页']"))) print("首页加载完成,开始自动浏览") for i in range(10): close_popup() texts = visible_texts() summary = " | ".join(texts[:10]) writer.writerow([time.strftime("%Y-%m-%d %H:%M:%S"), summary]) driver.get_screenshot_as_file(f"screen_{time.strftime('%Y%m%d_%H%M%S')}.png") print(f"已记录第{i + 1}屏,文本摘要: {summary[:50]}") old_texts = texts swipe_up() wait_new_page(old_texts) close_popup() driver.press_keycode(3) driver.quit() print("自动浏览结束")这个脚本里最值得学习的是循环顺序:先处理弹窗,再提取信息,再截图,然后滑动,最后等待新页面出现。这个顺序经过多次实践调整,能最大限度避免信息抓取到重复页面。每一屏只抓前10个文本作为摘要,避免CSV被XML冗余信息撑爆;截图则作为完整兜底,方便后续人工查看。
4.4 为什么用显式等待而不是长时间sleep
新手很容易写出time.sleep(5)这种代码,看起来省事,实际隐患很大。
抖音的加载速度受网络影响非常明显。网络好的时候1秒就出新视频,网络差的时候可能要5秒。如果固定sleep 3秒,网络慢就会抓到旧页面,网络快则白白浪费大量时间。显式等待会根据实际条件动态调整时间,页面元素已经到位就立刻继续,不需要人为估算。
脚本设计里我用wait_new_page等待页面文本发生变化,这个函数本质上是一个轮询循环,它可以处理1秒到8秒的网络波动。如果8秒还没变化,就跳过当前屏继续,不会把脚本卡死。
5. 常见问题与避坑指南
5.1 弹窗、权限和青少年模式怎么处理
抖音在不同状态下会弹各种东西,常见的有隐私协议、通知权限、青少年模式、活动弹窗。处理思路是写一个统一弹窗清理函数,把可能出现的关键词都放进去,每轮循环先调用一次。
autoGrantPermissions能解决系统权限弹窗,但解决不了App内部弹窗。App内部弹窗一般都有一个可点击按钮,关键词是“同意”“我知道了”“以后再说”“取消”这几个。把这些词写进close_popup里,每轮循环都尝试点击一次,找不到就跳过,既不干扰正常流程,也不会漏掉弹窗。
需要特别留意的是青少年模式。这个弹窗出现后,如果设备没有登录,往往需要家长验证才能关闭,自动脚本处理不了。所以实操中我会在首次手动登录并做好设置,再开启noReset让登录状态保留。脚本运行期间尽量保持账号已经完成基础设置,能省掉很多麻烦。
5.2 元素找不到、定位失效的排查顺序
当find_element报NoSuchElementException时,先不要急着改XPath,按下面顺序排查。
第一步,看元素是否存在。用adb shell uiautomator dump导出当前页面XML,搜索目标文本或ID。如果搜索不到,说明元素确实不在控件树里。
第二步,判断当前是不是WebView。抖音很多页面是原生和WebView混合的,比如一些活动页、直播间部分区域。原生节点用driver.page_source能看到,WebView里的内容看不到。执行driver.contexts会输出当前所有可用上下文,如果里面有WEBVIEW_xxx,需要切换到对应WebView再定位。
第三步,考虑App是否用了自绘渲染。抖音部分信息流会用到高度自定义的渲染方式,有时节点是存在的,但信息不完整,甚至只有一个大容器,内部细节无法拿到。这种情况下不建议死磕XPath,直接用坐标滑动和截图判断更实在。自动化不是非要拿到每个控件,很多场景用屏幕比例坐标反而更省心。
5.3 长时间运行后卡死、黑屏怎么办
脚本跑的时间长了,偶尔会遇到设备休眠导致Session失效、App进程被系统回收、甚至服务端报错。解决思路是在主流程外做好恢复动作。
最简单可靠的方法是每次循环都用try/finally兜底,确保最终一定会关闭驱动。如果循环内异常太频繁,可以在外层捕获到特定异常后,重新激活App:
try: driver.activate_app("你的抖音包名") except Exception: driver.launch_app()另外,可以在手机设置里关闭自动锁屏和屏幕超时,或者用充电线保持供电,否则设备一旦锁屏,Appium的控件树就取不到了。脚本结束前尽量执行一次driver.press_keycode(3)回到桌面,不会让手机停留在抖音界面造成误触。
5.4 关于账号使用边界的提醒
我实测跑自动化刷视频时,如果频率过高、持续时间过长,会遇到平台要求进行安全验证的情况。这说明大规模、高频率的自动化行为是可能触发限制策略的。因此,脚本里一定要控制频率和次数,建议每次运行不超过20屏,滑动间隔不要太短,不要涉及点赞、评论、关注等交互行为。
同时,建议使用自己日常使用的设备,并且保持App更新后及时检查控件树变化。平台App是不断更新的,今天能用的XPath,下次升级可能就失效了。把脚本当成测试学习项目看,不要当成能长期跑的黑盒工具。
6. 把脚本扩展成顺手小工具的几点建议
6.1 做成命令行工具
跑测试的时候总得改循环次数、输出文件名,老改代码很烦。可以用argparse把这些参数暴露成命令行参数:
python run_douyin_auto.py --times 20 --output video_log.csv然后脚本里用args.times替代固定的range(10),用args.output替代写死的CSV文件名。这样整个工具就可以交给别人用,哪怕看不懂代码也能操作。
6.2 加上日志和运行监控
刷视频过程中偶尔会碰到某一屏什么都没抓到,如果不做日志,事后很难排查。我习惯用logging模块把每次滑屏、每次异常都记录到本地文件:
import logging logging.basicConfig(filename="auto_run.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.info("开始本轮自动浏览") logging.info(f"当前第 {i + 1} 屏") logging.exception("发生异常")日志和截图放在同一个目录里,出问题时按时间戳一一对应,能很快定位是哪一步出错。
6.3 用pytest组织测试用例
如果本身是做测试工作的,可以把这套脚本改造成pytest用例。启动App和关闭App分别放在setup_method和teardown_method里,把“滑动一次并等待新视频加载”作为一个用例,循环操作通过参数化来执行。这样做的好处是测试报告清晰,每个步骤都有关联的断言,方便集成到现有测试框架里。
最后分享一个个人体会。不要一上来就想着用Appium搞定抖音全部页面,Appium的能力边界在控件树可见的范围内。像抖音这种重刷新的信息流应用,脚本最可靠的写法不是每一步都精确控点,而是在几个关键节点卡住等待,剩下的交给滑动和重试。先跑通10次,再考虑扩展成命令行工具,这是我一直用的节奏。只要每一步都按“判断状态再操作”这个思路来,这套脚本就能稳稳当当地长久用下去。