feat: post tweet (#86)

* feat: post tweet
* fix: import
* refactor: constants
* feat: X settings
This commit is contained in:
RainbowBird
2025-03-18 10:01:39 +08:00
committed by GitHub
parent d99f358f14
commit 6c2e36e15c
13 changed files with 362 additions and 66 deletions
+5 -2
View File
@@ -121,10 +121,9 @@ settings:
sections:
section:
provider-voice-selection:
description: Select the suitable speech provider
title: Provider
custom_model_placeholder: Enter custom model name...
custom_voice_placeholder: Enter custom voice ID...
description: Select the suitable speech provider
no_models: No models available
no_models_description: No models were found for this provider
no_voices: No voices available
@@ -135,8 +134,12 @@ settings:
search_voices_results: Found {count} of {total} voices
show_less: Show less
show_more: Show more
title: Provider
title: Speech
title: Modules
x:
description: X / Twitter browsing and usage
title: X / Twitter
vision:
description: Vision
title: Vision
+3
View File
@@ -127,6 +127,9 @@ settings:
title: 提供商
title: 发声
title: 机体模块
x:
description: X / Twitter 的浏览和使用
title: X / Twitter
vision:
description: 视觉
title: 视觉
@@ -92,6 +92,14 @@ const modulesList = computed<Module[]>(() => [
to: '',
configured: false,
},
{
id: 'x',
name: t('settings.pages.modules.x.title'),
description: t('settings.pages.modules.x.description'),
icon: 'i-simple-icons:x',
to: '',
configured: false,
},
])
</script>
@@ -4,7 +4,7 @@ import type { Config } from '../../config/types'
import fs from 'node:fs'
import { chromium } from 'playwright'
import { TWITTER_SESSION_FILE } from '../../../constants'
import { TWITTER_SESSION_FILE } from '../../constants'
import { logger } from '../../utils/logger'
export interface Context {
@@ -2,7 +2,7 @@ import type { Cookie } from 'playwright'
import type { TwitterService } from '../../types/services'
import type { Context } from '../browser/context'
import { TWITTER_LOGIN_URL, TWITTER_SESSION_FILE } from '../../../constants'
import { TWITTER_LOGIN_URL, TWITTER_SESSION_FILE } from '../../constants'
import { logger } from '../../utils/logger'
export function useTwitterAuthServices(ctx: Context): TwitterService {
@@ -2,10 +2,11 @@ import type { TwitterService } from '../../types/services'
import type { Context } from '../browser/context'
import type { Tweet } from './tweet'
import { TWITTER_HOME_URL } from '../../../constants'
import { TWITTER_HOME_URL } from '../../constants'
import { SELECTORS } from '../../parsers/selectors'
import { TweetParser } from '../../parsers/tweet-parser'
import { logger } from '../../utils/logger'
import { SELECTORS } from '../../utils/selectors'
import { scrollToLoadMoreTweets } from '../utils/scroll-helper'
/**
* Timeline Options
@@ -30,7 +31,7 @@ export function useTwitterTimelineServices(ctx: Context): TwitterService {
// Optional: scroll to load more tweets if needed
if (options.count && options.count > 5) {
await scrollToLoadMoreTweets(Math.min(options.count, 20))
await scrollToLoadMoreTweets(ctx.page, Math.min(options.count, 20))
}
// Parse all tweets directly from the DOM using Playwright
@@ -62,49 +63,6 @@ export function useTwitterTimelineServices(ctx: Context): TwitterService {
}
}
async function scrollToLoadMoreTweets(targetCount: number): Promise<void> {
try {
// Initial tweet count
let previousTweetCount = 0
let currentTweetCount = await countVisibleTweets()
let scrollAttempts = 0
const maxScrollAttempts = 10
logger.timeline.log(`Initial tweet count: ${currentTweetCount}, target: ${targetCount}`)
// Scroll until we have enough tweets or reach maximum scroll attempts
while (currentTweetCount < targetCount && scrollAttempts < maxScrollAttempts) {
// Scroll down using Playwright's mouse wheel simulation
await ctx.page.mouse.wheel(0, 800)
// Wait for new content to load
await ctx.page.waitForTimeout(1000)
// Check if we have new tweets
previousTweetCount = currentTweetCount
currentTweetCount = await countVisibleTweets()
// If no new tweets were loaded, we might have reached the end
if (currentTweetCount === previousTweetCount) {
scrollAttempts++
}
else {
scrollAttempts = 0 // Reset counter if we're still loading tweets
}
logger.timeline.debug(`Scrolled for more tweets: ${currentTweetCount}/${targetCount}`)
}
}
catch (error) {
logger.timeline.error('Error while scrolling for more tweets:', (error as Error).message)
}
}
async function countVisibleTweets(): Promise<number> {
const tweetElements = await ctx.page.$$(SELECTORS.TIMELINE.TWEET)
return tweetElements.length
}
return {
getTimeline,
}
@@ -1,6 +1,11 @@
import type { TwitterService } from '../../types/services'
import type { Context } from '../browser/context'
import { TWITTER_BASE_URL, TWITTER_HOME_URL, TWITTER_SEARCH_URL } from '../../constants'
import { SELECTORS } from '../../parsers/selectors'
import { TweetParser } from '../../parsers/tweet-parser'
import { scrollToLoadMoreTweets } from '../utils/scroll-helper'
/**
* Tweet Interface
*/
@@ -43,25 +48,282 @@ export interface TweetDetail extends Tweet {
quotedTweet?: Tweet
}
export function useTwitterTweetServices(_ctx: Context): TwitterService {
async function searchTweets(_query: string, _options?: SearchOptions): Promise<Tweet[]> {
throw new Error('Search feature not yet implemented')
export function useTwitterTweetServices(ctx: Context): TwitterService {
/**
* Searches for tweets based on the provided query and options
*/
async function searchTweets(query: string, options: SearchOptions = {}): Promise<Tweet[]> {
try {
const page = ctx.page
const searchUrl = new URL(TWITTER_SEARCH_URL)
searchUrl.searchParams.append('q', query)
if (options.filter) {
searchUrl.searchParams.append('f', options.filter)
}
await page.goto(searchUrl.toString())
await page.waitForSelector(SELECTORS.TIMELINE.TWEET)
// Apply filter if specified
if (options.filter) {
switch (options.filter) {
case 'latest':
await page.click(SELECTORS.SEARCH.LATEST_TAB)
break
case 'top':
await page.click(SELECTORS.SEARCH.TOP_TAB)
break
case 'photos':
case 'videos':
// These require custom filter selection from the filter button
await page.click(SELECTORS.SEARCH.SEARCH_FILTERS)
await page.click(`[role="menuitem"]:has-text("${options.filter === 'photos' ? 'Photos' : 'Videos'}")`)
break
}
}
// Wait for content to load after filter change
await page.waitForSelector(SELECTORS.TIMELINE.TWEET)
// Use the TweetParser to extract tweets
let tweets = await TweetParser.parseTimelineTweets(page)
// Limit tweets to count if specified
if (options.count && options.count > 0) {
tweets = tweets.slice(0, options.count)
}
return tweets
}
catch (error: unknown) {
console.error('Error searching tweets:', error)
throw new Error(`Failed to search tweets: ${error instanceof Error ? error.message : String(error)}`)
}
}
async function likeTweet(_tweetId: string): Promise<boolean> {
throw new Error('Like feature not yet implemented')
/**
* Likes a tweet with the given ID
*/
async function likeTweet(tweetId: string): Promise<boolean> {
try {
const page = ctx.page
await page.goto(`${TWITTER_BASE_URL}/i/status/${tweetId}`)
await page.waitForSelector(SELECTORS.TIMELINE.TWEET)
const likeButton = await page.$(SELECTORS.TIMELINE.LIKE_BUTTON)
if (!likeButton) {
throw new Error('Like button not found')
}
// Check if already liked
const isAlreadyLiked = await page.$eval(
SELECTORS.TIMELINE.LIKE_BUTTON,
el => el.getAttribute('aria-pressed') === 'true',
)
if (!isAlreadyLiked) {
await likeButton.click()
// Wait for the like to register
await page.waitForFunction(
`document.querySelector('${SELECTORS.TIMELINE.LIKE_BUTTON}')?.getAttribute('aria-pressed') === 'true'`,
{ timeout: 5000 },
)
}
return true
}
catch (error: unknown) {
console.error('Error liking tweet:', error)
throw new Error(`Failed to like tweet: ${error instanceof Error ? error.message : String(error)}`)
}
}
async function retweet(_tweetId: string): Promise<boolean> {
throw new Error('Retweet feature not yet implemented')
/**
* Retweets a tweet with the given ID
*/
async function retweet(tweetId: string): Promise<boolean> {
try {
const page = ctx.page
await page.goto(`${TWITTER_BASE_URL}/i/status/${tweetId}`)
await page.waitForSelector(SELECTORS.TIMELINE.TWEET)
// Click retweet button to open modal
const retweetButton = await page.$(SELECTORS.TIMELINE.RETWEET_BUTTON)
if (!retweetButton) {
throw new Error('Retweet button not found')
}
await retweetButton.click()
// Wait for retweet confirmation dialog and click it
await page.waitForSelector('[data-testid="retweetConfirm"]')
await page.click('[data-testid="retweetConfirm"]')
// Wait for the retweet to register
await page.waitForFunction(
`document.querySelector('${SELECTORS.TIMELINE.RETWEET_BUTTON}')?.getAttribute('aria-pressed') === 'true'`,
{ timeout: 5000 },
)
return true
}
catch (error: unknown) {
console.error('Error retweeting:', error)
throw new Error(`Failed to retweet: ${error instanceof Error ? error.message : String(error)}`)
}
}
async function postTweet(_content: string, _options?: PostOptions): Promise<string> {
throw new Error('Post tweet feature not yet implemented')
/**
* Posts a new tweet with the given content and options
*/
async function postTweet(content: string, options: PostOptions = {}): Promise<string> {
try {
const page = ctx.page
// Go to home page where you can compose a tweet
await page.goto(TWITTER_HOME_URL)
// Wait for the tweet composer to load
await page.waitForSelector(SELECTORS.COMPOSE.TWEET_INPUT)
// Type the tweet content
await page.click(SELECTORS.COMPOSE.TWEET_INPUT)
await page.type(SELECTORS.COMPOSE.TWEET_INPUT, content)
// Handle media uploads if any
if (options.media && options.media.length > 0) {
// Click the media button
await page.click(SELECTORS.COMPOSE.MEDIA_BUTTON)
// Wait for the file input and upload files
await page.setInputFiles('input[type="file"][multiple]', options.media)
// Wait for media to upload
await page.waitForSelector('[data-testid="attachments"]')
}
// Handle reply case
if (options.inReplyTo) {
// For reply, we need to navigate to the tweet first and click reply
await page.goto(`${TWITTER_BASE_URL}/i/status/${options.inReplyTo}`)
await page.waitForSelector(SELECTORS.TIMELINE.REPLY_BUTTON)
await page.click(SELECTORS.TIMELINE.REPLY_BUTTON)
// Wait for reply composer and type content
await page.waitForSelector(SELECTORS.COMPOSE.TWEET_INPUT)
await page.click(SELECTORS.COMPOSE.TWEET_INPUT)
await page.type(SELECTORS.COMPOSE.TWEET_INPUT, content)
// If there's media, handle it
if (options.media && options.media.length > 0) {
await page.click(SELECTORS.COMPOSE.MEDIA_BUTTON)
await page.setInputFiles('input[type="file"][multiple]', options.media)
await page.waitForSelector('[data-testid="attachments"]')
}
}
// Click the tweet button
await page.click(SELECTORS.COMPOSE.TWEET_BUTTON)
// Wait for the tweet to be posted
await page.waitForSelector('[data-testid="toast"]', { timeout: 10000 })
// Extract the tweet ID - we need to get the URL of the newly created tweet
// Twitter usually redirects to the tweet page or shows a toast with a link
let tweetId = ''
try {
// Try to find the link in the toast
const toastLink = await page.$('[data-testid="toast"] a[href*="/status/"]')
if (toastLink) {
const href = await toastLink.getAttribute('href')
if (href) {
const match = href.match(/\/status\/(\d+)/)
tweetId = match?.[1] || ''
}
}
// If we couldn't get the ID from the toast, check the current URL
if (!tweetId) {
const url = await page.url()
const match = url.match(/\/status\/(\d+)/)
tweetId = match?.[1] || ''
}
}
catch {
// If we fail to get the ID, generate a temporary one
tweetId = `temp-${Date.now()}`
}
return tweetId
}
catch (error: unknown) {
console.error('Error posting tweet:', error)
throw new Error(`Failed to post tweet: ${error instanceof Error ? error.message : String(error)}`)
}
}
async function getTweetDetails(_tweetId: string): Promise<TweetDetail> {
throw new Error('Get tweet details feature not yet implemented')
/**
* Gets detailed information about a specific tweet
*/
async function getTweetDetails(tweetId: string): Promise<TweetDetail> {
try {
const page = ctx.page
await page.goto(`${TWITTER_BASE_URL}/i/status/${tweetId}`)
await page.waitForSelector(SELECTORS.TIMELINE.TWEET)
// Get the main tweet element
const tweetElement = await page.$(SELECTORS.TIMELINE.TWEET)
if (!tweetElement) {
throw new Error('Tweet element not found')
}
// Use the TweetParser to extract the main tweet data
const mainTweet = await TweetParser.extractTweetData(page, tweetElement)
if (!mainTweet) {
throw new Error('Failed to extract tweet data')
}
// Check for quoted tweet
let quotedTweet: Tweet | undefined
const quotedTweetElement = await page.$('[data-testid="quotedTweet"]')
if (quotedTweetElement) {
const extractedQuotedTweet = await TweetParser.extractTweetData(page, quotedTweetElement)
if (extractedQuotedTweet) {
quotedTweet = extractedQuotedTweet
}
}
// Get replies by scrolling to load more using reusable scroll logic
const replySelector = '[data-testid="tweet"][aria-labelledby*="reply"]'
// Try to load at least 10 replies (if available)
await scrollToLoadMoreTweets(page, 10, replySelector)
// Find reply tweets
const replyElements = await page.$$(replySelector)
const replies: Tweet[] = []
for (const replyElement of replyElements) {
const extractedReply = await TweetParser.extractTweetData(page, replyElement)
if (extractedReply) {
replies.push(extractedReply)
}
}
// Construct the detailed tweet
const tweetDetail: TweetDetail = {
...mainTweet,
replies: replies.length > 0 ? replies : undefined,
quotedTweet,
}
return tweetDetail
}
catch (error: unknown) {
console.error('Error getting tweet details:', error)
throw new Error(`Failed to get tweet details: ${error instanceof Error ? error.message : String(error)}`)
}
}
return {
@@ -1,7 +1,7 @@
import type { TwitterService } from '../../types/services'
import type { Context } from '../browser/context'
import { TWITTER_BASE_URL } from '../../../constants'
import { TWITTER_BASE_URL } from '../../constants'
import { logger } from '../../utils/logger'
/**
@@ -0,0 +1,62 @@
import type { Page } from 'playwright'
import { SELECTORS } from '../../parsers/selectors'
import { logger } from '../../utils/logger'
/**
* Scroll the page to load more tweets
* @param page Playwright page instance
* @param targetCount Target number of tweets to load
* @param selector Tweet element selector
*/
export async function scrollToLoadMoreTweets(
page: Page,
targetCount: number,
selector = SELECTORS.TIMELINE.TWEET,
): Promise<void> {
try {
// Initial tweet count
let previousTweetCount = 0
let currentTweetCount = await countVisibleElements(page, selector)
let scrollAttempts = 0
const maxScrollAttempts = 10
logger.main.debug(`Initial tweet count: ${currentTweetCount}, target count: ${targetCount}`)
// Scroll until we reach target count or max scroll attempts
while (currentTweetCount < targetCount && scrollAttempts < maxScrollAttempts) {
// Simulate mouse wheel scroll using Playwright
await page.mouse.wheel(0, 800)
// Wait for new content to load
await page.waitForTimeout(1000)
// Check for new tweets
previousTweetCount = currentTweetCount
currentTweetCount = await countVisibleElements(page, selector)
// If no new tweets loaded, we may have reached the end
if (currentTweetCount === previousTweetCount) {
scrollAttempts++
}
else {
scrollAttempts = 0 // Reset counter if tweets are still loading
}
logger.main.debug(`Scrolled to load more content: ${currentTweetCount}/${targetCount}`)
}
}
catch (error) {
logger.main.errorWithError('Error while scrolling to load more content:', (error as Error).message)
}
}
/**
* Count visible elements on the page
* @param page Playwright page instance
* @param selector Element selector
*/
export async function countVisibleElements(page: Page, selector: string): Promise<number> {
const elements = await page.$$(selector)
return elements.length
}
@@ -1,8 +1,8 @@
import type { Page } from 'playwright'
import type { UserLink, UserProfile, UserStats } from '../types/twitter'
import type { UserLink, UserProfile, UserStats } from '../core/services/user'
import { logger } from '../utils/logger'
import { SELECTORS } from '../utils/selectors'
import { SELECTORS } from './selectors'
/**
* Profile Parser
@@ -1,8 +1,8 @@
import type { ElementHandle, Page } from 'playwright'
import type { Tweet } from '../types/twitter'
import type { Tweet } from '../core/services/tweet'
import { logger } from '../utils/logger'
import { SELECTORS } from '../utils/selectors'
import { SELECTORS } from './selectors'
/**
* Tweet Parser