From ead57e593d15fe46095e6bb6a6ad943f7bccbab0 Mon Sep 17 00:00:00 2001 From: Tony Date: Sun, 27 Sep 2026 23:26:16 +0800 Subject: [PATCH] feat(route/threads): add post (#23409) --- lib/routes/threads/index.ts | 3 +- lib/routes/threads/post.ts | 82 +++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 lib/routes/threads/post.ts diff --git a/lib/routes/threads/index.ts b/lib/routes/threads/index.ts index c1d739b253..7935294f57 100644 --- a/lib/routes/threads/index.ts +++ b/lib/routes/threads/index.ts @@ -35,7 +35,8 @@ Specify options (in the format of query string) in parameter \`routeParams\` to }; async function handler(ctx) { - const { user, routeParams } = ctx.req.param(); + const { user: rawUser, routeParams } = ctx.req.param(); + const user = rawUser.startsWith('@') ? rawUser.slice(1) : rawUser; const options = parseRouteOptions(new URLSearchParams(routeParams)); const response = await ofetch(profileUrl(user)); diff --git a/lib/routes/threads/post.ts b/lib/routes/threads/post.ts new file mode 100644 index 0000000000..09c6ec8d30 --- /dev/null +++ b/lib/routes/threads/post.ts @@ -0,0 +1,82 @@ +import type { CheerioAPI } from 'cheerio'; +import { load } from 'cheerio'; +import type { Context } from 'hono'; + +import type { Route } from '@/types'; +import { ViewType } from '@/types'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +import { buildContent, parseRouteOptions, profileUrl, threadUrl } from './utils'; + +export const route: Route = { + path: '/:user/post/:id/:routeParams?', + categories: ['social-media'], + view: ViewType.SocialMedia, + example: '/threads/@zuck/post/Ddt7cL5EfUG', + parameters: { + user: 'Username', + id: 'Post ID, the last segment of the post URL', + routeParams: 'Extra parameters, in the format of query string. Accepts the same options as User timeline', + }, + radar: [ + { + source: ['www.threads.com/:user/post/:id'], + target: '/:user/post/:id', + }, + ], + name: 'Post & Replies', + maintainers: ['TonyRL'], + handler, +}; + +const findNodes = (node, predicate: (node) => boolean, acc: any[] = []): any[] => { + if (node instanceof Object) { + if (predicate(node)) { + acc.push(node); + } + for (const value of Object.values(node)) { + findNodes(value, predicate, acc); + } + } + return acc; +}; + +const extractRelayNodes = ($: CheerioAPI, predicate: (node) => boolean): any[] => + $('script[data-sjs]:contains("RelayPrefetchedStreamCache")') + .toArray() + .flatMap((script) => findNodes(JSON.parse($(script).text()), predicate)); + +async function handler(ctx: Context) { + const { user: rawUser, id, routeParams } = ctx.req.param(); + const user = rawUser.startsWith('@') ? rawUser.slice(1) : rawUser; + const options = parseRouteOptions(new URLSearchParams(routeParams)); + const link = `${profileUrl(user)}/post/${id}`; + + const response = await ofetch(link); + const $ = load(response); + + const [post] = extractRelayNodes($, (node) => node.code === id && node.taken_at); + if (!post) { + throw new Error('Failed to fetch post data'); + } + const replies = extractRelayNodes($, (node) => node.direct_replies).flatMap((node) => node.direct_replies.edges.flatMap((thread) => thread.node.posts.edges.map((edge) => edge.node))); + + const items = [post, ...replies].map((post) => { + const { title, description } = buildContent({ post }, options); + return { + author: post.user?.username, + title, + description, + pubDate: parseDate(post.taken_at, 'X'), + link: threadUrl(post.code), + }; + }); + + return { + title: `@${post.user?.username}: ${post.caption?.text.split('\n', 1)[0] ?? ''}`, + link, + image: post.user?.profile_pic_url, + item: items, + }; +}