feat: post tweet (#86)
* feat: post tweet * fix: import * refactor: constants * feat: X settings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user