From 6c2e36e15c6f9f935a6fde4c673bb8d605295afb Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 18 Mar 2025 10:01:39 +0800 Subject: [PATCH] feat: post tweet (#86) * feat: post tweet * fix: import * refactor: constants * feat: X settings --- apps/stage-web/locales/en.yml | 7 +- apps/stage-web/locales/zh-CN.yml | 3 + .../src/pages/settings/modules/index.vue | 8 + .../twitter-services/{ => src}/constants.ts | 0 .../src/core/browser/context.ts | 2 +- .../src/core/services/auth.ts | 2 +- .../src/core/services/timeline.ts | 50 +-- .../src/core/services/tweet.ts | 284 +++++++++++++++++- .../src/core/services/user.ts | 2 +- .../src/core/utils/scroll-helper.ts | 62 ++++ .../src/parsers/profile-parser.ts | 4 +- .../src/{utils => parsers}/selectors.ts | 0 .../src/parsers/tweet-parser.ts | 4 +- 13 files changed, 362 insertions(+), 66 deletions(-) rename services/twitter-services/{ => src}/constants.ts (100%) create mode 100644 services/twitter-services/src/core/utils/scroll-helper.ts rename services/twitter-services/src/{utils => parsers}/selectors.ts (100%) diff --git a/apps/stage-web/locales/en.yml b/apps/stage-web/locales/en.yml index ebd8d0a4e..3434b16aa 100644 --- a/apps/stage-web/locales/en.yml +++ b/apps/stage-web/locales/en.yml @@ -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 diff --git a/apps/stage-web/locales/zh-CN.yml b/apps/stage-web/locales/zh-CN.yml index aa2156ed7..1f2d89b90 100644 --- a/apps/stage-web/locales/zh-CN.yml +++ b/apps/stage-web/locales/zh-CN.yml @@ -127,6 +127,9 @@ settings: title: 提供商 title: 发声 title: 机体模块 + x: + description: X / Twitter 的浏览和使用 + title: X / Twitter vision: description: 视觉 title: 视觉 diff --git a/apps/stage-web/src/pages/settings/modules/index.vue b/apps/stage-web/src/pages/settings/modules/index.vue index 51bc3600e..21c46b197 100644 --- a/apps/stage-web/src/pages/settings/modules/index.vue +++ b/apps/stage-web/src/pages/settings/modules/index.vue @@ -92,6 +92,14 @@ const modulesList = computed(() => [ 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, + }, ]) diff --git a/services/twitter-services/constants.ts b/services/twitter-services/src/constants.ts similarity index 100% rename from services/twitter-services/constants.ts rename to services/twitter-services/src/constants.ts diff --git a/services/twitter-services/src/core/browser/context.ts b/services/twitter-services/src/core/browser/context.ts index 1c9ea3c12..ac6fb412e 100644 --- a/services/twitter-services/src/core/browser/context.ts +++ b/services/twitter-services/src/core/browser/context.ts @@ -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 { diff --git a/services/twitter-services/src/core/services/auth.ts b/services/twitter-services/src/core/services/auth.ts index 2e8f6d9f2..275f2d336 100644 --- a/services/twitter-services/src/core/services/auth.ts +++ b/services/twitter-services/src/core/services/auth.ts @@ -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 { diff --git a/services/twitter-services/src/core/services/timeline.ts b/services/twitter-services/src/core/services/timeline.ts index a659198c5..15fcdaa10 100644 --- a/services/twitter-services/src/core/services/timeline.ts +++ b/services/twitter-services/src/core/services/timeline.ts @@ -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 { - 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 { - const tweetElements = await ctx.page.$$(SELECTORS.TIMELINE.TWEET) - return tweetElements.length - } - return { getTimeline, } diff --git a/services/twitter-services/src/core/services/tweet.ts b/services/twitter-services/src/core/services/tweet.ts index 88ecfb6c7..fa949a694 100644 --- a/services/twitter-services/src/core/services/tweet.ts +++ b/services/twitter-services/src/core/services/tweet.ts @@ -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 { - 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 { + 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 { - throw new Error('Like feature not yet implemented') + /** + * Likes a tweet with the given ID + */ + async function likeTweet(tweetId: string): Promise { + 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 { - throw new Error('Retweet feature not yet implemented') + /** + * Retweets a tweet with the given ID + */ + async function retweet(tweetId: string): Promise { + 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 { - 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 { + 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 { - throw new Error('Get tweet details feature not yet implemented') + /** + * Gets detailed information about a specific tweet + */ + async function getTweetDetails(tweetId: string): Promise { + 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 { diff --git a/services/twitter-services/src/core/services/user.ts b/services/twitter-services/src/core/services/user.ts index f34795ebd..4a90dae96 100644 --- a/services/twitter-services/src/core/services/user.ts +++ b/services/twitter-services/src/core/services/user.ts @@ -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' /** diff --git a/services/twitter-services/src/core/utils/scroll-helper.ts b/services/twitter-services/src/core/utils/scroll-helper.ts new file mode 100644 index 000000000..f2e86507b --- /dev/null +++ b/services/twitter-services/src/core/utils/scroll-helper.ts @@ -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 { + 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 { + const elements = await page.$$(selector) + return elements.length +} diff --git a/services/twitter-services/src/parsers/profile-parser.ts b/services/twitter-services/src/parsers/profile-parser.ts index 7e55ff1f1..e243f772c 100644 --- a/services/twitter-services/src/parsers/profile-parser.ts +++ b/services/twitter-services/src/parsers/profile-parser.ts @@ -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 diff --git a/services/twitter-services/src/utils/selectors.ts b/services/twitter-services/src/parsers/selectors.ts similarity index 100% rename from services/twitter-services/src/utils/selectors.ts rename to services/twitter-services/src/parsers/selectors.ts diff --git a/services/twitter-services/src/parsers/tweet-parser.ts b/services/twitter-services/src/parsers/tweet-parser.ts index daade42f6..3fefe22c0 100644 --- a/services/twitter-services/src/parsers/tweet-parser.ts +++ b/services/twitter-services/src/parsers/tweet-parser.ts @@ -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