SuperAPI AI Directory

x-tweet-ego

Office & productivity

View repository

Updated 10-15 22:13

About this skill

Tweet on X with ego-browser

Field-verified in 2026-08 (posting succeeded in a real test on a personal account). 2026-08-17 added environment-difference fixes: screenshot-unavailable fallback, quote menu keyboard navigation preferred, tab replacement defense.

Dependencies (MUST READ)

  • This skill supplements the flow and pitfalls;before executing you must first read skill://ego-browser to get the full helper API (useOrCreateTaskSpace、openOrReuseTab、click、typeText、pressKey、js、cdp、completeTaskSpace and other signatures and semantics). This file does not repeat those API definitions.
  • All operations are executed via ego-browser nodejs <<'EOF' ... EOF heredoc.
  • If ego-browser is not installed in the environment, follow skill://ego-browser 's references/install.md to install it before executing.

The only reliable posting flow (MUST DO)

Complete all steps in one heredoc(open → force render → input → gate → post → verify → wrap up), do not split into multiple heredoc rounds of back-and-forth probing. Directly apply the template below, changing only task space 名 and text:

ego-browser nodejs <<'EOF'
const task = await useOrCreateTaskSpace('发推:<任务名>')
const text = '<推文正文,支持中文与 \\n 换行>'

// Generic helper (pitfalls 7/10): screenshot failure is non-fatal (it is only a force-render means), DOM polling as fallback; if the page is not on X, re-enter async function ensureMounted(sel, ms = 10000) { try { await captureScreenshot() } catch (e) { cliLog('screenshot unavailable (non-fatal): ' + e.message) } const deadline = Date.now() + ms while (Date.now() < deadline) { if (await js(String.raw!!document.querySelector(${JSON.stringify(sel)}))) return true await wait(1) } return false } async function ensureOnX(url) { const u = (await pageInfo()).url if (!u.includes('x.com')) { await gotoUrl(url); await waitForLoad({ timeout: 30 }); await wait(3) } }

// 1. Open the already-logged-in post dialog and confirm the page is really on X (pitfall 10: the tab may be replaced with a new tab) await openOrReuseTab('https://x.com/compose/post', { wait: true, timeout: 30 }) await ensureOnX('https://x.com/compose/post')

// 2. Force render + wait for the composer to mount (pitfall 7: background tab rAF freeze is thawed by screenshot; when screenshots are unavailable, animations run normally if the page is visible) if (!(await ensureMounted('[data-testid="tweetTextarea_0"]'))) { cliLog('ABORT: composer not mounted'); await completeTaskSpace(task.id, { keep: false }); process.exit(1) }

// 3. Real mouse click to focus + real key input (no problem with Chinese) await click('[data-testid="tweetTextarea_0"]', { label: 'focus composer' }) await typeText(text)

// 4. Pre-post gate: text matches exactly + button is enabled. If not satisfied, ABORT; never click post const gate = await js(String.raw(() => { const comp = document.querySelector('[data-testid="tweetTextarea_0"]') const dlg = comp ? comp.closest('[role="dialog"]') : null const btn = dlg ? dlg.querySelector('[data-testid="tweetButton"]') : null return { ok: !!comp && !!btn && comp.innerText.trim() === ${JSON.stringify(text)} && btn.disabled === false, innerText: comp ? comp.innerText : null, btnDisabled: btn ? btn.disabled : null } })()) if (!gate.ok) { cliLog('ABORT: ' + JSON.stringify(gate)); await completeTaskSpace(task.id, { keep: false }); process.exit(1) }

// 5. Post await click('[role="dialog"] [data-testid="tweetButton"]', { label: 'click post' }) await wait(5)

// 6. Verify success: dialog has closed + URL back to /home + success toast appears const result = await js(String.raw(() => ({ url: location.href, dialogOpen: !![...document.querySelectorAll('[role="dialog"]')].find(d => d.querySelector('[data-testid="tweetTextarea_0"]')), toast: [...document.querySelectorAll('[data-testid="toast"]')].map(t => t.innerText) }))()) cliLog('posted: ' + JSON.stringify(result))

// 7. Wrap up (keep:false closes the task space) await completeTaskSpace(task.id, { keep: false }) EOF


