|
| 1 | +import { config } from '@/config'; |
| 2 | +import cache from '@/utils/cache'; |
| 3 | +import logger from '@/utils/logger'; |
| 4 | +import ofetch from '@/utils/ofetch'; |
| 5 | + |
| 6 | +const CACHE_KEY = 'twitter:gql-query-ids'; |
| 7 | + |
| 8 | +// Hardcoded fallback IDs (last known working values) |
| 9 | +export const fallbackIds: Record<string, string> = { |
| 10 | + UserTweets: 'E3opETHurmVJflFsUBVuUQ', |
| 11 | + UserByScreenName: 'Yka-W8dz7RaEuQNkroPkYw', |
| 12 | + HomeTimeline: 'xhYBF94fPSp8ey64FfYXiA', |
| 13 | + HomeLatestTimeline: '0vp2Au9doTKsbn2vIk48Dg', |
| 14 | + UserTweetsAndReplies: 'bt4TKuFz4T7Ckk-VvQVSow', |
| 15 | + UserMedia: 'dexO_2tohK86JDudXXG3Yw', |
| 16 | + UserByRestId: 'Qw77dDjp9xCpUY-AXwt-yQ', |
| 17 | + SearchTimeline: 'UN1i3zUiCWa-6r-Uaho4fw', |
| 18 | + ListLatestTweetsTimeline: 'Pa45JvqZuKcW1plybfgBlQ', |
| 19 | + TweetDetail: 'QuBlQ6SxNAQCt6-kBiCXCQ', |
| 20 | +}; |
| 21 | + |
| 22 | +const operationNames = Object.keys(fallbackIds); |
| 23 | + |
| 24 | +async function fetchTwitterPage(): Promise<string> { |
| 25 | + const response = await ofetch('https://x.com', { |
| 26 | + parseResponse: (txt) => txt, |
| 27 | + }); |
| 28 | + return response as unknown as string; |
| 29 | +} |
| 30 | + |
| 31 | +function extractQueryIds(scriptContent: string): Record<string, string> { |
| 32 | + const ids: Record<string, string> = {}; |
| 33 | + const matches = scriptContent.matchAll(/queryId:"([^"]+?)".+?operationName:"([^"]+?)"/g); |
| 34 | + for (const match of matches) { |
| 35 | + const [, queryId, operationName] = match; |
| 36 | + if (operationNames.includes(operationName)) { |
| 37 | + ids[operationName] = queryId; |
| 38 | + } |
| 39 | + } |
| 40 | + return ids; |
| 41 | +} |
| 42 | + |
| 43 | +async function fetchAndExtractIds(): Promise<Record<string, string>> { |
| 44 | + const html = await fetchTwitterPage(); |
| 45 | + |
| 46 | + // Extract main.hash.js URL — it contains all the GraphQL query IDs we need |
| 47 | + const mainMatch = html.match(/\/client-web\/main\.([a-z0-9]+)\./); |
| 48 | + if (!mainMatch) { |
| 49 | + logger.warn('twitter gql-id-resolver: main.js URL not found in Twitter page'); |
| 50 | + return {}; |
| 51 | + } |
| 52 | + |
| 53 | + const mainUrl = `https://abs.twimg.com/responsive-web/client-web/main.${mainMatch[1]}.js`; |
| 54 | + logger.debug(`twitter gql-id-resolver: fetching ${mainUrl}`); |
| 55 | + |
| 56 | + const content = await ofetch(mainUrl, { |
| 57 | + parseResponse: (txt) => txt, |
| 58 | + }); |
| 59 | + return extractQueryIds(content as unknown as string); |
| 60 | +} |
| 61 | + |
| 62 | +let resolvePromise: Promise<Record<string, string>> | null = null; |
| 63 | + |
| 64 | +export async function resolveQueryIds(): Promise<Record<string, string>> { |
| 65 | + // Check cache first |
| 66 | + const cached = await cache.get(CACHE_KEY); |
| 67 | + if (cached) { |
| 68 | + try { |
| 69 | + const parsed = typeof cached === 'string' ? JSON.parse(cached) : cached; |
| 70 | + if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) { |
| 71 | + logger.debug(`twitter gql-id-resolver: using cached query IDs`); |
| 72 | + return { ...fallbackIds, ...parsed }; |
| 73 | + } |
| 74 | + } catch { |
| 75 | + // ignore parse error |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + // Deduplicate concurrent requests |
| 80 | + if (!resolvePromise) { |
| 81 | + resolvePromise = (async () => { |
| 82 | + try { |
| 83 | + logger.info('twitter gql-id-resolver: fetching fresh query IDs from Twitter JS bundles'); |
| 84 | + const ids = await fetchAndExtractIds(); |
| 85 | + |
| 86 | + if (Object.keys(ids).length > 0) { |
| 87 | + await cache.set(CACHE_KEY, JSON.stringify(ids), config.cache.contentExpire); |
| 88 | + const found = operationNames.filter((name) => ids[name]); |
| 89 | + const missing = operationNames.filter((name) => !ids[name]); |
| 90 | + logger.debug(`twitter gql-id-resolver: resolved ${found.length}/${operationNames.length} query IDs. Missing: ${missing.join(', ') || 'none'}`); |
| 91 | + } else { |
| 92 | + logger.warn('twitter gql-id-resolver: failed to extract any query IDs, using fallback'); |
| 93 | + } |
| 94 | + |
| 95 | + return ids; |
| 96 | + } catch (error) { |
| 97 | + logger.warn(`twitter gql-id-resolver: error fetching query IDs: ${error}. Using fallback.`); |
| 98 | + return {}; |
| 99 | + } finally { |
| 100 | + resolvePromise = null; |
| 101 | + } |
| 102 | + })(); |
| 103 | + } |
| 104 | + |
| 105 | + const ids = await resolvePromise; |
| 106 | + return { ...fallbackIds, ...ids }; |
| 107 | +} |
| 108 | + |
| 109 | +export function buildGqlMap(queryIds: Record<string, string>): Record<string, string> { |
| 110 | + const map: Record<string, string> = {}; |
| 111 | + for (const name of operationNames) { |
| 112 | + const id = queryIds[name] || fallbackIds[name]; |
| 113 | + map[name] = `/graphql/${id}/${name}`; |
| 114 | + } |
| 115 | + return map; |
| 116 | +} |
0 commit comments