Skip to content

Commit 9b0769b

Browse files
authored
Merge branch 'DIYgod:master' into feat-jable
2 parents 694529f + 2933f89 commit 9b0769b

File tree

11 files changed

+556
-317
lines changed

11 files changed

+556
-317
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ jobs:
145145
pull-requests: write
146146
contents: write
147147
steps:
148-
- uses: fastify/github-action-merge-dependabot@1b2ed42db8f9d81a46bac83adedfc03eb5149dff # v3.11.2
148+
- uses: fastify/github-action-merge-dependabot@30c3f8f14a4f7b315ba38dbc1b793d27128fef82 # v3.12.0
149149
with:
150150
github-token: ${{ secrets.GITHUB_TOKEN }}
151151
target: patch

lib/routes/infoq/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ const ProcessFeed = async (list, cache) => {
2222
const author = data.author ? data.author.map((p) => p.nickname).join(',') : data.no_author;
2323
const category = [...e.topic.map((t) => t.name), ...e.label.map((l) => l.name)];
2424
const content = data.content_url ? (await got(data.content_url)).body : data.content;
25+
const description = addCoverToDescription(parseContent(content), data.article_cover);
2526

2627
return {
2728
title: data.article_title,
28-
description: parseContent(content),
29+
description,
2930
pubDate: parseDate(e.publish_time, 'x'),
3031
category,
3132
author,
@@ -97,6 +98,10 @@ const parseToSimpleTexts = (content) =>
9798
return parseToSimpleText(i.content);
9899
});
99100

101+
function addCoverToDescription(content, cover) {
102+
return `<p><img src="${cover}"></p>${content}`;
103+
}
104+
100105
function parseContent(content) {
101106
const isRichContent = content.startsWith(`{"`);
102107
if (!isRichContent) {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { Namespace } from '@/types';
2+
3+
export const namespace: Namespace = {
4+
name: 'Thinking Machines Lab',
5+
url: 'thinkingmachines.ai',
6+
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { load } from 'cheerio';
2+
3+
import type { Route } from '@/types';
4+
import cache from '@/utils/cache';
5+
import ofetch from '@/utils/ofetch';
6+
import { parseDate } from '@/utils/parse-date';
7+
8+
export const route: Route = {
9+
path: '/news',
10+
name: 'News',
11+
url: 'thinkingmachines.ai/news',
12+
maintainers: ['w3nhao'],
13+
example: '/thinkingmachines/news',
14+
categories: ['programming'],
15+
features: {
16+
requireConfig: false,
17+
requirePuppeteer: false,
18+
antiCrawler: false,
19+
},
20+
radar: [
21+
{
22+
source: ['thinkingmachines.ai/news', 'thinkingmachines.ai/news/'],
23+
target: '/news',
24+
},
25+
],
26+
handler,
27+
};
28+
29+
async function handler() {
30+
const baseUrl = 'https://thinkingmachines.ai';
31+
const listUrl = `${baseUrl}/news/`;
32+
33+
const response = await ofetch(listUrl);
34+
const $ = load(response);
35+
36+
const items = $('main li a')
37+
.toArray()
38+
.map((el) => {
39+
const $el = $(el);
40+
const title = $el.find('.post-title').text().trim();
41+
const dateStr = $el.find('time.desktop-time').text().trim();
42+
const href = $el.attr('href') || '';
43+
const link = href.startsWith('http') ? href : `${baseUrl}${href}`;
44+
45+
return { title, dateStr, link };
46+
})
47+
.filter((item) => item.title && item.link);
48+
49+
const fullItems = await Promise.all(
50+
items.map((item) =>
51+
cache.tryGet(item.link, async () => {
52+
const articleResponse = await ofetch(item.link);
53+
const $article = load(articleResponse);
54+
55+
// Remove non-content elements
56+
$article('nav, footer, header, script, style').remove();
57+
// Remove heading (title, author, pubDate) and paginator
58+
$article('.post-heading, #post-prev-link, #post-next-link').remove();
59+
60+
const description = $article('main').html()?.trim() || '';
61+
62+
return {
63+
title: item.title,
64+
link: item.link,
65+
pubDate: parseDate(item.dateStr, 'MMM D, YYYY'),
66+
description,
67+
};
68+
})
69+
)
70+
);
71+
72+
return {
73+
title: 'Thinking Machines Lab - News',
74+
link: listUrl,
75+
item: fullItems,
76+
};
77+
}

lib/routes/twitter/api/web-api/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import InvalidParameterError from '@/errors/types/invalid-parameter';
33
import cache from '@/utils/cache';
44
import ofetch from '@/utils/ofetch';
55

6-
import { baseUrl, gqlFeatures, gqlMap } from './constants';
6+
import { baseUrl, gqlFeatures, gqlMap, initGqlMap } from './constants';
77
import { gatherLegacyFromData, paginationTweets, twitterGot } from './utils';
88

99
const getUserData = (id) =>
@@ -216,5 +216,5 @@ export default {
216216
getList,
217217
getHomeTimeline,
218218
getHomeLatestTimeline,
219-
init: () => {},
219+
init: initGqlMap,
220220
};

lib/routes/twitter/api/web-api/constants.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
1+
import { buildGqlMap, fallbackIds, resolveQueryIds } from './gql-id-resolver';
2+
13
const baseUrl = 'https://x.com/i/api';
24

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

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

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

@@ -114,4 +109,4 @@ const timelineParams = {
114109

115110
const bearerToken = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
116111

117-
export { baseUrl, bearerToken, gqlFeatures, gqlMap, thirdPartySupportedAPI, timelineParams };
112+
export { baseUrl, bearerToken, gqlFeatures, gqlMap, initGqlMap, thirdPartySupportedAPI, timelineParams };
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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

Comments
 (0)