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
4 changes: 2 additions & 2 deletions lib/routes/twitter/api/web-api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import InvalidParameterError from '@/errors/types/invalid-parameter';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';

import { baseUrl, gqlFeatures, gqlMap } from './constants';
import { baseUrl, gqlFeatures, gqlMap, initGqlMap } from './constants';
import { gatherLegacyFromData, paginationTweets, twitterGot } from './utils';

const getUserData = (id) =>
Expand Down Expand Up @@ -216,5 +216,5 @@ export default {
getList,
getHomeTimeline,
getHomeLatestTimeline,
init: () => {},
init: initGqlMap,
};
23 changes: 9 additions & 14 deletions lib/routes/twitter/api/web-api/constants.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { buildGqlMap, fallbackIds, resolveQueryIds } from './gql-id-resolver';

const baseUrl = 'https://x.com/i/api';

const graphQLEndpointsPlain = [
'/graphql/E3opETHurmVJflFsUBVuUQ/UserTweets',
'/graphql/Yka-W8dz7RaEuQNkroPkYw/UserByScreenName',
'/graphql/HJFjzBgCs16TqxewQOeLNg/HomeTimeline',
'/graphql/DiTkXJgLqBBxCs7zaYsbtA/HomeLatestTimeline',
'/graphql/bt4TKuFz4T7Ckk-VvQVSow/UserTweetsAndReplies',
'/graphql/dexO_2tohK86JDudXXG3Yw/UserMedia',
'/graphql/Qw77dDjp9xCpUY-AXwt-yQ/UserByRestId',
'/graphql/UN1i3zUiCWa-6r-Uaho4fw/SearchTimeline',
'/graphql/Pa45JvqZuKcW1plybfgBlQ/ListLatestTweetsTimeline',
'/graphql/QuBlQ6SxNAQCt6-kBiCXCQ/TweetDetail',
];
// Initial gqlMap from fallback IDs, updated dynamically via initGqlMap()
let gqlMap: Record<string, string> = buildGqlMap(fallbackIds);

const gqlMap = Object.fromEntries(graphQLEndpointsPlain.map((endpoint) => [endpoint.split('/')[3].replace(/V2$|Query$|QueryV2$/, ''), endpoint]));
const initGqlMap = async () => {
const queryIds = await resolveQueryIds();
gqlMap = buildGqlMap(queryIds);
};

const thirdPartySupportedAPI = ['UserByScreenName', 'UserByRestId', 'UserTweets', 'UserTweetsAndReplies', 'ListLatestTweetsTimeline', 'SearchTimeline', 'UserMedia'];

Expand Down Expand Up @@ -114,4 +109,4 @@ const timelineParams = {

const bearerToken = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';

export { baseUrl, bearerToken, gqlFeatures, gqlMap, thirdPartySupportedAPI, timelineParams };
export { baseUrl, bearerToken, gqlFeatures, gqlMap, initGqlMap, thirdPartySupportedAPI, timelineParams };
169 changes: 169 additions & 0 deletions lib/routes/twitter/api/web-api/gql-id-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import cache from '@/utils/cache';
import logger from '@/utils/logger';
import ofetch from '@/utils/ofetch';

const CACHE_KEY = 'twitter:gql-query-ids';
const CACHE_TTL = 86400; // 24 hours

// Hardcoded fallback IDs (last known working values)
export const fallbackIds: Record<string, string> = {
UserTweets: 'E3opETHurmVJflFsUBVuUQ',
UserByScreenName: 'Yka-W8dz7RaEuQNkroPkYw',
HomeTimeline: 'xhYBF94fPSp8ey64FfYXiA',
HomeLatestTimeline: '0vp2Au9doTKsbn2vIk48Dg',
UserTweetsAndReplies: 'bt4TKuFz4T7Ckk-VvQVSow',
UserMedia: 'dexO_2tohK86JDudXXG3Yw',
UserByRestId: 'Qw77dDjp9xCpUY-AXwt-yQ',
SearchTimeline: 'UN1i3zUiCWa-6r-Uaho4fw',
ListLatestTweetsTimeline: 'Pa45JvqZuKcW1plybfgBlQ',
TweetDetail: 'QuBlQ6SxNAQCt6-kBiCXCQ',
};

const operationNames = Object.keys(fallbackIds);

async function fetchTwitterPage(): Promise<string> {
const response = await ofetch('https://x.com', {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use this fixed version of UA instead of RSSHub's auto-generated UA string? Does the site only works with this specific version of UA string?

},
parseResponse: (txt) => txt,
});
return response as unknown as string;
}

