diff --git a/packages/core/src/types/node.ts b/packages/core/src/types/node.ts index 45327ef..909ecd8 100644 --- a/packages/core/src/types/node.ts +++ b/packages/core/src/types/node.ts @@ -30,6 +30,8 @@ export interface NodeCredentials { bearerToken?: string /** TwitterAPI.io API key (third-party, simpler) */ twitterApiIoKey?: string + /** Xquik API key for read-only X/Twitter search */ + xquikApiKey?: string /** OAuth 1.0a Consumer Key (API Key) */ consumerKey?: string /** OAuth 1.0a Consumer Secret (API Secret) */ diff --git a/packages/docs-mcp/src/docs.ts b/packages/docs-mcp/src/docs.ts index bbbde85..e8ff255 100644 --- a/packages/docs-mcp/src/docs.ts +++ b/packages/docs-mcp/src/docs.ts @@ -371,7 +371,7 @@ Search Twitter/X for posts matching keywords. - **Estimated Duration:** 15s - **Capabilities:** supportsRerun - **Services:** Required: twitter -- **Input:** keywords (required), excludeRetweets, minLikes, maxResults, lang, sinceDays +- **Input:** keywords (required), excludeRetweets, minLikes, maxResults, lang, sinceDays, apiProvider (auto|twitterapi_io|xquik) - **Output:** posts (TwitterPost[]), totalFound, hasMore, cursor ### linkedin_monitor diff --git a/packages/nodes/src/integrations/social/credentials.ts b/packages/nodes/src/integrations/social/credentials.ts index 255e640..b8b98e7 100644 --- a/packages/nodes/src/integrations/social/credentials.ts +++ b/packages/nodes/src/integrations/social/credentials.ts @@ -32,5 +32,7 @@ export const twitterCredential = defineOAuth2Credential({ consumerSecret: z.string().optional(), accessTokenSecret: z.string().optional(), bearerToken: z.string().optional(), + twitterApiIoKey: z.string().optional(), + xquikApiKey: z.string().optional(), }), }); diff --git a/packages/nodes/src/integrations/social/twitter-extended.test.ts b/packages/nodes/src/integrations/social/twitter-extended.test.ts index b920f82..7aa8966 100644 --- a/packages/nodes/src/integrations/social/twitter-extended.test.ts +++ b/packages/nodes/src/integrations/social/twitter-extended.test.ts @@ -4,6 +4,7 @@ import { twitterCreateTweetNode, twitterDeleteTweetNode, twitterLikeTweetNode, + twitterMonitorNode, twitterRetweetNode, twitterSearchTweetsNode, twitterSendDMNode, @@ -290,3 +291,97 @@ describe('twitter_get_user_by_username', () => { ); }); }); + +describe('twitter_monitor', () => { + it('searches with Xquik when requested', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + tweets: [ + { + id: 't1', + text: 'hello from xquik', + createdAt: '2026-02-20T00:00:00.000Z', + likeCount: 12, + replyCount: 3, + retweetCount: 4, + viewCount: 100, + author: { + username: 'jam', + name: 'Jam', + followers: 250, + }, + }, + ], + has_next_page: true, + next_cursor: 'next_1', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await twitterMonitorNode.executor( + { + keywords: ['jam nodes'], + maxResults: 10, + apiProvider: 'xquik', + }, + { + ...baseContext, + credentials: { twitter: { xquikApiKey: 'xquik_key' } }, + } + ); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('https://xquik.com/api/v1/x/tweets/search?'); + expect(url).toContain('q=%28%22jam+nodes%22%29+-is%3Aretweet'); + expect(url).toContain('limit=10'); + const headers = init.headers as Record; + expect(headers['x-api-key']).toBe('xquik_key'); + expect(headers['xquik-api-contract']).toBe('2026-04-29'); + expect(result.output?.posts[0]?.authorHandle).toBe('jam'); + expect(result.output?.cursor).toBe('next_1'); + }); + + it('uses Xquik automatically when it is the only search key', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ tweets: [], has_next_page: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await twitterMonitorNode.executor( + { keywords: ['jam'] }, + { + ...baseContext, + credentials: { twitter: { xquikApiKey: 'xquik_key' } }, + } + ); + + expect(result.success).toBe(true); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + 'https://xquik.com/api/v1/x/tweets/search?' + ); + }); + + it('returns a clear error when the requested Xquik key is missing', async () => { + const result = await twitterMonitorNode.executor( + { + keywords: ['jam'], + apiProvider: 'xquik', + }, + baseContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('context.credentials.twitter.xquikApiKey'); + }); +}); diff --git a/packages/nodes/src/integrations/social/twitter-monitor.ts b/packages/nodes/src/integrations/social/twitter-monitor.ts index cfd7796..9b64963 100644 --- a/packages/nodes/src/integrations/social/twitter-monitor.ts +++ b/packages/nodes/src/integrations/social/twitter-monitor.ts @@ -7,6 +7,10 @@ import { fetchWithRetry } from '../../utils/http.js'; // ============================================================================= const TWITTERAPI_BASE_URL = 'https://api.twitterapi.io'; +const XQUIK_BASE_URL = 'https://xquik.com/api/v1'; +const XQUIK_API_CONTRACT = '2026-04-29'; + +type TwitterSearchProvider = 'auto' | 'twitterapi_io' | 'xquik'; // ============================================================================= // Types @@ -51,6 +55,33 @@ interface TwitterApiSearchResponse { next_cursor: string; } +interface XquikTweet { + id: string; + url?: string; + text: string; + retweetCount?: number; + replyCount?: number; + likeCount?: number; + quoteCount?: number; + viewCount?: number; + createdAt?: string; + author?: { + id?: string; + username?: string; + userName?: string; + name?: string; + followers?: number; + isVerified?: boolean; + verified?: boolean; + }; +} + +interface XquikSearchResponse { + tweets?: XquikTweet[]; + has_next_page?: boolean; + next_cursor?: string; +} + /** * Twitter/X post in unified social format */ @@ -89,6 +120,8 @@ export const TwitterMonitorInputSchema = z.object({ lang: z.string().optional(), /** Search tweets from last N days */ sinceDays: z.number().optional(), + /** Search provider to use */ + apiProvider: z.enum(['auto', 'twitterapi_io', 'xquik']).optional().default('auto'), }); export type TwitterMonitorInput = z.infer; @@ -191,6 +224,118 @@ async function searchTwitter( return response.json() as Promise; } +async function searchXquik( + apiKey: string, + query: string, + queryType: 'Latest' | 'Top', + limit: number +): Promise { + const url = new URL(`${XQUIK_BASE_URL}/x/tweets/search`); + url.searchParams.set('q', query); + url.searchParams.set('queryType', queryType); + url.searchParams.set('limit', String(limit)); + + const response = await fetchWithRetry( + url.toString(), + { + method: 'GET', + headers: { + 'x-api-key': apiKey, + 'xquik-api-contract': XQUIK_API_CONTRACT, + }, + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Xquik API error: ${response.status} - ${errorText}`); + } + + return response.json() as Promise; +} + +function getSearchProvider( + inputProvider: TwitterSearchProvider, + credentials: { + twitterApiIoKey?: string; + xquikApiKey?: string; + } = {} +): { provider: Exclude; apiKey: string } | { error: string } { + if (inputProvider === 'twitterapi_io') { + if (!credentials.twitterApiIoKey) { + return { + error: 'TwitterAPI.io key not configured. Please provide context.credentials.twitter.twitterApiIoKey.', + }; + } + return { provider: 'twitterapi_io', apiKey: credentials.twitterApiIoKey }; + } + + if (inputProvider === 'xquik') { + if (!credentials.xquikApiKey) { + return { + error: 'Xquik API key not configured. Please provide context.credentials.twitter.xquikApiKey.', + }; + } + return { provider: 'xquik', apiKey: credentials.xquikApiKey }; + } + + if (credentials.twitterApiIoKey) { + return { provider: 'twitterapi_io', apiKey: credentials.twitterApiIoKey }; + } + + if (credentials.xquikApiKey) { + return { provider: 'xquik', apiKey: credentials.xquikApiKey }; + } + + return { + error: 'Twitter search key not configured. Provide context.credentials.twitter.twitterApiIoKey or context.credentials.twitter.xquikApiKey.', + }; +} + +function normalizeTwitterApiPost(tweet: TwitterApiTweet): TwitterPost { + return { + id: tweet.id, + platform: 'twitter', + url: tweet.url, + text: tweet.text, + authorName: tweet.author.name, + authorHandle: tweet.author.userName, + authorUrl: `https://twitter.com/${tweet.author.userName}`, + authorFollowers: tweet.author.followers, + engagement: { + likes: tweet.likeCount, + comments: tweet.replyCount, + shares: tweet.retweetCount, + views: tweet.viewCount || 0, + }, + postedAt: tweet.createdAt, + }; +} + +function normalizeXquikPost(tweet: XquikTweet): TwitterPost { + const author = tweet.author || {}; + const authorHandle = author.username || author.userName || 'unknown'; + + return { + id: tweet.id, + platform: 'twitter', + url: tweet.url || `https://x.com/${authorHandle}/status/${tweet.id}`, + text: tweet.text, + authorName: author.name || authorHandle, + authorHandle, + authorUrl: authorHandle === 'unknown' ? '' : `https://x.com/${authorHandle}`, + authorFollowers: author.followers || 0, + engagement: { + likes: tweet.likeCount || 0, + comments: tweet.replyCount || 0, + shares: tweet.retweetCount || 0, + views: tweet.viewCount || 0, + }, + postedAt: tweet.createdAt || '', + }; +} + // ============================================================================= // Node Definition // ============================================================================= @@ -198,8 +343,8 @@ async function searchTwitter( /** * Twitter Monitor Node * - * Searches Twitter/X for posts matching keywords using TwitterAPI.io. - * Requires `context.credentials.twitter.twitterApiIoKey` to be provided. + * Searches Twitter/X for posts matching keywords using TwitterAPI.io or Xquik. + * Requires `context.credentials.twitter.twitterApiIoKey` or `xquikApiKey`. * * @example * ```typescript @@ -232,12 +377,14 @@ export const twitterMonitorNode = defineNode({ }; } - // Check for API key (prefer TwitterAPI.io key) - const apiKey = context.credentials?.twitter?.twitterApiIoKey; - if (!apiKey) { + const provider = getSearchProvider( + input.apiProvider || 'auto', + context.credentials?.twitter + ); + if ('error' in provider) { return { success: false, - error: 'Twitter API key not configured. Please provide context.credentials.twitter.twitterApiIoKey.', + error: provider.error, }; } @@ -255,36 +402,23 @@ export const twitterMonitorNode = defineNode({ lang: input.lang, }); - // Search tweets - const response = await searchTwitter(apiKey, query, 'Latest'); + const maxResults = input.maxResults || 50; + const response = provider.provider === 'xquik' + ? await searchXquik(provider.apiKey, query, 'Latest', maxResults) + : await searchTwitter(provider.apiKey, query, 'Latest'); - // Transform to unified format const posts: TwitterPost[] = (response.tweets || []) - .slice(0, input.maxResults || 50) - .map((tweet) => ({ - id: tweet.id, - platform: 'twitter' as const, - url: tweet.url, - text: tweet.text, - authorName: tweet.author.name, - authorHandle: tweet.author.userName, - authorUrl: `https://twitter.com/${tweet.author.userName}`, - authorFollowers: tweet.author.followers, - engagement: { - likes: tweet.likeCount, - comments: tweet.replyCount, - shares: tweet.retweetCount, - views: tweet.viewCount || 0, - }, - postedAt: tweet.createdAt, - })); + .slice(0, maxResults) + .map((tweet) => provider.provider === 'xquik' + ? normalizeXquikPost(tweet as XquikTweet) + : normalizeTwitterApiPost(tweet as TwitterApiTweet)); return { success: true, output: { posts, totalFound: posts.length, - hasMore: response.has_next_page, + hasMore: Boolean(response.has_next_page), cursor: response.next_cursor || undefined, }, }; diff --git a/packages/playground-web/src/lib/credentials.ts b/packages/playground-web/src/lib/credentials.ts index 75eba79..72f8bc1 100644 --- a/packages/playground-web/src/lib/credentials.ts +++ b/packages/playground-web/src/lib/credentials.ts @@ -117,6 +117,7 @@ export const CREDENTIAL_SCHEMAS: Record< { name: 'clientId', label: 'Client ID', type: 'text' }, { name: 'clientSecret', label: 'Client Secret', type: 'password' }, { name: 'twitterApiIoKey', label: 'TwitterAPI.io Key', type: 'password' }, + { name: 'xquikApiKey', label: 'Xquik API Key', type: 'password' }, ], linkedin: [{ name: 'accessToken', label: 'Access Token', type: 'password' }], reddit: [ diff --git a/packages/playground/src/commands/run.ts b/packages/playground/src/commands/run.ts index a6ba69e..6438d2e 100644 --- a/packages/playground/src/commands/run.ts +++ b/packages/playground/src/commands/run.ts @@ -48,7 +48,10 @@ const NODE_CREDENTIAL_REQUIREMENTS: Record< twitter_monitor: [ { service: 'twitter', - fields: [{ name: 'twitterApiIoKey', envVar: 'TWITTERAPI_IO_KEY' }], + fields: [ + { name: 'twitterApiIoKey', envVar: 'TWITTERAPI_IO_KEY' }, + { name: 'xquikApiKey', envVar: 'XQUIK_API_KEY' }, + ], }, ], twitter_create_tweet: [ diff --git a/packages/playground/src/credentials/env-provider.ts b/packages/playground/src/credentials/env-provider.ts index 7808f00..ebbab5f 100644 --- a/packages/playground/src/credentials/env-provider.ts +++ b/packages/playground/src/credentials/env-provider.ts @@ -50,6 +50,8 @@ const ENV_VAR_MAPPINGS: Record = { 'TWITTER_BEARER_TOKEN', 'JAM_TWITTERAPI_IO_KEY', 'TWITTERAPI_IO_KEY', + 'JAM_XQUIK_API_KEY', + 'XQUIK_API_KEY', 'JAM_TWITTER_CONSUMER_KEY', 'TWITTER_CONSUMER_KEY', 'JAM_TWITTER_CONSUMER_SECRET', @@ -81,6 +83,7 @@ function getFromEnv(name: string): Record | null { const accessToken = process.env['JAM_TWITTER_ACCESS_TOKEN'] || process.env['TWITTER_ACCESS_TOKEN']; const bearerToken = process.env['JAM_TWITTER_BEARER_TOKEN'] || process.env['TWITTER_BEARER_TOKEN']; const twitterApiIoKey = process.env['JAM_TWITTERAPI_IO_KEY'] || process.env['TWITTERAPI_IO_KEY']; + const xquikApiKey = process.env['JAM_XQUIK_API_KEY'] || process.env['XQUIK_API_KEY']; const refreshToken = process.env['JAM_TWITTER_REFRESH_TOKEN'] || process.env['TWITTER_REFRESH_TOKEN']; const clientId = process.env['JAM_TWITTER_CLIENT_ID'] || process.env['TWITTER_CLIENT_ID']; const clientSecret = process.env['JAM_TWITTER_CLIENT_SECRET'] || process.env['TWITTER_CLIENT_SECRET']; @@ -92,6 +95,7 @@ function getFromEnv(name: string): Record | null { if (accessToken) credentials['accessToken'] = accessToken; if (bearerToken) credentials['bearerToken'] = bearerToken; if (twitterApiIoKey) credentials['twitterApiIoKey'] = twitterApiIoKey; + if (xquikApiKey) credentials['xquikApiKey'] = xquikApiKey; if (refreshToken) credentials['refreshToken'] = refreshToken; if (clientId) credentials['clientId'] = clientId; if (clientSecret) credentials['clientSecret'] = clientSecret;