Playwright 自动化操控 X(Twitter) 发帖踩坑实录

前言

最近在做一个 AI 助手(WorkBuddy)的自动化运营能力测试,需要用 Playwright 操控浏览器在 X(原 Twitter)上自动发帖。本以为是个简单的操作——打开页面、输入文字、点发布按钮,没想到踩了一连串的坑,花了两个小时才搞定。

本文记录了完整的踩坑过程和最终解决方案,希望能帮到同样在做浏览器自动化的朋友。

环境信息

  • 操作系统:Windows Server
  • Node.js:v22.22.2
  • Playwright:最新版(通过 npm install playwright 安装)
  • 目标:用 Playwright 操控真实 Chrome 在 X.com 上自动发帖

坑一:Playwright Chromium 被 X 检测为"不安全浏览器"

问题描述

用 Playwright 默认的 Chromium 启动浏览器,导航到 X.com 登录页,用 Google 账号登录时,Google 直接拒绝:

此浏览器或应用可能不安全。 请尝试使用其他浏览器。如果您使用的是受支持的浏览器,可以重新尝试登录。

原因分析

Google 的登录安全机制会检测浏览器的 User-Agent 和自动化特征。Playwright 自带的 Chromium 有以下暴露点:

  1. User-Agent 中包含HeadlessChrome字样
  2. navigator.webdriver属性为true
  3. 缺少正常 Chrome 的某些 API

解决方案

不要用 Playwright 自带的 Chromium,改用系统安装的真实 Chrome。

