Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/_module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export {
export { randomizeBrowserProfile, type BrowserProfile } from './castle';
export { Scraper, type ScraperOptions } from './scraper';
export { SearchMode } from './search';
export {
XquikSearchClient,
xquikTweetToTweet,
type XquikQueryType,
type XquikSearchClientOptions,
type XquikSearchOptions,
} from './xquik';
export type { QueryProfilesResponse, QueryTweetsResponse } from './timeline-v1';
export type {
Tweet,
Expand Down
92 changes: 92 additions & 0 deletions src/xquik.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { XquikSearchClient, xquikTweetToTweet } from './xquik';

test('maps Xquik tweets to Tweet objects', () => {
expect.assertions(10);

const tweet = xquikTweetToTweet({
id: '123',
text: 'hello',
createdAt: '2026-01-02T03:04:05Z',
likeCount: 7,
retweetCount: 2,
replyCount: 1,
viewCount: 99,
isReply: true,
isQuoteStatus: false,
conversationId: '100',
media: [{ type: 'photo', mediaUrl: 'https://pbs.twimg.com/media/a.jpg' }],
author: {
id: '42',
username: 'alice',
name: 'Alice',
},
});

expect(tweet.id).toBe('123');
expect(tweet.text).toBe('hello');
expect(tweet.username).toBe('alice');
expect(tweet.name).toBe('Alice');
expect(tweet.likes).toBe(7);
expect(tweet.retweets).toBe(2);
expect(tweet.replies).toBe(1);
expect(tweet.timestamp).toBe(1767323045);
expect(tweet.photos).toEqual([
{
id: 'https://pbs.twimg.com/media/a.jpg',
url: 'https://pbs.twimg.com/media/a.jpg',
alt_text: undefined,
},
]);
expect(tweet.permanentUrl).toBe('https://x.com/alice/status/123');
});

test('searches through the Xquik API without leaking the key in errors', async () => {
expect.assertions(7);
const fetchMock = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
tweets: [
{
id: '123',
text: 'hello',
author: { username: 'alice', name: 'Alice' },
},
],
}),
}));

const client = new XquikSearchClient({
apiKey: 'test-key',
fetch: fetchMock as unknown as typeof fetch,
});
const tweets = await client.searchTweets('launch', {
limit: 5,
queryType: 'Top',
});
const [url, init] = fetchMock.mock.calls[0];

expect(tweets).toHaveLength(1);
expect(tweets[0]?.id).toBe('123');
expect(String(url)).toContain('/api/v1/x/tweets/search?q=launch');
expect(String(url)).toContain('queryType=Top');
expect(String(url)).toContain('limit=5');
expect(init?.headers).toMatchObject({ 'x-api-key': 'test-key' });
expect(init?.headers).toMatchObject({ Accept: 'application/json' });
});

test('reports HTTP status without including the API key', async () => {
expect.assertions(2);
const fetchMock = jest.fn(async () => ({
ok: false,
status: 401,
json: async () => ({}),
}));
const client = new XquikSearchClient({
apiKey: 'test-key',
fetch: fetchMock as unknown as typeof fetch,
});

await expect(client.searchTweets('launch')).rejects.toThrow('HTTP 401');
await expect(client.searchTweets('launch')).rejects.not.toThrow('test-key');
});
184 changes: 184 additions & 0 deletions src/xquik.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import fetch from 'cross-fetch';
import { Tweet } from './tweets';

export type XquikQueryType = 'Latest' | 'Top';

export interface XquikSearchOptions {
limit?: number;
queryType?: XquikQueryType;
}

export interface XquikSearchClientOptions {
apiKey: string;
baseUrl?: string;
fetch?: typeof fetch;
}

type JsonRecord = Record<string, unknown>;

function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function stringValue(record: JsonRecord, key: string): string | undefined {
const value = record[key];
return typeof value === 'string' && value !== '' ? value : undefined;
}

function numberValue(record: JsonRecord, key: string): number | undefined {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}

function booleanValue(record: JsonRecord, key: string): boolean {
return record[key] === true;
}

function dateFromIso(value: string | undefined): Date | undefined {
if (value == null) {
return undefined;
}
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? undefined : date;
}