要点:
- **一步到位**:门禁与验证都内嵌在模板里,失败时输出 `ABORT` 原因即停,不需要任何中间轮次的探查。
- 门禁 `ok === false` 时绝不点发帖按钮;发帖后若 dialog 未关/URL 未跳,先读 `result` 判断,**不要重复点发帖**(可能重复发帖)。
- 只有 toast/dialog/URL 三项全部存疑时,才额外去 `https://x.com/<username>` 核对时间线顶部(`article` 的 `innerText` 首段即正文);正常情况不必,省一次导航。

## 引用推文(Quote)流程 (MUST DO)

引用入口在**转帖按钮的菜单里**(不是独立按钮):点 `[data-testid="retweet"]` → 菜单含「转帖」和「引用」两项 → 选「引用」打开引用弹窗,在弹窗里输入自己的正文。同样一次 heredoc 跑完:

```bash
ego-browser nodejs <<'EOF'
const task = await useOrCreateTaskSpace('引推:<任务名>')
const text = '<引推正文>'
const tweetUrl = 'https://x.com/<user>/status/<id>'

// 通用 helper(坑位 7/10):截图失败不致命(只是强制渲染手段),DOM 轮询兜底;页面不在 X 就重进
async function ensureMounted(sel, ms = 10000) {
  try { await captureScreenshot() } catch (e) { cliLog('screenshot unavailable (non-fatal): ' + e.message) }
  const deadline = Date.now() + ms
  while (Date.now() < deadline) {
    if (await js(String.raw`!!document.querySelector(${JSON.stringify(sel)})`)) return true
    await wait(1)
  }
  return false
}
async function ensureOnX(url) {
  const u = (await pageInfo()).url
  if (!u.includes('x.com')) { await gotoUrl(url); await waitForLoad({ timeout: 30 }); await wait(3) }
}

// 1. 打开原推,确认页面真的在 X(坑位 10)
await openOrReuseTab(tweetUrl, { wait: true, timeout: 30 })
await ensureOnX(tweetUrl)
if (!(await ensureMounted('article', 15000))) { cliLog('ABORT: tweet page not loaded'); await completeTaskSpace(task.id, { keep: false }); process.exit(1) }

// 2. 主推 = 详情页第一个 article,其转帖按钮滚进视口再点(坑位 8;不要用 aria-label 里的转帖数——实时变化会过期)
await js(String.raw`(() => {
  const art = document.querySelector('article')
  const btn = art ? art.querySelector('[data-testid="retweet"]') : null
  if (!btn) return false
  btn.scrollIntoView({ block: 'center' })
  return true
})()`)
await wait(1)
await click('xpath=(//article)[1]//button[@data-testid="retweet"]', { label: 'open repost menu' })
await wait(2)

// 3. 等菜单出现(「引用」span 存在)
const menuOk = await js(String.raw`!![...document.querySelectorAll('span')].find(s => s.textContent.trim() === '引用')`)
if (!menuOk) { cliLog('ABORT: quote item not in menu'); await completeTaskSpace(task.id, { keep: false }); process.exit(1) }

// 4. 键盘选「引用」优先(坑位 9):菜单打开后焦点自动落在「转帖」menuitem → ↓ 到「引用」→ Enter。
//    每步验证焦点,绝不盲按 Enter(焦点若还在「转帖」上按 Enter 会直接转帖 = 误操作)
const f1 = await js(String.raw`(() => { const a = document.activeElement; return a ? { role: a.getAttribute('role'), text: (a.textContent || '').trim() } : null })()`)
if (f1 && f1.role === 'menuitem' && f1.text.startsWith('转帖')) {
  await pressKey('ArrowDown')
  await wait(1)
  const f2 = await js(String.raw`(() => { const a = document.activeElement; return a ? (a.textContent || '').trim() : '' })()`)
  if (f2.startsWith('引用')) { await pressKey('Enter') }
}
let mounted = await ensureMounted('[role="dialog"] [data-testid="tweetTextarea_0"]', 8000)
if (!mounted) {
  // 兜底:坐标点「引用」span(菜单无 menuitem/无焦点的环境),先滚进视口再取坐标
  const rect = await js(String.raw`(() => {
    const s = [...document.querySelectorAll('span')].find(s => s.textContent.trim() === '引用')
    if (!s) return null
    s.scrollIntoView({ block: 'center' })
    const r = s.getBoundingClientRect()
    return { x: r.x + r.width/2, y: r.y + r.height/2 }
  })()`)
  if (rect) { await click([rect.x, rect.y], { label: 'click quote' }) }
  mounted = await ensureMounted('[role="dialog"] [data-testid="tweetTextarea_0"]', 8000)
}
if (!mounted) { cliLog('ABORT: quote dialog not mounted'); await completeTaskSpace(task.id, { keep: false }); process.exit(1) }

// 5. 聚焦引用弹窗 composer + 真实按键输入(composer/按钮 testid 与普通发帖相同,用 dialog 限定)
await click('[role="dialog"] [data-testid="tweetTextarea_0"]', { label: 'focus quote composer' })
await typeText(text)

// 6. 门禁:文本完全匹配 + 按钮可用。不满足就 ABORT,绝不点发帖
const gate = await js(String.raw`(() => {
  const comp = document.querySelector('[role="dialog"] [data-testid="tweetTextarea_0"]')
  const btn = document.querySelector('[role="dialog"] [data-testid="tweetButton"]')
  return {
    ok: !!comp && !!btn && comp.innerText.trim() === ${JSON.stringify(text)} && btn.disabled === false,
    innerText: comp ? comp.innerText : null,
    btnDisabled: btn ? btn.disabled : null
  }
})()`)
if (!gate.ok) { cliLog('ABORT: ' + JSON.stringify(gate)); await completeTaskSpace(task.id, { keep: false }); process.exit(1) }

// 7. 发帖 + 验证:引用成功后 URL 停在原推页面(不回 /home),以 dialog 关闭 + toast 为准
await click('[role="dialog"] [data-testid="tweetButton"]', { label: 'click post' })
await wait(6)
const result = await js(String.raw`(() => ({
  dialogOpen: !![...document.querySelectorAll('[role="dialog"]')].find(d => d.querySelector('[data-testid="tweetTextarea_0"]')),
  toast: [...document.querySelectorAll('[data-testid="toast"]')].map(t => t.innerText)
}))()`)
cliLog('posted: ' + JSON.stringify(result))

await completeTaskSpace(task.id, { keep: false })
EOF

Pitfall list

1. The composer is a contenteditable div, not a textarea

  • fillInput will write the text into the DOM, butdoes not trigger React onChange → the post button stays disabled. Button disabled = it was not written in.
  • Must click focus + typeText(real key events).
  • To read the content use .innerText;.value which is always an empty string, don't be misled.

2. Two composers exist on the page at the same time

  • The post dialog and the inline composer on the home page are both in the DOM.
  • Dialog post button:[role="dialog"] [data-testid="tweetButton"](the only correct button)
  • Home page inline button:[data-testid="tweetButtonInline"](disabled when content is empty, not the target)
  • Both composers' testid are tweetTextarea_0; use closest('[role="dialog"]') to scope it. The first one in the DOM is the dialog's.

3. Do not use document.execCommand('selectAll' / 'delete') to clear

  • This clears the DOM but leaves React state residual: the button is still enabled but the content is empty, and any subsequent input becomes ineffective.
  • To clear, use real keystrokes (after focusing, Meta+a + Backspace), or Escape to close and reopen the dialog.

4. Synthetic clicks do not work

  • element.click()(called directly inside js) X may not respond. Always use the helper click(selector, { label }) for real mouse clicks.

5. Do not use navigator.clipboard.writeText

  • In isolated task space it hangs permanently, causing Runtime.evaluate to time out.

6. CDP Input.insertText is also unreliable

  • Tested ineffective on React controlled contenteditable.typeText is the only verified feasible input path.

7. Background tab freezes rAF: the dialog "not appearing" is actually not rendered (reproducible, 2026-08 tested 10/10)

  • Symptom:openOrReuseTab('https://x.com/compose/post') After , the URL stays at /compose/post, but on the pagethere is no dialog, no composer, no tweetButton; waiting/polling for any amount of time will not make them appear.
  • But the route is actually already active: at this point press Escape, and the URL will jump back to /x.com/home, indicating the dialog is already mounted, just not visible.
  • Root cause: the isolated task space's tab is in the background (document.visibilityState === 'hidden'), the browser freezes requestAnimationFrame, X's modal open animation (0.15s fade-in + scale) is forever stuck on the first frame:opacity: 0、scale(0.92)、animation-play-state: running does not advance. The dialog content (composer/button) only mounts to the DOM after the animation completes.
  • Fix (verified): after opening the pageimmediately await captureScreenshot()—the screenshot forces one frame to be composited, the animation instantly completes, and the composer and tweetButton mount immediately. Everything is normal afterward.
  • Ineffective means:Page.bringToFront(the isolated window itself is hidden, visibility does not change), long waits, polling, reload. Only an operation that forces a composited frame (a screenshot) can resolve it.
  • Environment differences (tested 2026-08-17): in some environments Page.captureScreenshot will cause a CDP timeout (screenshots completely unavailable). In this case:
    • If document.visibilityState === 'visible': the animation runs normally, screenshots are not required—the template ensureMounted already uses DOM polling as a fallback, and screenshot failure only warns without interrupting.
    • If the page hidden and screenshots are unavailable: rAF cannot be unfrozen,ensureMounted will time out → ABORT; switch to a real tab / reopen the window and retry.
  • Screenshots are only aforced rendering means, not a verification means: verification always relies on the gate DOM (gate); do not take screenshots just to "confirm".

8. Elements outside the viewport: coordinate/selector clicks silently fail

  • Symptom: for long tweets (especially after clicking "Show more" to expand the full text), the action bar is at the bottom of the page, andgetBoundingClientRect() 's y far exceeds the viewport height (measured retweet button y≈1196 vs viewport height 805). In this case, neither helper coordinate clicks nor selector clicks take effect—the menu/dialog does not appear, andNo error is reported at all, making it easy to misjudge it as “X's behavior changed”.
  • Fix: before clicking, first scrollIntoView({ block: 'center' }) scroll the target into the viewport, then click.
  • The detail page contains multiple [data-testid="retweet"]at the same time (one each for the main post + related post). To lock onto the main post, use (//article)[1]//button[@data-testid="retweet"](the first article on the detail page is the main post).Do not use the repost count in aria-label— the repost count changes in real time, and the numbers in the feed/screenshots will become stale (measured on 2026-08-17: 129 continued to grow within a few minutes).

9. The Quote entry is in the repost button's menu; keyboard navigation is preferred

  • Quote tweet is not a separate button: after clicking [data-testid="retweet"] , a menu pops up containing “Repost” and “Quote”; “Quote” opens an ordinary composer with the original text embedded (the testid is still tweetTextarea_0 / tweetButton, just use [role="dialog"] to narrow it down, see the Quote flow above).
  • Keyboard navigation is preferred (succeeded in one attempt in testing on 2026-08-17): after the menu opens, focus automatically lands on the “Repost” menuitem (in this environment the menu items have role="menuitem", unlike earlier versions where “bare spans had no menuitem”), then↓ to “Quote” and Enter to open the quote dialog.Before pressing Enter, you must verify that focus is on “Quote”— if focus is still on “Repost” and you press Enter, it will directly repost (a misoperation). Step 4 of the template already has built-in step-by-step focus verification.
  • Coordinate click as fallback: in environments without menuitem/no focus, find the “Quote” span by text, and afterscrollIntoView({ block: 'center' }) take the center coordinates and perform a real mouse click (pitfall 4: do not use element.click())。
  • After a successful quote, the URL stays on the original tweet's page (it does not return to /home); verification is based on the dialog closing + the toast “Your post was sent”.

10. The tab may be silently replaced with a new tab (measured on 2026-08-17)

  • Symptom: during execution, the tab quietly becomes chrome://new-tab-page/, the page content is completely lost, and all subsequent selectors/coordinates fail to match with no error reported at all.
  • The cause is unknown (suspected to be related to the task space/tab management); it cannot be prevented for now, only detected and recovered from.
  • Fix: after opening the URL, first pageInfo() validate that url contains the target domain (the template ensureOnX already has this built in); if it does not match, gotoUrl(url) + waitForLoad({ timeout: 30 }) re-enter, then continue.

Other

  • After posting, if the dialog has not closed/the URL has not changed, do not repeatedly click post (this may post twice); read the page state first.
  • If the page crashes/is stuck at chrome-error:// , use gotoUrl(url) + waitForLoad to re-enter instead of gotoAndWait。
  • After completion, completeTaskSpace(id, { keep: false }) to wrap up.
  • Token-saving principle: run the entire posting flow in one heredoc (see the template above). Do not stop midway to take screenshots/snapshots/multiple rounds of confirmation — all verification is embedded in the script gates. Do not interrupt if a screenshot fails (see pitfall 7); it is only a forced-rendering measure. A second round of investigation is only needed when there is an ABORT or the posted result is in doubt.