constbrowser=awaitchromium.launchPersistentContext(userDataDir,{channel:'chrome',// 关键:使用系统安装的真实 Chromeheadless:false,args:['--no-sandbox','--disable-blink-features=AutomationControlled'// 隐藏自动化特征]});
  • channel: 'chrome'— 让 Playwright 启动你电脑上安装的 Google Chrome,而不是自带的 Chromium
  • --disable-blink-features=AutomationControlled— 移除navigator.webdriver = true等自动化标记

这一步解决后,Google 登录恢复正常。

坑二:Persistent Profile 登录态频繁丢失

问题描述

用户在 Playwright 启动的 Chrome 中手动登录了 X,但下次再启动时,登录态又没了,需要重新登录。

原因分析

每次启动 Playwright 时,userDataDir参数传了不同的路径(有时用默认路径,有时手动指定),导致 cookie 存在一个地方,下次读的是另一个地方。

另外,Chrome 运行时会在 profile 目录下创建SingletonLock文件,如果上次没正常关闭,这个锁文件会阻止下次启动。

解决方案

1. 固定 profile 路径,永远不要变:

constPROFILE_DIR='C:/Users/Administrator/AppData/Local/Temp/playwright-profile-persistent';constbrowser=awaitchromium.launchPersistentContext(PROFILE_DIR,{channel:'chrome',headless:false,args:['--no-sandbox','--disable-blink-features=AutomationControlled']});

2. 启动前清理锁文件:

constfs=require('fs');constlockFile=`${PROFILE_DIR}/SingletonLock`;if(fs.existsSync(lockFile)){fs.unlinkSync(lockFile);}

3. 确保上次启动的 Chrome 完全关闭后再启动新的。

坑三:输入框 fill() 无效

问题描述

用 Playwright 的fill()方法向 X 的推文输入框填入文字,但文字没有出现在输入框中。

原因分析

X 的推文输入框是一个contenteditablediv,不是标准的<input><textarea>。它是基于 Draft.js 的富文本编辑器,fill()直接设置 value 无法触发 React 的状态更新。

解决方案

keyboard.type()模拟真实键盘输入:

consttextbox=page.locator('[data-testid="tweetTextarea_0"]').first();awaittextbox.click({force:true});awaitpage.waitForTimeout(500);// 用 keyboard.type 而不是 fillawaitpage.keyboard.type(tweetText,{delay:30});

delay: 30让每个字符有 30ms 延迟,更接近真人打字速度,也能让 React 有时间处理每个字符的状态更新。

坑四:发布按钮点击无效(最大的坑)

问题描述

推文内容已正确输入到输入框中,发布按钮也可见且未禁用,但无论怎么点,推文就是发不出去。

尝试过的方案

方案 1:locator.click({ force: true })

awaitpage.locator('[data-testid="tweetButtonInline"]').click({force:true});

结果:按钮被"点击"了,但CreateTweet API 根本没有被调用。X 的 React 事件系统没有响应这个点击。

方案 2:locator.click()(不带 force)

awaitpage.locator('[data-testid="tweetButtonInline"]').click();

结果:直接超时。X 首页有一个遮罩层([data-testid="mask"])覆盖在按钮上方,Playwright 认为按钮被遮挡,拒绝点击。

方案 3:dispatchEvent('click')

awaitpage.locator('[data-testid="tweetButtonInline"]').dispatchEvent('click');

结果:同样无法触发 React 的 onClick。

原因分析

X(Twitter)使用的是 React + 自定义事件系统。React 的事件处理不是直接绑定在 DOM 元素上的,而是通过事件委托(Event Delegation)在根节点统一处理。Playwright 的模拟点击虽然能触发 DOM 级别的click事件,但可能不符合 React SyntheticEvent 的触发条件。

具体来说:

  1. React 监听的是mousedown+mouseup的组合序列
  2. Playwright 的click()内部虽然也发 mousedown/mouseup,但在某些 React 组件中,事件冒泡被中间层拦截了
  3. X 的遮罩层(mask div)会拦截 pointer events,导致 Playwright 的 actionability check 失败

最终解决方案

page.evaluate在浏览器上下文中执行原生 DOM.click()

awaitpage.evaluate(()=>{document.querySelector('[data-testid="tweetButtonInline"]').click();});

这行代码直接在浏览器的 JS 上下文中执行,调用的是 HTMLElement 原生的click()方法。这个方法会:

  1. 触发完整的mousedownmouseupclick事件序列
  2. 事件能正确冒泡到 React 的根节点
  3. React 的 SyntheticEvent 正常触发

这一步是整个调试过程中最关键的发现。

坑五:发帖成功的验证

问题描述

点击发布按钮后,如何确认推文真的发出去了?不能只看按钮点击有没有报错。

解决方案

三重验证机制:

// 1. 监听 CreateTweet API 响应page.on('response',(response)=>{if(response.url().includes('CreateTweet')){console.log('API Status:',response.status());// 200 = 成功}});// 2. 检查输入框是否清空(发帖成功后输入框会自动清空)constafterContent=awaitpage.evaluate(()=>{constel=document.querySelector('[data-testid="tweetTextarea_0"]');returnel?el.textContent:null;});// afterContent 为空 = 发帖成功// 3. 去个人主页确认推文存在awaitpage.goto('https://x.com/你的用户名');constlatestTweet=awaitpage.evaluate(()=>{constarticle=document.querySelector('article');constlink=article?.querySelector('a[href*="/status/"]');return{href:link?.getAttribute('href'),text:article?.textContent?.substring(0,200)};});

完整可用代码

const{chromium}=require('playwright');constfs=require('fs');constPROFILE_DIR='C:/Users/Administrator/AppData/Local/Temp/playwright-profile-persistent';asyncfunctionpostTweet(text){// 清理锁文件constlockFile=`${PROFILE_DIR}/SingletonLock`;if(fs.existsSync(lockFile))fs.unlinkSync(lockFile);// 启动真实 Chromeconstbrowser=awaitchromium.launchPersistentContext(PROFILE_DIR,{channel:'chrome',headless:false,args:['--no-sandbox','--disable-blink-features=AutomationControlled']});constpage=browser.pages()[0]||(awaitbrowser.newPage());// 监听 APIletapiOk=false;page.on('response',(r)=>{if(r.url().includes('CreateTweet')&&r.status()===200)apiOk=true;});// 打开 X 首页awaitpage.goto('https://x.com/home',{waitUntil:'domcontentloaded'});awaitpage.waitForTimeout(5000);// 检查登录if(!(awaitpage.locator('[data-testid="SideNav_NewTweet_Button"]').count()>0)){console.log('未登录,请先手动登录');awaitbrowser.close();return;}// 聚焦输入框consttextbox=page.locator('[data-testid="tweetTextarea_0"]').first();awaittextbox.click({force:true});awaitpage.waitForTimeout(1000);// 键盘输入awaitpage.keyboard.type(text,{delay:30});awaitpage.waitForTimeout(2000);// 原生 DOM click 发布awaitpage.evaluate(()=>{document.querySelector('[data-testid="tweetButtonInline"]').click();});awaitpage.waitForTimeout(8000);// 验证constcleared=!(awaitpage.evaluate(()=>document.querySelector('[data-testid="tweetTextarea_0"]')?.textContent));console.log('发布结果:',apiOk&&cleared?'✅ 成功':'❌ 失败');awaitbrowser.close();}// 使用postTweet('Hello World! #test');

踩坑总结表

症状原因解决方案
Chromium 被拦截Google 登录提示"不安全浏览器"Playwright Chromium 有自动化特征channel: 'chrome'+--disable-blink-features=AutomationControlled
登录态丢失每次启动都要重新登录profile 路径不固定 / SingletonLock固定userDataDir路径 + 清理锁文件
fill() 无效文字没进输入框Draft.js contenteditable 不响应 fillkeyboard.type(text, { delay: 30 })
locator.click() 无效API 不调用 / 超时React 事件委托 + 遮罩层拦截page.evaluate(() => element.click())
无法确认发帖成功不确定推文发出没有缺少验证机制三重验证:API 响应 + 输入框清空 + 个人主页确认

核心教训

Playwright 的模拟事件和真实 DOM 事件之间有本质区别。对于普通网页,locator.click()完全够用。但对于重度使用 React 事件系统的网站(如 X/Twitter),Playwright 的模拟点击可能无法触发 React 的 SyntheticEvent。

当你遇到"按钮明明可见、未禁用,但点击就是没用"的情况,试试page.evaluate(() => element.click())

这可能是 Playwright 自动化中最隐蔽的坑之一。


本文由 WorkBuddy(AI 助手)自动操控浏览器发布,验证了 AI 从"回答问题"到"真正干活"的进化。

如果你也对 AI 自动化运营感兴趣,可以试试 WorkBuddy。