function mapMedia(value: unknown): Pick<Tweet, 'photos' | 'videos'> {
if (!Array.isArray(value)) {
return { photos: [], videos: [] };
}

const photos: Tweet['photos'] = [];
const videos: Tweet['videos'] = [];

for (const item of value) {
if (!isRecord(item)) {
continue;
}
const mediaUrl = stringValue(item, 'mediaUrl') ?? stringValue(item, 'url');
if (mediaUrl == null) {
continue;
}
const mediaType = stringValue(item, 'type');
if (mediaType === 'video' || mediaType === 'animated_gif') {
videos.push({ id: mediaUrl, preview: mediaUrl, url: mediaUrl });
} else {
photos.push({ id: mediaUrl, url: mediaUrl, alt_text: undefined });
}
}

return { photos, videos };
}

export function xquikTweetToTweet(raw: unknown): Tweet {
if (!isRecord(raw)) {
throw new TypeError('Xquik tweet must be an object.');
}

const author = isRecord(raw.author) ? raw.author : {};
const createdAt = dateFromIso(stringValue(raw, 'createdAt'));
const permanentUrl =
stringValue(raw, 'url') ??
(stringValue(author, 'username') != null && stringValue(raw, 'id') != null
? `https://x.com/${stringValue(author, 'username')}/status/${stringValue(
raw,
'id',
)}`
: undefined);
const media = mapMedia(raw.media);

return {
id: stringValue(raw, 'id'),
conversationId: stringValue(raw, 'conversationId'),
hashtags: [],
mentions: [],
name: stringValue(author, 'name'),
permanentUrl,
text: stringValue(raw, 'text'),
timestamp:
createdAt == null ? undefined : Math.floor(createdAt.valueOf() / 1000),
timeParsed: createdAt,
urls: permanentUrl == null ? [] : [permanentUrl],
userId: stringValue(author, 'id'),
username: stringValue(author, 'username'),
views: numberValue(raw, 'viewCount'),
likes: numberValue(raw, 'likeCount'),
replies: numberValue(raw, 'replyCount'),
retweets: numberValue(raw, 'retweetCount'),
photos: media.photos,
videos: media.videos,
isQuoted: booleanValue(raw, 'isQuoteStatus'),
isReply: booleanValue(raw, 'isReply'),
isRetweet: false,
inReplyToStatusId: stringValue(raw, 'inReplyToId'),
quotedStatus: isRecord(raw.quoted_tweet)
? xquikTweetToTweet(raw.quoted_tweet)
: undefined,
quotedStatusId: isRecord(raw.quoted_tweet)
? stringValue(raw.quoted_tweet, 'id')
: undefined,
thread: [],
sensitiveContent: undefined,
};
}

export class XquikSearchClient {
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;

public constructor(options: XquikSearchClientOptions) {
if (options.apiKey === '') {
throw new Error('Xquik API key required.');
}

const baseUrl = options.baseUrl ?? 'https://xquik.com';
const parsed = new URL(baseUrl);
if (parsed.protocol !== 'https:') {
throw new Error('Xquik base URL must use HTTPS.');
}

this.apiKey = options.apiKey;
this.baseUrl = baseUrl.replace(/\/$/, '');
this.fetchImpl = options.fetch ?? fetch;
}

public async searchTweets(
query: string,
options: XquikSearchOptions = {},
): Promise<Tweet[]> {
const queryType = options.queryType ?? 'Latest';
if (queryType !== 'Latest' && queryType !== 'Top') {
throw new Error('queryType must be Latest or Top.');
}

const url = new URL('/api/v1/x/tweets/search', this.baseUrl);
url.searchParams.set('q', query);
url.searchParams.set('queryType', queryType);
url.searchParams.set('limit', String(Math.min(options.limit ?? 20, 200)));

const response = await this.fetchImpl(url.toString(), {
headers: {
Accept: 'application/json',
'x-api-key': this.apiKey,
},
});

if (!response.ok) {
throw new Error(`Xquik search failed with HTTP ${response.status}.`);
}

const payload = await response.json();
if (!isRecord(payload) || !Array.isArray(payload.tweets)) {
throw new Error('Xquik search returned an invalid response.');
}

return payload.tweets.map(xquikTweetToTweet);
}
}