function extractScriptUrls(html: string): string[] {
const urls: string[] = [];

// Extract main.xxx.js URL
const mainMatch = html.match(/\/client-web\/main\.([a-z0-9]+)\./);
if (mainMatch) {
urls.push(`https://abs.twimg.com/responsive-web/client-web/main.${mainMatch[1]}.js`);
}

// Extract other script bundle URLs from the chunk map
// Twitter embeds a JSON map like: e=>e+"."+{"chunk1":"hash1","chunk2":"hash2"}[e]+"a.js"
const chunkMatch = html.match(/e=>e\+"\."[+](.+?)\[e\][+]"a\.js"/);
if (chunkMatch) {
try {
const chunks = JSON.parse(chunkMatch[1]);
for (const [key, value] of Object.entries(chunks)) {
const url = `https://abs.twimg.com/responsive-web/client-web/${key}.${value}a.js`;
// Skip i18n, icon, and syntax highlighter bundles
if (!url.includes('/i18n/') && !url.includes('/icons/') && !url.includes('react-syntax-highlighter')) {
urls.push(url);
}
}
} catch {
logger.debug('twitter gql-id-resolver: failed to parse chunk map');
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main.hash.js has already got those 10 graphql hashes. Why other files are still needed?


return urls;
}

function extractQueryIds(scriptContent: string): Record<string, string> {
const ids: Record<string, string> = {};
// Match patterns like: queryId:"xhYBF94fPSp8ey64FfYXiA",operationName:"HomeTimeline"
// Also handles: queryId:"xxx",...,operationName:"yyy" with other fields in between
const matches = scriptContent.matchAll(/queryId:"([^"]+?)".+?operationName:"([^"]+?)"/g);
for (const match of matches) {
const [, queryId, operationName] = match;
if (operationNames.includes(operationName)) {
ids[operationName] = queryId;
}
}
return ids;
}

async function fetchAndExtractIds(): Promise<Record<string, string>> {
const html = await fetchTwitterPage();
const scriptUrls = extractScriptUrls(html);

if (scriptUrls.length === 0) {
logger.warn('twitter gql-id-resolver: no script URLs found in Twitter page');
return {};
}

logger.debug(`twitter gql-id-resolver: found ${scriptUrls.length} script URLs`);

const results = await Promise.allSettled(
scriptUrls.map(async (url) => {
const content = await ofetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use this fixed version of UA instead of RSSHub's auto-generated UA string? Does the site only works with this specific version of UA string?

},
parseResponse: (txt) => txt,
});
return extractQueryIds(content as unknown as string);
})
);

const allIds: Record<string, string> = {};
for (const result of results) {
if (result.status === 'fulfilled') {
Object.assign(allIds, result.value);
}
}

return allIds;
}

let resolvePromise: Promise<Record<string, string>> | null = null;

export async function resolveQueryIds(): Promise<Record<string, string>> {
// Check cache first (tolerate cache failures)
try {
const cached = await cache.get(CACHE_KEY);
if (cached) {
const parsed = typeof cached === 'string' ? JSON.parse(cached) : cached;
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) {
logger.debug(`twitter gql-id-resolver: using cached query IDs`);
return { ...fallbackIds, ...parsed };
}
}
} catch {
logger.debug('twitter gql-id-resolver: cache read failed, will fetch fresh IDs');
}

