style: lint

This commit is contained in:
Neko Ayaka
2026-08-26 19:49:58 +08:00
parent e60a04a4ec
commit 98f40d7d0b
1625 changed files with 75216 additions and 75203 deletions
@@ -178,48 +178,48 @@ The configuration system has been optimized using the `defu` library for deep me
```typescript
interface Config {
// BrowserBase/Stagehand configuration
browserbase: {
apiKey: string
projectId?: string
endpoint?: string
stagehand?: {
modelName?: string // e.g., "gpt-4o" or "claude-3-5-sonnet-latest"
modelClientOptions?: {
apiKey: string // OpenAI or Anthropic API key
}
// Adapter configuration
adapters: {
airi?: {
enabled: boolean
token?: string
url?: string
}
mcp?: {
enabled: boolean
port?: number
}
}
// Browser configuration
browser: BrowserConfig
// Twitter configuration
twitter: {
credentials?: TwitterCredentials
defaultOptions?: {
timeline?: TimelineOptions
search?: SearchOptions
}
}
// Adapter configuration
adapters: {
airi?: {
url?: string
token?: string
enabled: boolean
}
mcp?: {
port?: number
enabled: boolean
// BrowserBase/Stagehand configuration
browserbase: {
apiKey: string
endpoint?: string
projectId?: string
stagehand?: {
modelClientOptions?: {
apiKey: string // OpenAI or Anthropic API key
}
modelName?: string // e.g., "gpt-4o" or "claude-3-5-sonnet-latest"
}
}
// System configuration
system: {
logLevel: string
concurrency: number
logLevel: string
}
// Twitter configuration
twitter: {
credentials?: TwitterCredentials
defaultOptions?: {
search?: SearchOptions
timeline?: TimelineOptions
}
}
}
```
@@ -262,10 +262,10 @@ async function main() {
await browser.initialize({
headless: true,
stagehand: {
modelName: 'gpt-4o', // Or 'claude-3-5-sonnet-latest' for Anthropic
modelClientOptions: {
apiKey: process.env.OPENAI_API_KEY // Or process.env.ANTHROPIC_API_KEY
}
},
modelName: 'gpt-4o' // Or 'claude-3-5-sonnet-latest' for Anthropic
}
})
@@ -304,8 +304,8 @@ async function startAIRIModule() {
// Create AIRI adapter
const airiAdapter = new AIRIAdapter(twitter, {
url: process.env.AIRI_URL,
token: process.env.AIRI_TOKEN
token: process.env.AIRI_TOKEN,
url: process.env.AIRI_URL
})
// Start adapter
@@ -14,9 +14,9 @@ export function useAdapter() {
const { AiriAdapter } = await import('./airi-adapter')
adapters.airi = new AiriAdapter(ctx, {
url: config.adapters.airi.url,
token: config.adapters.airi.token,
credentials: config.credentials || {},
token: config.adapters.airi.token,
url: config.adapters.airi.url,
})
await adapters.airi.start()
@@ -20,36 +20,34 @@ import { errorToMessage } from '../utils/error'
import { logger } from '../utils/logger'
export interface AiriAdapterConfig {
url?: string
token?: string
credentials: {
apiKey?: string
apiSecret?: string
accessToken?: string
accessTokenSecret?: string
apiKey?: string
apiSecret?: string
}
token?: string
url?: string
}
export interface XConfig {
apiKey?: string
apiSecret?: string
accessToken?: string
accessTokenSecret?: string
apiKey?: string
apiSecret?: string
}
export class AiriAdapter {
private client: Client
private config: AiriAdapterConfig
private ctx: Context
private twitterServices: TwitterServices
private config: AiriAdapterConfig
constructor(ctx: Context, config: AiriAdapterConfig) {
this.ctx = ctx
this.config = config
this.client = new Client({
name: 'x',
url: config.url || 'ws://localhost:6121/ws',
token: config.token,
possibleEvents: [
'module:authenticate',
'module:authenticated',
@@ -57,6 +55,8 @@ export class AiriAdapter {
'ui:configure',
'input:text',
],
token: config.token,
url: config.url || 'ws://localhost:6121/ws',
})
this.twitterServices = {
@@ -69,6 +69,234 @@ export class AiriAdapter {
this.setupEventHandlers()
}
/**
* Start the AiriAdapter and connect to the AIRI server
*/
async start(): Promise<void> {
logger.main.log('Starting Airi adapter for X...')
try {
await this.client.connect()
logger.main.log('Airi adapter for X started successfully')
}
catch (error) {
logger.main.errorWithError('Failed to start Airi adapter for X:', error)
throw error
}
}
/**
* Stop the AiriAdapter and disconnect from the AIRI server
*/
async stop(): Promise<void> {
logger.main.log('Stopping Airi adapter for X...')
try {
this.client.close()
logger.main.log('Airi adapter for X stopped')
}
catch (error) {
logger.main.errorWithError('Error stopping Airi adapter for X:', error)
throw error
}
}
private async handleGetTimeline(count: number): Promise<boolean> {
const timelineOptions = { count }
const tweets = await this.twitterServices.timeline.getTimeline(timelineOptions)
logger.main.log(`Retrieved ${tweets.length} tweets from timeline`)
// Return timeline to the user
this.client.send({
data: {
text: `Latest ${tweets.length} tweets from your timeline:
${tweets.map((t: Tweet) => `- ${t.author.displayName}: ${t.text.substring(0, 80)}...`).join('\n')}`,
},
type: 'input:text',
})
return true
}
private async handleGetUser(content: string): Promise<boolean> {
if (content) {
const userProfile = await this.twitterServices.user.getUserProfile(content)
logger.main.log(`Retrieved profile for user: @${content}`)
// Return user info to the user
this.client.send({
data: {
text: `User Profile for @${userProfile.username}:
Display Name: ${userProfile.displayName}
Bio: ${userProfile.bio || 'N/A'}
Followers: ${userProfile.followersCount || 0}
Following: ${userProfile.followingCount || 0}`,
},
type: 'input:text',
})
return true
}
else {
throw new Error('Username is empty. Please provide a username to retrieve.')
}
}
private async handleInput(input: string): Promise<void> {
let responseSent = false
try {
// Parse and handle X commands
logger.main.log('Processing X command:', input)
// Parse the command using the dedicated parsing function
const parsedCommand = parseTwitterCommand(input)
if (!parsedCommand) {
throw new Error(`Unknown X command: ${input}. Supported commands: "post tweet: <text>", "search tweets: <query>", "like tweet: <tweetId>", "retweet: <tweetId>", "get user: <username>", "get timeline [count: N]"`)
}
// Execute the appropriate command handler based on the parsed command
switch (parsedCommand.command) {
case 'get timeline':
responseSent = await this.handleGetTimeline(parsedCommand.count || 10)
break
case 'get user':
responseSent = await this.handleGetUser(parsedCommand.content)
break
case 'like tweet':
await this.handleLikeTweet(parsedCommand.content)
break
case 'post tweet':
await this.handlePostTweet(parsedCommand.content)
break
case 'retweet':
await this.handleRetweet(parsedCommand.content)
break
case 'search tweets':
responseSent = await this.handleSearchTweets(parsedCommand.content)
break
default:
// This should not happen if parseTwitterCommand is working correctly
throw new Error(`Unknown X command: ${input}`)
}
// Only send the original processing response if we haven't already sent a specific response
if (!responseSent) {
this.client.send({
data: {
text: `Processed X command: ${input}`,
},
type: 'input:text',
})
}
}
catch (error: unknown) {
const errorMessage = errorToMessage(error)
logger.main.errorWithError('Error handling input:', error)
this.client.send({
data: {
text: `Error processing X command: ${errorMessage}`,
},
type: 'input:text',
})
}
}
private async handleLikeTweet(content: string): Promise<void> {
if (content) {
await this.twitterServices.tweet.likeTweet(content)
logger.main.log(`Liked tweet: ${content}`)
}
else {
throw new Error('Tweet ID is empty. Please provide a tweet ID to like.')
}
}
private async handlePostTweet(content: string): Promise<void> {
if (content) {
await this.twitterServices.tweet.postTweet(content)
logger.main.log('Posted tweet:', content)
}
else {
throw new Error('Tweet text is empty. Please provide text to post.')
}
}
private async handleRetweet(content: string): Promise<void> {
if (content) {
await this.twitterServices.tweet.retweet(content)
logger.main.log(`Retweeted: ${content}`)
}
else {
throw new Error('Tweet ID is empty. Please provide a tweet ID to retweet.')
}
}
private async handleSearchTweets(content: string): Promise<boolean> {
if (content) {
const tweets = await this.twitterServices.tweet.searchTweets(content)
logger.main.log(`Found ${tweets.length} tweets for query: ${content}`)
// Return results to the user
this.client.send({
data: {
text: `Found ${tweets.length} tweets for '${content}':
${tweets.slice(0, 5).map((t: Tweet) => `- ${t.text.substring(0, 100)}...`).join('\n')}`,
},
type: 'input:text',
})
return true
}
else {
throw new Error('Search query is empty. Please provide a query to search.')
}
}
/**
* Reinitialize the browser context to refresh session state
* This is needed when credentials are updated from the UI
*/
private async reinitializeBrowserContext(): Promise<void> {
try {
// Clear the session file to force re-authentication with new credentials
const sessionFile = await useSessionFileAsync()
// Clear the session file to force re-authentication
await fs.writeFile(
sessionFile,
JSON.stringify({ cookies: [], origins: [] }, null, 2),
)
logger.main.log('Session file cleared, re-initializing browser context')
// Use the updated configuration with new credentials
const config = {
...getDefaultConfig(),
credentials: {
...getDefaultConfig().credentials,
...this.config.credentials,
},
}
await initBrowser(config)
// Update the context reference
this.ctx = useContext()
// Reinitialize services with the new context
this.twitterServices = {
timeline: useTwitterTimelineServices(this.ctx),
tweet: useTwitterTweetServices(this.ctx),
user: useTwitterUserServices(this.ctx),
}
logger.main.log('Browser context reinitialized successfully with new credentials')
}
catch (error) {
logger.main.errorWithError('Failed to reinitialize browser context:', error)
throw error
}
}
private setupEventHandlers(): void {
// Handle configuration from UI
this.client.onEvent('ui:configure', async (event) => {
@@ -129,234 +357,6 @@ export class AiriAdapter {
}
})
}
private async handlePostTweet(content: string): Promise<void> {
if (content) {
await this.twitterServices.tweet.postTweet(content)
logger.main.log('Posted tweet:', content)
}
else {
throw new Error('Tweet text is empty. Please provide text to post.')
}
}
private async handleSearchTweets(content: string): Promise<boolean> {
if (content) {
const tweets = await this.twitterServices.tweet.searchTweets(content)
logger.main.log(`Found ${tweets.length} tweets for query: ${content}`)
// Return results to the user
this.client.send({
type: 'input:text',
data: {
text: `Found ${tweets.length} tweets for '${content}':
${tweets.slice(0, 5).map((t: Tweet) => `- ${t.text.substring(0, 100)}...`).join('\n')}`,
},
})
return true
}
else {
throw new Error('Search query is empty. Please provide a query to search.')
}
}
private async handleLikeTweet(content: string): Promise<void> {
if (content) {
await this.twitterServices.tweet.likeTweet(content)
logger.main.log(`Liked tweet: ${content}`)
}
else {
throw new Error('Tweet ID is empty. Please provide a tweet ID to like.')
}
}
private async handleRetweet(content: string): Promise<void> {
if (content) {
await this.twitterServices.tweet.retweet(content)
logger.main.log(`Retweeted: ${content}`)
}
else {
throw new Error('Tweet ID is empty. Please provide a tweet ID to retweet.')
}
}
private async handleGetUser(content: string): Promise<boolean> {
if (content) {
const userProfile = await this.twitterServices.user.getUserProfile(content)
logger.main.log(`Retrieved profile for user: @${content}`)
// Return user info to the user
this.client.send({
type: 'input:text',
data: {
text: `User Profile for @${userProfile.username}:
Display Name: ${userProfile.displayName}
Bio: ${userProfile.bio || 'N/A'}
Followers: ${userProfile.followersCount || 0}
Following: ${userProfile.followingCount || 0}`,
},
})
return true
}
else {
throw new Error('Username is empty. Please provide a username to retrieve.')
}
}
private async handleGetTimeline(count: number): Promise<boolean> {
const timelineOptions = { count }
const tweets = await this.twitterServices.timeline.getTimeline(timelineOptions)
logger.main.log(`Retrieved ${tweets.length} tweets from timeline`)
// Return timeline to the user
this.client.send({
type: 'input:text',
data: {
text: `Latest ${tweets.length} tweets from your timeline:
${tweets.map((t: Tweet) => `- ${t.author.displayName}: ${t.text.substring(0, 80)}...`).join('\n')}`,
},
})
return true
}
private async handleInput(input: string): Promise<void> {
let responseSent = false
try {
// Parse and handle X commands
logger.main.log('Processing X command:', input)
// Parse the command using the dedicated parsing function
const parsedCommand = parseTwitterCommand(input)
if (!parsedCommand) {
throw new Error(`Unknown X command: ${input}. Supported commands: "post tweet: <text>", "search tweets: <query>", "like tweet: <tweetId>", "retweet: <tweetId>", "get user: <username>", "get timeline [count: N]"`)
}
// Execute the appropriate command handler based on the parsed command
switch (parsedCommand.command) {
case 'post tweet':
await this.handlePostTweet(parsedCommand.content)
break
case 'search tweets':
responseSent = await this.handleSearchTweets(parsedCommand.content)
break
case 'like tweet':
await this.handleLikeTweet(parsedCommand.content)
break
case 'retweet':
await this.handleRetweet(parsedCommand.content)
break
case 'get user':
responseSent = await this.handleGetUser(parsedCommand.content)
break
case 'get timeline':
responseSent = await this.handleGetTimeline(parsedCommand.count || 10)
break
default:
// This should not happen if parseTwitterCommand is working correctly
throw new Error(`Unknown X command: ${input}`)
}
// Only send the original processing response if we haven't already sent a specific response
if (!responseSent) {
this.client.send({
type: 'input:text',
data: {
text: `Processed X command: ${input}`,
},
})
}
}
catch (error: unknown) {
const errorMessage = errorToMessage(error)
logger.main.errorWithError('Error handling input:', error)
this.client.send({
type: 'input:text',
data: {
text: `Error processing X command: ${errorMessage}`,
},
})
}
}
/**
* Start the AiriAdapter and connect to the AIRI server
*/
async start(): Promise<void> {
logger.main.log('Starting Airi adapter for X...')
try {
await this.client.connect()
logger.main.log('Airi adapter for X started successfully')
}
catch (error) {
logger.main.errorWithError('Failed to start Airi adapter for X:', error)
throw error
}
}
/**
* Stop the AiriAdapter and disconnect from the AIRI server
*/
async stop(): Promise<void> {
logger.main.log('Stopping Airi adapter for X...')
try {
this.client.close()
logger.main.log('Airi adapter for X stopped')
}
catch (error) {
logger.main.errorWithError('Error stopping Airi adapter for X:', error)
throw error
}
}
/**
* Reinitialize the browser context to refresh session state
* This is needed when credentials are updated from the UI
*/
private async reinitializeBrowserContext(): Promise<void> {
try {
// Clear the session file to force re-authentication with new credentials
const sessionFile = await useSessionFileAsync()
// Clear the session file to force re-authentication
await fs.writeFile(
sessionFile,
JSON.stringify({ cookies: [], origins: [] }, null, 2),
)
logger.main.log('Session file cleared, re-initializing browser context')
// Use the updated configuration with new credentials
const config = {
...getDefaultConfig(),
credentials: {
...getDefaultConfig().credentials,
...this.config.credentials,
},
}
await initBrowser(config)
// Update the context reference
this.ctx = useContext()
// Reinitialize services with the new context
this.twitterServices = {
timeline: useTwitterTimelineServices(this.ctx),
tweet: useTwitterTweetServices(this.ctx),
user: useTwitterUserServices(this.ctx),
}
logger.main.log('Browser context reinitialized successfully with new credentials')
}
catch (error) {
logger.main.errorWithError('Failed to reinitialize browser context:', error)
throw error
}
}
}
function isXConfig(config: unknown): config is XConfig {
@@ -23,13 +23,13 @@ import { logger } from '../utils/logger'
* Implements HTTP server using H3.js
*/
export class MCPAdapter {
private mcpServer: McpServer
private app: H3
private server: ReturnType<typeof createServer> | null = null
private port: number
private activeTransports: SSEServerTransport[] = []
private extraResourceInfo: string[] = []
private app: H3
private ctx: Context
private extraResourceInfo: string[] = []
private mcpServer: McpServer
private port: number
private server: null | ReturnType<typeof createServer> = null
private twitterServices: TwitterServices
@@ -59,6 +59,79 @@ export class MCPAdapter {
this.setupRoutes()
}
/**
* Start MCP server
*/
start(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.server !== null) {
logger.mcp.warn('MCP server is already running')
resolve()
return
}
try {
// Create Node.js HTTP server
this.server = createServer(toNodeHandler(this.app))
// Add error event handlers
this.server.on('error', (error) => {
logger.mcp.errorWithError('MCP server error:', error)
reject(error)
})
// Log available resources for debugging
logger.mcp.debug('Registered MCP resources:')
logger.mcp.debug('- twitter://timeline/{count}: Get tweet timeline')
logger.mcp.debug('- twitter://tweet/{id}: Get single tweet details')
logger.mcp.debug('- twitter://user/{username}: Get user profile information')
if (this.extraResourceInfo?.length) {
this.extraResourceInfo.forEach((info) => {
logger.mcp.debug(`- ${info}`)
})
}
this.server.listen(this.port, '127.0.0.1', () => {
const serverAddress = `http://localhost:${this.port}`
logger.mcp.log(`MCP server started at: ${serverAddress}`)
logger.mcp.log(`SSE endpoint: ${serverAddress}/sse`)
logger.mcp.log(`Messages endpoint: ${serverAddress}/messages`)
resolve()
})
}
catch (error) {
logger.mcp.errorWithError('Error starting MCP server:', error)
reject(error)
}
})
}
/**
* Stop MCP server
*/
stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server === null) {
logger.mcp.warn('MCP server is not running')
resolve()
return
}
try {
this.server.close(() => {
this.server = null
logger.mcp.log('MCP server stopped')
resolve()
})
}
catch (error) {
logger.mcp.errorWithError('Error stopping MCP server:', error)
this.server = null
resolve()
}
})
}
/**
* Configure MCP server resources and tools
*/
@@ -73,9 +146,9 @@ export class MCPAdapter {
logger.mcp.debug('Listing available timeline resources')
return {
resources: [{
description: 'Tweet timeline',
name: 'timeline',
uri: 'twitter://timeline/10', // Default number of tweets
description: 'Tweet timeline',
}],
}
},
@@ -93,8 +166,8 @@ export class MCPAdapter {
return {
contents: tweets.map((tweet: Tweet) => ({
uri: `twitter://tweet/${tweet.id}`,
text: `Tweet by @${tweet.author.username} (${tweet.author.displayName}):\n${tweet.text}`,
uri: `twitter://tweet/${tweet.id}`,
})),
}
}
@@ -115,8 +188,8 @@ export class MCPAdapter {
return {
contents: [{
uri: uri.href,
text: `Tweet by @${tweet.author.username} (${tweet.author.displayName}):\n${tweet.text}`,
uri: uri.href,
}],
}
}
@@ -137,8 +210,8 @@ export class MCPAdapter {
return {
contents: [{
uri: uri.href,
text: `Profile for @${profile.username} (${profile.displayName})\n${profile.bio || ''}`,
uri: uri.href,
}],
}
}
@@ -159,16 +232,16 @@ export class MCPAdapter {
return {
content: [{
type: 'text',
text: success
? 'Successfully loaded login state from session file! If you logged in manually, auto-monitoring is set up to save your session.'
: 'No valid session file found. Please log in manually in the browser, the system will automatically save your session.',
type: 'text',
}],
}
}
catch (error) {
return {
content: [{ type: 'text', text: `Failed to check login status: ${errorToMessage(error)}` }],
content: [{ text: `Failed to check login status: ${errorToMessage(error)}`, type: 'text' }],
isError: true,
}
}
@@ -180,10 +253,10 @@ export class MCPAdapter {
'post-tweet',
{
content: z.string(),
replyTo: z.string().optional(),
media: z.array(z.string()).optional(),
replyTo: z.string().optional(),
},
async ({ content, replyTo, media }) => {
async ({ content, media, replyTo }) => {
try {
const tweetId = await this.twitterServices.tweet.postTweet(content, {
inReplyTo: replyTo,
@@ -192,14 +265,14 @@ export class MCPAdapter {
return {
content: [{
type: 'text',
text: `Successfully posted tweet: ${tweetId}`,
type: 'text',
}],
}
}
catch (error) {
return {
content: [{ type: 'text', text: `Failed to post tweet: ${errorToMessage(error)}` }],
content: [{ text: `Failed to post tweet: ${errorToMessage(error)}`, type: 'text' }],
isError: true,
}
}
@@ -216,14 +289,14 @@ export class MCPAdapter {
return {
content: [{
type: 'text',
text: success ? 'Successfully liked tweet' : 'Failed to like tweet',
type: 'text',
}],
}
}
catch (error) {
return {
content: [{ type: 'text', text: `Failed to like tweet: ${errorToMessage(error)}` }],
content: [{ text: `Failed to like tweet: ${errorToMessage(error)}`, type: 'text' }],
isError: true,
}
}
@@ -240,14 +313,14 @@ export class MCPAdapter {
return {
content: [{
type: 'text',
text: success ? 'Successfully retweeted' : 'Failed to retweet',
type: 'text',
}],
}
}
catch (error) {
return {
content: [{ type: 'text', text: `Failed to retweet: ${errorToMessage(error)}` }],
content: [{ text: `Failed to retweet: ${errorToMessage(error)}`, type: 'text' }],
isError: true,
}
}
@@ -264,16 +337,16 @@ export class MCPAdapter {
return {
content: [{
type: 'text',
text: success
? 'Successfully saved browser session to file. This session will be loaded automatically next time.'
: 'Failed to save browser session',
type: 'text',
}],
}
}
catch (error) {
return {
content: [{ type: 'text', text: `Failed to save session: ${errorToMessage(error)}` }],
content: [{ text: `Failed to save session: ${errorToMessage(error)}`, type: 'text' }],
isError: true,
}
}
@@ -284,25 +357,25 @@ export class MCPAdapter {
this.mcpServer.tool(
'search',
{
query: z.string(),
count: z.number().optional(),
filter: z.enum(['latest', 'photos', 'videos', 'top']).optional(),
query: z.string(),
},
async ({ query, count, filter }) => {
async ({ count, filter, query }) => {
try {
const results = await this.twitterServices.tweet.searchTweets(query, { count, filter })
return {
content: [{
type: 'text',
text: `Search results: ${results.length} tweets`,
type: 'text',
}],
resources: results.map((tweet: Tweet) => `twitter://tweet/${tweet.id}`),
}
}
catch (error) {
return {
content: [{ type: 'text', text: `Search failed: ${errorToMessage(error)}` }],
content: [{ text: `Search failed: ${errorToMessage(error)}`, type: 'text' }],
isError: true,
}
}
@@ -327,15 +400,15 @@ export class MCPAdapter {
return {
content: [{
type: 'text',
text: `Successfully refreshed timeline, retrieved ${tweets.length} tweets`,
type: 'text',
}],
resources: tweets.map((tweet: Tweet) => `twitter://tweet/${tweet.id}`),
}
}
catch (error) {
return {
content: [{ type: 'text', text: `Failed to refresh timeline: ${errorToMessage(error)}` }],
content: [{ text: `Failed to refresh timeline: ${errorToMessage(error)}`, type: 'text' }],
isError: true,
}
}
@@ -362,8 +435,8 @@ export class MCPAdapter {
if (!profileUsername) {
return {
content: [{
type: 'text',
text: `Failed to get profile: Please provide a username or navigate to a profile page`,
type: 'text',
}],
isError: true,
}
@@ -373,7 +446,6 @@ export class MCPAdapter {
return {
content: [{
type: 'text',
text: `Profile Information:\n`
+ `Username: @${profile.username}\n`
+ `Display Name: ${profile.displayName}\n`
@@ -382,13 +454,14 @@ export class MCPAdapter {
+ `Following: ${profile.followingCount || 'N/A'}\n`
+ `Tweets: ${profile.tweetCount || 'N/A'}\n`
+ `Joined: ${profile.joinDate || 'N/A'}`,
type: 'text',
}],
resources: [`twitter://user/${profile.username}`],
}
}
catch (error) {
return {
content: [{ type: 'text', text: `Failed to get profile: ${errorToMessage(error)}` }],
content: [{ text: `Failed to get profile: ${errorToMessage(error)}`, type: 'text' }],
isError: true,
}
}
@@ -406,7 +479,7 @@ export class MCPAdapter {
const parsedUrl = new URL(url)
if (parsedUrl.hostname === 'x.com') {
const pathParts = parsedUrl.pathname.split('/').filter(Boolean)
if (pathParts.length > 0 && !['search', 'explore', 'home', 'notifications', 'messages'].includes(pathParts[0])) {
if (pathParts.length > 0 && !['explore', 'home', 'messages', 'notifications', 'search'].includes(pathParts[0])) {
return pathParts[0]
}
}
@@ -499,91 +572,18 @@ export class MCPAdapter {
// Root path - provide service info
router.get('/', defineEventHandler(() => {
return {
endpoints: {
messages: '/messages',
sse: '/sse',
},
name: 'Twitter MCP Service',
version: '1.0.0',
endpoints: {
sse: '/sse',
messages: '/messages',
},
}
}))
// Use router
this.app.use(router)
}
/**
* Start MCP server
*/
start(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.server !== null) {
logger.mcp.warn('MCP server is already running')
resolve()
return
}
try {
// Create Node.js HTTP server
this.server = createServer(toNodeHandler(this.app))
// Add error event handlers
this.server.on('error', (error) => {
logger.mcp.errorWithError('MCP server error:', error)
reject(error)
})
// Log available resources for debugging
logger.mcp.debug('Registered MCP resources:')
logger.mcp.debug('- twitter://timeline/{count}: Get tweet timeline')
logger.mcp.debug('- twitter://tweet/{id}: Get single tweet details')
logger.mcp.debug('- twitter://user/{username}: Get user profile information')
if (this.extraResourceInfo?.length) {
this.extraResourceInfo.forEach((info) => {
logger.mcp.debug(`- ${info}`)
})
}
this.server.listen(this.port, '127.0.0.1', () => {
const serverAddress = `http://localhost:${this.port}`
logger.mcp.log(`MCP server started at: ${serverAddress}`)
logger.mcp.log(`SSE endpoint: ${serverAddress}/sse`)
logger.mcp.log(`Messages endpoint: ${serverAddress}/messages`)
resolve()
})
}
catch (error) {
logger.mcp.errorWithError('Error starting MCP server:', error)
reject(error)
}
})
}
/**
* Stop MCP server
*/
stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server === null) {
logger.mcp.warn('MCP server is not running')
resolve()
return
}
try {
this.server.close(() => {
this.server = null
logger.mcp.log('MCP server stopped')
resolve()
})
}
catch (error) {
logger.mcp.errorWithError('Error stopping MCP server:', error)
this.server = null
resolve()
}
})
}
}
// h3 utility function: read body from event
@@ -30,6 +30,21 @@ export class ConfigManager {
}
}
/**
* Get complete configuration
*/
getConfig(): Config {
return this.config
}
/**
* Update configuration
*/
updateConfig(newConfig: Partial<Config>): void {
// Use defu to merge new configuration
this.config = merge(this.config, newConfig)
}
/**
* Load configuration from file
*/
@@ -48,21 +63,6 @@ export class ConfigManager {
logger.config.errorWithError(`Failed to load configuration file: ${(error as Error).message}`, error)
}
}
/**
* Get complete configuration
*/
getConfig(): Config {
return this.config
}
/**
* Update configuration
*/
updateConfig(newConfig: Partial<Config>): void {
// Use defu to merge new configuration
this.config = merge(this.config, newConfig)
}
}
// Singleton instance
@@ -7,6 +7,19 @@ import process from 'node:process'
* Complete configuration interface
*/
export interface Config {
// Adapter configuration
adapters: {
airi?: {
enabled: boolean
token?: string
url?: string
}
mcp?: {
enabled: boolean
port?: number
}
}
// Browser configuration
browser: BrowserConfig & {
apiKey: string // API Key for Stagehand
@@ -15,39 +28,26 @@ export interface Config {
// Twitter API credentials
credentials?: {
apiKey?: string
apiSecret?: string
accessToken?: string
accessTokenSecret?: string
apiKey?: string
apiSecret?: string
}
// System configuration
system: {
concurrency: number
logFormat?: 'json' | 'pretty'
logLevel: 'debug' | 'error' | 'info' | 'verbose' | 'warn'
}
// Twitter configuration
twitter: {
defaultOptions?: {
timeline?: TimelineOptions
search?: SearchOptions
timeline?: TimelineOptions
}
}
// Adapter configuration
adapters: {
airi?: {
url?: string
token?: string
enabled: boolean
}
mcp?: {
port?: number
enabled: boolean
}
}
// System configuration
system: {
logLevel: 'error' | 'warn' | 'info' | 'verbose' | 'debug'
logFormat?: 'json' | 'pretty'
concurrency: number
}
}
/**
@@ -58,23 +58,39 @@ export function getDefaultConfig(): Config {
// The auth service will load cookies from session file instead
return {
adapters: {
airi: {
enabled: process.env.ENABLE_AIRI === 'true',
token: process.env.AIRI_TOKEN || '',
url: process.env.AIRI_URL || 'http://localhost:3000',
},
mcp: {
enabled: process.env.ENABLE_MCP === 'true',
port: Number(process.env.MCP_PORT || 8080),
},
},
browser: {
apiKey: process.env.BROWSERBASE_API_KEY || '', // Move apiKey to browser config
headless: process.env.BROWSER_HEADLESS === 'true',
requestRetries: Number.parseInt(process.env.BROWSER_REQUEST_RETRIES || '2'),
requestTimeout: Number.parseInt(process.env.BROWSER_REQUEST_TIMEOUT || '20000'),
timeout: Number.parseInt(process.env.BROWSER_TIMEOUT || '30000'),
userAgent: process.env.BROWSER_USER_AGENT || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
viewport: {
width: Number.parseInt(process.env.BROWSER_VIEWPORT_WIDTH || '1280'),
height: Number.parseInt(process.env.BROWSER_VIEWPORT_HEIGHT || '800'),
width: Number.parseInt(process.env.BROWSER_VIEWPORT_WIDTH || '1280'),
},
timeout: Number.parseInt(process.env.BROWSER_TIMEOUT || '30000'),
requestTimeout: Number.parseInt(process.env.BROWSER_REQUEST_TIMEOUT || '20000'),
requestRetries: Number.parseInt(process.env.BROWSER_REQUEST_RETRIES || '2'),
},
credentials: {
apiKey: process.env.TWITTER_API_KEY,
apiSecret: process.env.TWITTER_API_SECRET,
accessToken: process.env.TWITTER_ACCESS_TOKEN,
accessTokenSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET,
apiKey: process.env.TWITTER_API_KEY,
apiSecret: process.env.TWITTER_API_SECRET,
},
system: {
concurrency: Number(process.env.CONCURRENCY || 1),
logFormat: 'pretty',
logLevel: 'debug',
},
twitter: {
defaultOptions: {
@@ -85,21 +101,5 @@ export function getDefaultConfig(): Config {
},
},
},
adapters: {
airi: {
url: process.env.AIRI_URL || 'http://localhost:3000',
token: process.env.AIRI_TOKEN || '',
enabled: process.env.ENABLE_AIRI === 'true',
},
mcp: {
port: Number(process.env.MCP_PORT || 8080),
enabled: process.env.ENABLE_MCP === 'true',
},
},
system: {
logLevel: 'debug',
logFormat: 'pretty',
concurrency: Number(process.env.CONCURRENCY || 1),
},
}
}
@@ -23,10 +23,10 @@ export async function initBrowser(config: Config) {
})
const context = await browser.newContext({
bypassCSP: true,
storageState: await useSessionFileAsync(),
userAgent: config.browser.userAgent,
viewport: config.browser.viewport,
bypassCSP: true,
})
context.setDefaultTimeout(config.browser.timeout || 30000)
@@ -36,6 +36,14 @@ export async function initBrowser(config: Config) {
ctxInstance = { browser, context, page }
}
export function useContext(): Context {
if (!ctxInstance) {
throw new Error('Context not initialized')
}
return ctxInstance
}
export async function useSessionFileAsync(): Promise<string> {
const defaultSession = {
cookies: [],
@@ -60,11 +68,3 @@ export async function useSessionFileAsync(): Promise<string> {
return TWITTER_SESSION_FILE
}
export function useContext(): Context {
if (!ctxInstance) {
throw new Error('Context not initialized')
}
return ctxInstance
}
@@ -8,21 +8,11 @@ import { errorToMessage } from '../../utils/error'
import { scrollToLoadMoreTweets } from '../utils/scroll-helper'
/**
* Tweet Interface
* Post Options
*/
export interface Tweet {
id: string
text: string
author: {
username: string
displayName: string
avatarUrl?: string
}
timestamp: string
likeCount?: number
retweetCount?: number
replyCount?: number
mediaUrls?: string[]
export interface PostOptions {
inReplyTo?: string
media?: string[]
}
/**
@@ -30,23 +20,33 @@ export interface Tweet {
*/
export interface SearchOptions {
count?: number
filter?: 'latest' | 'photos' | 'videos' | 'top'
filter?: 'latest' | 'photos' | 'top' | 'videos'
}
/**
* Post Options
* Tweet Interface
*/
export interface PostOptions {
media?: string[]
inReplyTo?: string
export interface Tweet {
author: {
avatarUrl?: string
displayName: string
username: string
}
id: string
likeCount?: number
mediaUrls?: string[]
replyCount?: number
retweetCount?: number
text: string
timestamp: string
}
/**
* Tweet Detail
*/
export interface TweetDetail extends Tweet {
replies?: Tweet[]
quotedTweet?: Tweet
replies?: Tweet[]
}
export function useTwitterTweetServices(ctx: Context): TwitterService {
@@ -72,15 +72,15 @@ export function useTwitterTweetServices(ctx: Context): TwitterService {
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
case 'top':
await page.click(SELECTORS.SEARCH.TOP_TAB)
break
}
}
@@ -315,8 +315,8 @@ export function useTwitterTweetServices(ctx: Context): TwitterService {
// Construct the detailed tweet
const tweetDetail: TweetDetail = {
...mainTweet,
replies: replies.length > 0 ? replies : undefined,
quotedTweet,
replies: replies.length > 0 ? replies : undefined,
}
return tweetDetail
@@ -328,10 +328,10 @@ export function useTwitterTweetServices(ctx: Context): TwitterService {
}
return {
searchTweets,
likeTweet,
retweet,
postTweet,
getTweetDetails,
likeTweet,
postTweet,
retweet,
searchTweets,
}
}
@@ -4,38 +4,38 @@ import type { Context } from '../browser/context'
import { TWITTER_BASE_URL } from '../../constants'
import { logger } from '../../utils/logger'
/**
* User Link
*/
export interface UserLink {
title: string
type: string
url: string
}
/**
* User Profile
*/
export interface UserProfile {
username: string
displayName: string
bio?: string
avatarUrl?: string
bannerUrl?: string
bio?: string
displayName: string
followersCount?: number
followingCount?: number
tweetCount?: number
isVerified?: boolean
joinDate?: string
tweetCount?: number
username: string
}
/**
* User Stats
*/
export interface UserStats {
tweets: number
following: number
followers: number
}
/**
* User Link
*/
export interface UserLink {
type: string
url: string
title: string
following: number
tweets: number
}
export function useTwitterUserServices(ctx: Context): TwitterService {
@@ -76,12 +76,12 @@ export function useTwitterUserServices(ctx: Context): TwitterService {
: undefined
return {
username,
displayName,
bio: bio || undefined,
avatarUrl: avatarUrl || undefined,
bio: bio || undefined,
displayName,
followersCount: followerCount || undefined,
followingCount: followingCount || undefined,
username,
}
}
catch (error) {
@@ -3,6 +3,16 @@ import type { Page } from 'playwright'
import { SELECTORS } from '../../parsers/selectors'
import { logger } from '../../utils/logger'
/**
* 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
}
/**
* Scroll the page to load more tweets
* @param page Playwright page instance
@@ -50,13 +60,3 @@ export async function scrollToLoadMoreTweets(
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
}
+43 -43
View File
@@ -11,6 +11,28 @@ import { initBrowser, useContext } from './core/browser/context'
import { useTwitterAuthServices } from './core/services/auth'
import { initLogger, logger } from './utils/logger'
/**
* Bootstrap the application
*/
async function bootstrap() {
initLogger()
try {
const resources = await initializeApp()
setupShutdownHooks(resources.adapters, resources.context)
logger.main.log('Twitter service successfully started!')
}
catch (error) {
logger.main.withError(error).error('Startup failed')
process.exit(1)
}
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason) => {
logger.main.withError(reason).error('Unhandled Promise rejection:')
})
}
/**
* Clean up application resources
*/
@@ -30,6 +52,27 @@ async function cleanup(adapters: { airi?: AiriAdapter, mcp?: MCPAdapter }, brows
logger.main.log('Twitter service stopped')
}
/**
* Initialize core application components
*/
async function initializeApp() {
const config = useConfigManager().getConfig()
logger.main.log('Starting Twitter service...')
await initBrowser(config)
const ctx = useContext()
const adapters = await useAdapter().initAdapters(config, ctx)
// Login to Twitter
await useTwitterAuthServices(ctx).attemptLogin()
return {
adapters,
browser: ctx.browser,
context: ctx.context,
}
}
/**
* Set up process shutdown hooks
*/
@@ -52,47 +95,4 @@ function setupShutdownHooks(adapters: { airi?: AiriAdapter, mcp?: MCPAdapter },
})
}
/**
* Initialize core application components
*/
async function initializeApp() {
const config = useConfigManager().getConfig()
logger.main.log('Starting Twitter service...')
await initBrowser(config)
const ctx = useContext()
const adapters = await useAdapter().initAdapters(config, ctx)
// Login to Twitter
await useTwitterAuthServices(ctx).attemptLogin()
return {
adapters,
context: ctx.context,
browser: ctx.browser,
}
}
/**
* Bootstrap the application
*/
async function bootstrap() {
initLogger()
try {
const resources = await initializeApp()
setupShutdownHooks(resources.adapters, resources.context)
logger.main.log('Twitter service successfully started!')
}
catch (error) {
logger.main.withError(error).error('Startup failed')
process.exit(1)
}
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason) => {
logger.main.withError(reason).error('Unhandled Promise rejection:')
})
}
bootstrap()
@@ -1,35 +1,35 @@
// Define a union type for the command parsing result
export type ParseResult
= | { command: 'post tweet', content: string }
| { command: 'search tweets', content: string }
| { command: 'like tweet', content: string }
| { command: 'retweet', content: string }
= | { command: 'get timeline', content: string, count: number }
| { command: 'get user', content: string }
| { command: 'get timeline', content: string, count: number }
| { command: 'like tweet', content: string }
| { command: 'post tweet', content: string }
| { command: 'retweet', content: string }
| { command: 'search tweets', content: string }
type NonTimelineCommands = 'post tweet' | 'search tweets' | 'like tweet' | 'retweet' | 'get user'
type NonTimelineCommands = 'get user' | 'like tweet' | 'post tweet' | 'retweet' | 'search tweets'
/**
* Parses a Twitter command from the input string
* @param input The input string containing the command
* @returns Parsed command and content, or null if no valid command found
*/
export function parseTwitterCommand(input: string): ParseResult | null {
export function parseTwitterCommand(input: string): null | ParseResult {
// Handle commands based on explicit prefixes for better reliability
const normalizedInput = input.trim().toLowerCase()
// Define command patterns
const commandPatterns: Array<{ pattern: string, command: string }> = [
{ pattern: 'post tweet:', command: 'post tweet' },
{ pattern: 'search tweets:', command: 'search tweets' },
{ pattern: 'like tweet:', command: 'like tweet' },
{ pattern: 'retweet:', command: 'retweet' },
{ pattern: 'get user:', command: 'get user' },
{ pattern: 'get timeline', command: 'get timeline' },
const commandPatterns: Array<{ command: string, pattern: string }> = [
{ command: 'post tweet', pattern: 'post tweet:' },
{ command: 'search tweets', pattern: 'search tweets:' },
{ command: 'like tweet', pattern: 'like tweet:' },
{ command: 'retweet', pattern: 'retweet:' },
{ command: 'get user', pattern: 'get user:' },
{ command: 'get timeline', pattern: 'get timeline' },
]
// Find the matching command pattern
for (const { pattern, command } of commandPatterns) {
for (const { command, pattern } of commandPatterns) {
if (normalizedInput.startsWith(pattern)) {
// Extract the content after the prefix
const content = input.substring(pattern.length)
@@ -54,8 +54,8 @@ export class ProfileParser {
// const _links = await this.extractUserLinks(page)
const profile: UserProfile = {
username,
displayName,
username,
}
// Add optional fields if they exist
@@ -86,76 +86,12 @@ export class ProfileParser {
// Return minimal profile to avoid breaking
return {
username: 'unknown',
displayName: 'Unknown User',
username: 'unknown',
}
}
}
/**
* Extract username from Twitter profile URL
* @param url Twitter profile URL
* @returns Username or null if not found
*/
private static extractUsernameFromUrl(url: string): string | null {
try {
const match = url.match(/twitter\.com\/([^/]+)/)
if (match && match[1] && !['home', 'explore', 'notifications', 'messages'].includes(match[1])) {
return match[1]
}
return null
}
catch {
return null
}
}
/**
* Extract user statistics (followers, following, tweets)
* @param page Playwright page instance
* @returns Promise resolving to UserStats object
*/
private static async extractUserStats(page: Page): Promise<UserStats> {
const stats: UserStats = {
followers: 0,
following: 0,
tweets: 0,
}
try {
// Get stats container
const statsContainer = await page.$(SELECTORS.PROFILE.STATS)
if (!statsContainer)
return stats
// Get all stat items
const statItems = await statsContainer.$$('a')
for (const statItem of statItems) {
const text = await statItem.textContent() || ''
if (text.includes('Following')) {
const countText = text.replace(/Following.*/, '').trim()
stats.following = this.parseStatNumber(countText)
}
else if (text.includes('Followers')) {
const countText = text.replace(/Followers.*/, '').trim()
stats.followers = this.parseStatNumber(countText)
}
else if (text.includes('posts') || text.includes('Posts')) {
const countText = text.replace(/posts|Posts.*/, '').trim()
stats.tweets = this.parseStatNumber(countText)
}
}
return stats
}
catch (error) {
logger.parser.error('Error extracting user stats:', (error as Error).message)
return stats
}
}
/**
* Extract profile avatar URL
* @param page Playwright page instance
@@ -236,9 +172,9 @@ export class ProfileParser {
if (href && title) {
links.push({
title,
type: 'url',
url: href,
title,
})
}
}
@@ -249,9 +185,9 @@ export class ProfileParser {
const locationText = await locationElement.textContent()
if (locationText) {
links.push({
title: locationText.replace('Location', '').trim(),
type: 'location',
url: '',
title: locationText.replace('Location', '').trim(),
})
}
}
@@ -264,6 +200,70 @@ export class ProfileParser {
}
}
/**
* Extract username from Twitter profile URL
* @param url Twitter profile URL
* @returns Username or null if not found
*/
private static extractUsernameFromUrl(url: string): null | string {
try {
const match = url.match(/twitter\.com\/([^/]+)/)
if (match && match[1] && !['explore', 'home', 'messages', 'notifications'].includes(match[1])) {
return match[1]
}
return null
}
catch {
return null
}
}
/**
* Extract user statistics (followers, following, tweets)
* @param page Playwright page instance
* @returns Promise resolving to UserStats object
*/
private static async extractUserStats(page: Page): Promise<UserStats> {
const stats: UserStats = {
followers: 0,
following: 0,
tweets: 0,
}
try {
// Get stats container
const statsContainer = await page.$(SELECTORS.PROFILE.STATS)
if (!statsContainer)
return stats
// Get all stat items
const statItems = await statsContainer.$$('a')
for (const statItem of statItems) {
const text = await statItem.textContent() || ''
if (text.includes('Following')) {
const countText = text.replace(/Following.*/, '').trim()
stats.following = this.parseStatNumber(countText)
}
else if (text.includes('Followers')) {
const countText = text.replace(/Followers.*/, '').trim()
stats.followers = this.parseStatNumber(countText)
}
else if (text.includes('posts') || text.includes('Posts')) {
const countText = text.replace(/posts|Posts.*/, '').trim()
stats.tweets = this.parseStatNumber(countText)
}
}
return stats
}
catch (error) {
logger.parser.error('Error extracting user stats:', (error as Error).message)
return stats
}
}
/**
* Parse stat number with K, M suffixes
* @param text Number text (e.g., "10.5K")
@@ -3,81 +3,81 @@
* Used to locate elements on the page
*/
export const SELECTORS = {
LOGIN: {
USERNAME_INPUT: 'input[autocomplete="username"]',
PASSWORD_INPUT: 'input[type="password"]',
NEXT_BUTTON: 'div[role="button"]:has-text("Next")',
LOGIN_BUTTON: 'div[role="button"]:has-text("Log in")',
NEXT_BUTTON_ALT: '[data-testid="login-next-button"]',
LOGIN_BUTTON_ALT: '[data-testid="login-submit-button"]',
COMPOSE: {
EMOJI_BUTTON: '[data-testid="emojiButton"]',
MEDIA_BUTTON: '[data-testid="imageOrGifButton"]',
POLL_BUTTON: '[data-testid="createPollButton"]',
SCHEDULE_BUTTON: '[data-testid="scheduleButton"]',
TWEET_BUTTON: '[data-testid="tweetButtonInline"]',
TWEET_INPUT: '[data-testid="tweetTextarea_0"]',
},
HOME: {
TIMELINE: '[data-testid="primaryColumn"]',
TRENDING: '[data-testid="sidebarColumn"]',
TWEET_COMPOSER: '[data-testid="tweetButtonInline"]',
},
TIMELINE: {
TWEET: '[data-testid="tweet"]',
TWEET_TEXT: '[data-testid="tweetText"]',
TWEET_TIME: 'time',
LIKE_BUTTON: '[data-testid="like"]',
RETWEET_BUTTON: '[data-testid="retweet"]',
REPLY_BUTTON: '[data-testid="reply"]',
AUTHOR_LINK: '[data-testid="User-Name"] a',
AUTHOR_NAME: '[data-testid="User-Name"] a div span',
AUTHOR_USERNAME: '[data-testid="User-Name"] a div div span',
TWEET_STATS: '[data-testid="tweet"] [role="group"]',
REPLY_COUNT: '[data-testid="reply"] span span',
RETWEET_COUNT: '[data-testid="retweet"] span span',
LIKE_COUNT: '[data-testid="like"] span span',
VIEW_COUNT: '[data-testid="tweet"] [data-testid="app-text-transition-container"] span span',
MEDIA_CONTAINER: '[data-testid="tweetPhoto"]',
VIDEO_CONTAINER: '[data-testid="videoPlayer"]',
TWEET_LINK: 'a[aria-label*="posted"]',
TWEET_ID_CONTAINER: 'a[href*="/status/"]',
},
PROFILE: {
FOLLOW_BUTTON: '[data-testid="followButton"]',
UNFOLLOW_BUTTON: '[data-testid="unfollowButton"]',
DISPLAY_NAME: '[data-testid="UserName"] div span',
USERNAME: '[data-testid="UserName"] div span:has-text("@")',
BIO: '[data-testid="UserDescription"]',
STATS: '[data-testid="UserProfileStats"]',
FOLLOWING_STAT: '[href$="/following"]',
FOLLOWERS_STAT: '[href$="/followers"]',
AVATAR: '[data-testid="UserAvatar-Container"] img',
BANNER: '[data-testid="UserProfileHeader_Items"] img',
JOIN_DATE: '[data-testid="UserProfileHeader_Items"] span:has-text("Joined")',
LOCATION: '[data-testid="UserProfileHeader_Items"] span:has-text("Location")',
WEBSITE: '[data-testid="UserProfileHeader_Items"] a[href^="https"]',
VERIFIED_BADGE: '[data-testid="icon-verified"]',
},
COMPOSE: {
TWEET_INPUT: '[data-testid="tweetTextarea_0"]',
TWEET_BUTTON: '[data-testid="tweetButtonInline"]',
MEDIA_BUTTON: '[data-testid="imageOrGifButton"]',
POLL_BUTTON: '[data-testid="createPollButton"]',
EMOJI_BUTTON: '[data-testid="emojiButton"]',
SCHEDULE_BUTTON: '[data-testid="scheduleButton"]',
},
SEARCH: {
INPUT: '[data-testid="SearchBox_Search_Input"]',
RESULT_TAB: '[role="tablist"] [role="presentation"]',
PEOPLE_TAB: '[role="tab"]:has-text("People")',
LATEST_TAB: '[role="tab"]:has-text("Latest")',
TOP_TAB: '[role="tab"]:has-text("Top")',
SEARCH_FILTERS: '[data-testid="searchFiltersButton"]',
},
NOTIFICATIONS: {
ITEM: '[data-testid="cellInnerDiv"]',
MENTION: '[data-testid="notification"]',
FOLLOW: '[data-testid="notification"]',
LIKE: '[data-testid="notification"]',
RETWEET: '[data-testid="notification"]',
LOGIN: {
LOGIN_BUTTON: 'div[role="button"]:has-text("Log in")',
LOGIN_BUTTON_ALT: '[data-testid="login-submit-button"]',
NEXT_BUTTON: 'div[role="button"]:has-text("Next")',
NEXT_BUTTON_ALT: '[data-testid="login-next-button"]',
PASSWORD_INPUT: 'input[type="password"]',
USERNAME_INPUT: 'input[autocomplete="username"]',
},
MESSAGES: {
CONVERSATION: '[data-testid="conversation"]',
MESSAGE_INPUT: '[data-testid="dmComposerTextInput"]',
SEND_BUTTON: '[data-testid="dmComposerSendButton"]',
},
NOTIFICATIONS: {
FOLLOW: '[data-testid="notification"]',
ITEM: '[data-testid="cellInnerDiv"]',
LIKE: '[data-testid="notification"]',
MENTION: '[data-testid="notification"]',
RETWEET: '[data-testid="notification"]',
},
PROFILE: {
AVATAR: '[data-testid="UserAvatar-Container"] img',
BANNER: '[data-testid="UserProfileHeader_Items"] img',
BIO: '[data-testid="UserDescription"]',
DISPLAY_NAME: '[data-testid="UserName"] div span',
FOLLOW_BUTTON: '[data-testid="followButton"]',
FOLLOWERS_STAT: '[href$="/followers"]',
FOLLOWING_STAT: '[href$="/following"]',
JOIN_DATE: '[data-testid="UserProfileHeader_Items"] span:has-text("Joined")',
LOCATION: '[data-testid="UserProfileHeader_Items"] span:has-text("Location")',
STATS: '[data-testid="UserProfileStats"]',
UNFOLLOW_BUTTON: '[data-testid="unfollowButton"]',
USERNAME: '[data-testid="UserName"] div span:has-text("@")',
VERIFIED_BADGE: '[data-testid="icon-verified"]',
WEBSITE: '[data-testid="UserProfileHeader_Items"] a[href^="https"]',
},
SEARCH: {
INPUT: '[data-testid="SearchBox_Search_Input"]',
LATEST_TAB: '[role="tab"]:has-text("Latest")',
PEOPLE_TAB: '[role="tab"]:has-text("People")',
RESULT_TAB: '[role="tablist"] [role="presentation"]',
SEARCH_FILTERS: '[data-testid="searchFiltersButton"]',
TOP_TAB: '[role="tab"]:has-text("Top")',
},
TIMELINE: {
AUTHOR_LINK: '[data-testid="User-Name"] a',
AUTHOR_NAME: '[data-testid="User-Name"] a div span',
AUTHOR_USERNAME: '[data-testid="User-Name"] a div div span',
LIKE_BUTTON: '[data-testid="like"]',
LIKE_COUNT: '[data-testid="like"] span span',
MEDIA_CONTAINER: '[data-testid="tweetPhoto"]',
REPLY_BUTTON: '[data-testid="reply"]',
REPLY_COUNT: '[data-testid="reply"] span span',
RETWEET_BUTTON: '[data-testid="retweet"]',
RETWEET_COUNT: '[data-testid="retweet"] span span',
TWEET: '[data-testid="tweet"]',
TWEET_ID_CONTAINER: 'a[href*="/status/"]',
TWEET_LINK: 'a[aria-label*="posted"]',
TWEET_STATS: '[data-testid="tweet"] [role="group"]',
TWEET_TEXT: '[data-testid="tweetText"]',
TWEET_TIME: 'time',
VIDEO_CONTAINER: '[data-testid="videoPlayer"]',
VIEW_COUNT: '[data-testid="tweet"] [data-testid="app-text-transition-container"] span span',
},
}
@@ -10,6 +10,54 @@ import { SELECTORS } from './selectors'
* Extracts tweet information directly from the page DOM using Playwright
*/
export class TweetParser {
/**
* Extract tweet data from tweet element
* @param page Playwright page instance
* @param tweetElement Tweet element handle
* @returns Promise resolving to Tweet object
*/
static async extractTweetData(page: Page, tweetElement: ElementHandle): Promise<null | Tweet> {
try {
// Extract tweet ID
const id = await this.extractTweetId(tweetElement)
// Extract tweet text
const textElement = await tweetElement.$(SELECTORS.TIMELINE.TWEET_TEXT)
const text = textElement ? await textElement.textContent() : ''
// Extract author info
const author = await this.extractAuthorInfo(tweetElement)
// Extract timestamp
const timeElement = await tweetElement.$('time')
const timestamp = timeElement ? await timeElement.getAttribute('datetime') : new Date().toISOString()
// Extract engagement stats
const stats = await this.extractTweetStats(tweetElement)
// Extract media URLs
const mediaUrls = await this.extractMediaUrls(tweetElement)
const tweet: Tweet = {
author,
id,
text: text || '',
timestamp: timestamp || new Date().toISOString(),
...stats,
}
if (mediaUrls.length > 0) {
tweet.mediaUrls = mediaUrls
}
return tweet
}
catch (error) {
logger.parser.error('Error extracting tweet data:', (error as Error).message)
return null
}
}
/**
* Parse timeline tweets directly from the page
* @param page Playwright page instance
@@ -38,50 +86,71 @@ export class TweetParser {
}
/**
* Extract tweet data from tweet element
* @param page Playwright page instance
* Extract author info from tweet element
* @param tweetElement Tweet element handle
* @returns Promise resolving to Tweet object
* @returns Promise resolving to author object
*/
static async extractTweetData(page: Page, tweetElement: ElementHandle): Promise<Tweet | null> {
private static async extractAuthorInfo(tweetElement: ElementHandle): Promise<Tweet['author']> {
try {
// Extract tweet ID
const id = await this.extractTweetId(tweetElement)
// Extract tweet text
const textElement = await tweetElement.$(SELECTORS.TIMELINE.TWEET_TEXT)
const text = textElement ? await textElement.textContent() : ''
// Extract author info
const author = await this.extractAuthorInfo(tweetElement)
// Extract timestamp
const timeElement = await tweetElement.$('time')
const timestamp = timeElement ? await timeElement.getAttribute('datetime') : new Date().toISOString()
// Extract engagement stats
const stats = await this.extractTweetStats(tweetElement)
// Extract media URLs
const mediaUrls = await this.extractMediaUrls(tweetElement)
const tweet: Tweet = {
id,
text: text || '',
author,
timestamp: timestamp || new Date().toISOString(),
...stats,
// Find author element
const authorElement = await tweetElement.$('[data-testid="User-Name"]')
if (!authorElement) {
return {
displayName: 'Unknown User',
username: 'unknown',
}
}
if (mediaUrls.length > 0) {
tweet.mediaUrls = mediaUrls
}
// Get display name
const displayNameElement = await authorElement.$('span:first-child')
const displayName = displayNameElement ? await displayNameElement.textContent() || 'Unknown User' : 'Unknown User'
return tweet
// Get username
const usernameElement = await authorElement.$('a[href^="/"]')
let username = usernameElement ? await usernameElement.getAttribute('href') : 'unknown'
username = username?.replace('/', '') || 'unknown'
// Get avatar URL
const avatarElement = await tweetElement.$('img[src*="/profile_images/"]')
const avatarUrl = avatarElement ? await avatarElement.getAttribute('src') : undefined
return {
displayName,
username,
...(avatarUrl && { avatarUrl }),
}
}
catch (error) {
logger.parser.error('Error extracting tweet data:', (error as Error).message)
return null
logger.parser.error('Error extracting author info:', (error as Error).message)
return {
displayName: 'Unknown User',
username: 'unknown',
}
}
}
/**
* Extract media URLs from tweet element
* @param tweetElement Tweet element handle
* @returns Promise resolving to array of media URLs
*/
private static async extractMediaUrls(tweetElement: ElementHandle): Promise<string[]> {
try {
const mediaElements = await tweetElement.$$('img[src*="pbs.twimg.com/media/"]')
const mediaUrls: string[] = []
for (const mediaElement of mediaElements) {
const src = await mediaElement.getAttribute('src')
if (src) {
mediaUrls.push(src)
}
}
return mediaUrls
}
catch (error) {
logger.parser.error('Error extracting media URLs:', (error as Error).message)
return []
}
}
@@ -113,50 +182,6 @@ export class TweetParser {
}
}
/**
* Extract author info from tweet element
* @param tweetElement Tweet element handle
* @returns Promise resolving to author object
*/
private static async extractAuthorInfo(tweetElement: ElementHandle): Promise<Tweet['author']> {
try {
// Find author element
const authorElement = await tweetElement.$('[data-testid="User-Name"]')
if (!authorElement) {
return {
username: 'unknown',
displayName: 'Unknown User',
}
}
// Get display name
const displayNameElement = await authorElement.$('span:first-child')
const displayName = displayNameElement ? await displayNameElement.textContent() || 'Unknown User' : 'Unknown User'
// Get username
const usernameElement = await authorElement.$('a[href^="/"]')
let username = usernameElement ? await usernameElement.getAttribute('href') : 'unknown'
username = username?.replace('/', '') || 'unknown'
// Get avatar URL
const avatarElement = await tweetElement.$('img[src*="/profile_images/"]')
const avatarUrl = avatarElement ? await avatarElement.getAttribute('src') : undefined
return {
username,
displayName,
...(avatarUrl && { avatarUrl }),
}
}
catch (error) {
logger.parser.error('Error extracting author info:', (error as Error).message)
return {
username: 'unknown',
displayName: 'Unknown User',
}
}
}
/**
* Extract tweet stats (likes, retweets, replies)
* @param tweetElement Tweet element handle
@@ -164,13 +189,13 @@ export class TweetParser {
*/
private static async extractTweetStats(tweetElement: ElementHandle): Promise<{
likeCount?: number
retweetCount?: number
replyCount?: number
retweetCount?: number
}> {
const stats: {
likeCount?: number
retweetCount?: number
replyCount?: number
retweetCount?: number
} = {}
try {
@@ -206,37 +231,12 @@ export class TweetParser {
}
}
/**
* Extract media URLs from tweet element
* @param tweetElement Tweet element handle
* @returns Promise resolving to array of media URLs
*/
private static async extractMediaUrls(tweetElement: ElementHandle): Promise<string[]> {
try {
const mediaElements = await tweetElement.$$('img[src*="pbs.twimg.com/media/"]')
const mediaUrls: string[] = []
for (const mediaElement of mediaElements) {
const src = await mediaElement.getAttribute('src')
if (src) {
mediaUrls.push(src)
}
}
return mediaUrls
}
catch (error) {
logger.parser.error('Error extracting media URLs:', (error as Error).message)
return []
}
}
/**
* Parse count text (handles K, M suffixes)
* @param countText Count text from tweet
* @returns Parsed number or undefined
*/
private static parseCount(countText: string | null): number | undefined {
private static parseCount(countText: null | string): number | undefined {
if (!countText)
return undefined
@@ -2,16 +2,16 @@
* Browser Configuration Types
*/
export interface Viewport {
width: number
height: number
}
export interface BrowserConfig {
headless: boolean
requestRetries: number
requestTimeout: number
timeout: number
userAgent: string
viewport: Viewport
timeout: number
requestTimeout: number
requestRetries: number
}
export interface Viewport {
height: number
width: number
}
@@ -7,15 +7,15 @@ export interface SearchOptions {
includeReplies?: boolean
includeRetweets?: boolean
lang?: string
resultType?: 'recent' | 'popular' | 'mixed'
maxId?: string
resultType?: 'mixed' | 'popular' | 'recent'
sinceId?: string
}
export interface TimelineOptions {
count?: number
includeReplies?: boolean
includeRetweets?: boolean
excludeReplies?: boolean
includePromoted?: boolean
includeReplies?: boolean
includeRetweets?: boolean
}
@@ -1,3 +1,34 @@
/**
* Create an error with detailed context information
*
* @param message - Error message
* @param originalError - Original error object (optional)
* @param context - Additional context information (optional)
* @returns Enhanced error object
*/
export function createError(
message: string,
originalError?: unknown,
context?: Record<string, unknown>,
): Error {
let errorMessage = message
// Add original error information
if (originalError) {
errorMessage += `: ${errorToMessage(originalError)}`
}
// Create new error object
const error = new Error(errorMessage)
// Add context information
if (context) {
Object.assign(error, { context })
}
return error
}
/**
* Safely extract error message from any error type
* Handles Error objects, strings, objects, and other types
@@ -41,34 +72,3 @@ export function errorToMessage(error: unknown, fallbackMessage = 'Unknown error'
// For other cases, try to force convert to string
return String(error)
}
/**
* Create an error with detailed context information
*
* @param message - Error message
* @param originalError - Original error object (optional)
* @param context - Additional context information (optional)
* @returns Enhanced error object
*/
export function createError(
message: string,
originalError?: unknown,
context?: Record<string, unknown>,
): Error {
let errorMessage = message
// Add original error information
if (originalError) {
errorMessage += `: ${errorToMessage(originalError)}`
}
// Create new error object
const error = new Error(errorMessage)
// Add context information
if (context) {
Object.assign(error, { context })
}
return error
}
@@ -22,11 +22,11 @@ export function initLogger(): void {
const config = useConfigManager().getConfig()
const logLevelMap: Record<string, LogLevel> = {
debug: LogLevel.Debug,
error: LogLevel.Error,
warn: LogLevel.Warning,
info: LogLevel.Log,
verbose: LogLevel.Verbose,
debug: LogLevel.Debug,
warn: LogLevel.Warning,
}
setGlobalLogLevel(logLevelMap[config.system?.logLevel] || LogLevel.Debug)
@@ -64,12 +64,12 @@ export function useLogger(name?: string): Logg {
// Create pre-configured loggers for various services
export const logger = {
auth: useLogger('auth-service'),
timeline: useLogger('timeline-service'),
browser: useLogger('browser-adapter'),
airi: useLogger('airi-adapter'),
auth: useLogger('auth-service'),
browser: useLogger('browser-adapter'),
config: useLogger('config'),
main: useLogger('twitter-service'),
mcp: useLogger('mcp-adapter'),
parser: useLogger('parser'),
main: useLogger('twitter-service'),
config: useLogger('config'),
timeline: useLogger('timeline-service'),
}