// Deduplicate concurrent requests
if (!resolvePromise) {
resolvePromise = (async () => {
try {
logger.info('twitter gql-id-resolver: fetching fresh query IDs from Twitter JS bundles');
const ids = await fetchAndExtractIds();

if (Object.keys(ids).length > 0) {
try {
await cache.set(CACHE_KEY, JSON.stringify(ids), CACHE_TTL);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not hard-coded cache duration. Users are expected to change it to their likings through CACHE_CONTENT_EXPIRE

} catch {
logger.debug('twitter gql-id-resolver: cache write failed, IDs will be refetched next time');
}
Comment on lines +136 to +140
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not wrap cache.set with try-catch.

const found = operationNames.filter((name) => ids[name]);
const missing = operationNames.filter((name) => !ids[name]);
logger.info(`twitter gql-id-resolver: resolved ${found.length}/${operationNames.length} query IDs. Missing: ${missing.join(', ') || 'none'}`);
Comment on lines +132 to +143
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use debug log level

} else {
logger.warn('twitter gql-id-resolver: failed to extract any query IDs, using fallback');
}

return ids;
} catch (error) {
logger.warn(`twitter gql-id-resolver: error fetching query IDs: ${error}. Using fallback.`);
return {};
} finally {
resolvePromise = null;
}
})();
}

const ids = await resolvePromise;
return { ...fallbackIds, ...ids };
}

export function buildGqlMap(queryIds: Record<string, string>): Record<string, string> {
const map: Record<string, string> = {};
for (const name of operationNames) {
const id = queryIds[name] || fallbackIds[name];
map[name] = `/graphql/${id}/${name}`;
}
return map;
}
123 changes: 68 additions & 55 deletions lib/routes/twitter/api/web-api/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cookie as HttpCookieAgentCookie, CookieAgent } from 'http-cookie-agent/undici';
import queryString from 'query-string';
import { Cookie, CookieJar } from 'tough-cookie';
import { Client, ProxyAgent } from 'undici';
import undici, { Client, ProxyAgent } from 'undici';

import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
Expand Down Expand Up @@ -136,8 +136,11 @@ export const twitterGot = async (
)
: {};

const response = await ofetch.raw(requestUrl, {
retry: 0,
// Use undici.fetch directly instead of ofetch.raw to preserve the CookieAgent
// dispatcher. Both ofetch and the global wrappedFetch drop the dispatcher option
// when constructing a new Request object, which prevents cookies from being sent
// in production builds.
const response = await undici.fetch(requestUrl, {
headers: {
authority: 'x.com',
accept: '*/*',
Expand All @@ -160,67 +163,77 @@ export const twitterGot = async (
}),
},
dispatcher: dispatchers?.agent,
onResponse: async ({ response }) => {
const remaining = response.headers.get('x-rate-limit-remaining');
const remainingInt = Number.parseInt(remaining || '0');
const reset = response.headers.get('x-rate-limit-reset');
logger.debug(
`twitter debug: twitter rate limit remaining for token ${auth?.token} is ${remaining} and reset at ${reset}, auth: ${JSON.stringify(auth)}, status: ${response.status}, data: ${JSON.stringify(response._data?.data)}, cookie: ${JSON.stringify(dispatchers?.jar.serializeSync())}`
);
if (auth) {
if (remaining && remainingInt < 2 && reset) {
const resetTime = new Date(Number.parseInt(reset) * 1000);
const delay = (resetTime.getTime() - Date.now()) / 1000;
logger.debug(`twitter debug: twitter rate limit exceeded for token ${auth.token} with status ${response.status}, will unlock after ${delay}s`);
await cache.set(`${lockPrefix}${auth.token}`, '1', Math.ceil(delay) * 2);
} else if (response.status === 429 || JSON.stringify(response._data?.data) === '{"user":{}}') {
logger.debug(`twitter debug: twitter rate limit exceeded for token ${auth.token} with status ${response.status}`);
await cache.set(`${lockPrefix}${auth.token}`, '1', 2000);
} else if (response.status === 403 || response.status === 401) {
const newCookie = await login({
username: auth.username,
password: auth.password,
authenticationSecret: auth.authenticationSecret,
});
if (newCookie) {
logger.debug(`twitter debug: reset twitter cookie for token ${auth.token}, ${newCookie}`);
await cache.set(`twitter:cookie:${auth.token}`, newCookie, config.cache.contentExpire);
logger.debug(`twitter debug: unlock twitter cookie for token ${auth.token} with error1`);
await cache.set(`${lockPrefix}${auth.token}`, '', 1);
} else {
const tokenIndex = config.twitter.authToken?.indexOf(auth.token);
if (tokenIndex !== undefined && tokenIndex !== -1) {
config.twitter.authToken?.splice(tokenIndex, 1);
}
if (auth.username) {
const usernameIndex = config.twitter.username?.indexOf(auth.username);
if (usernameIndex !== undefined && usernameIndex !== -1) {
config.twitter.username?.splice(usernameIndex, 1);
}
}
if (auth.password) {
const passwordIndex = config.twitter.password?.indexOf(auth.password);
if (passwordIndex !== undefined && passwordIndex !== -1) {
config.twitter.password?.splice(passwordIndex, 1);
}
}
logger.debug(`twitter debug: delete twitter cookie for token ${auth.token} with status ${response.status}, remaining tokens: ${config.twitter.authToken?.length}`);
await cache.set(`${lockPrefix}${auth.token}`, '1', 3600);
});

let responseData: any;
try {
responseData = await response.json();
} catch {
responseData = null;
}

// Handle rate limiting and auth errors
const remaining = response.headers.get('x-rate-limit-remaining');
const remainingInt = Number.parseInt(remaining || '0');
const reset = response.headers.get('x-rate-limit-reset');
logger.debug(
`twitter debug: twitter rate limit remaining for token ${auth?.token} is ${remaining} and reset at ${reset}, auth: ${JSON.stringify(auth)}, status: ${response.status}, data: ${JSON.stringify(responseData?.data)}, cookie: ${JSON.stringify(dispatchers?.jar.serializeSync())}`
);
if (auth) {
if (remaining && remainingInt < 2 && reset) {
const resetTime = new Date(Number.parseInt(reset) * 1000);
const delay = (resetTime.getTime() - Date.now()) / 1000;
logger.debug(`twitter debug: twitter rate limit exceeded for token ${auth.token} with status ${response.status}, will unlock after ${delay}s`);
await cache.set(`${lockPrefix}${auth.token}`, '1', Math.ceil(delay) * 2);
} else if (response.status === 429 || JSON.stringify(responseData?.data) === '{"user":{}}') {
logger.debug(`twitter debug: twitter rate limit exceeded for token ${auth.token} with status ${response.status}`);
await cache.set(`${lockPrefix}${auth.token}`, '1', 2000);
} else if (response.status === 403 || response.status === 401) {
const newCookie = await login({
username: auth.username,
password: auth.password,
authenticationSecret: auth.authenticationSecret,
});
if (newCookie) {
logger.debug(`twitter debug: reset twitter cookie for token ${auth.token}, ${newCookie}`);
await cache.set(`twitter:cookie:${auth.token}`, newCookie, config.cache.contentExpire);
await cache.set(`${lockPrefix}${auth.token}`, '', 1);
} else {
const tokenIndex = config.twitter.authToken?.indexOf(auth.token);
if (tokenIndex !== undefined && tokenIndex !== -1) {
config.twitter.authToken?.splice(tokenIndex, 1);
}
if (auth.username) {
const usernameIndex = config.twitter.username?.indexOf(auth.username);
if (usernameIndex !== undefined && usernameIndex !== -1) {
config.twitter.username?.splice(usernameIndex, 1);
}
}
if (auth.password) {
const passwordIndex = config.twitter.password?.indexOf(auth.password);
if (passwordIndex !== undefined && passwordIndex !== -1) {
config.twitter.password?.splice(passwordIndex, 1);
}
} else {
logger.debug(`twitter debug: unlock twitter cookie with success for token ${auth.token}`);
await cache.set(`${lockPrefix}${auth.token}`, '', 1);
}
logger.debug(`twitter debug: delete twitter cookie for token ${auth.token} with status ${response.status}, remaining tokens: ${config.twitter.authToken?.length}`);
await cache.set(`${lockPrefix}${auth.token}`, '1', 3600);
}
},
});
} else {
logger.debug(`twitter debug: unlock twitter cookie with success for token ${auth.token}`);
await cache.set(`${lockPrefix}${auth.token}`, '', 1);
}
}

if (response.status >= 400) {
throw new Error(`Twitter API error: ${response.status}`);
}

if (auth?.token) {
logger.debug(`twitter debug: update twitter cookie for token ${auth.token}`);
await cache.set(`twitter:cookie:${auth.token}`, JSON.stringify(dispatchers?.jar.serializeSync()), config.cache.contentExpire);
}

return response._data;
return responseData;
};

export const paginationTweets = async (endpoint: string, userId: number | undefined, variables: Record<string, any>, path?: string[]) => {
Expand Down
Loading