api: add support for threads

extracts media from public threads.com & threads.net posts: videos,
photos, GIFs, and multi-media carousels.

post data is embedded in the page html as data-sjs json, but threads
only returns it to requests with a full browser-like header set, so the
extractor sends those. GIFs are giphy-backed and served as real .gif
files, so they're downloaded directly.
This commit is contained in:
Steven Irby 2026-05-19 01:23:40 +01:00
parent a636575b09
commit d1c20bee37
7 changed files with 360 additions and 0 deletions

View file

@ -103,6 +103,7 @@ export default function({
case "twitter":
case "snapchat":
case "bsky":
case "threads":
params = { picker: r.picker };
break;
@ -190,6 +191,7 @@ export default function({
case "streamable":
case "snapchat":
case "twitch":
case "threads":
responseType = "redirect";
break;
}

View file

@ -29,6 +29,7 @@ import loom from "./services/loom.js";
import facebook from "./services/facebook.js";
import bluesky from "./services/bluesky.js";
import newgrounds from "./services/newgrounds.js";
import threads from "./services/threads.js";
let freebind;
@ -266,6 +267,14 @@ export default async function({ host, patternMatch, params, authType }) {
});
break;
case "threads":
r = await threads({
...patternMatch,
alwaysProxy: params.alwaysProxy,
dispatcher,
});
break;
default:
return createResponse("error", {
code: "error.api.service.unsupported"

View file

@ -143,6 +143,13 @@ export const services = {
"s/:id"
],
},
threads: {
patterns: [
"@:user/post/:postId",
"@:user/post/:postId/media"
],
altDomains: ["threads.net"],
},
tiktok: {
patterns: [
":user/video/:postId",

View file

@ -60,6 +60,9 @@ export const testers = {
"streamable": pattern =>
pattern.id?.length <= 6,
"threads": pattern =>
pattern.postId?.length <= 24 && pattern.user?.length <= 30,
"tiktok": pattern =>
pattern.postId?.length <= 21 ||
pattern.shortLink?.length <= 21,

View file

@ -0,0 +1,205 @@
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
// threads.com serves a minimal logged-out gating shell to requests that
// lack a believable browser fingerprint. sending the full set of headers
// a real chrome navigation sends makes it return the post page with the
// media data embedded as json (same behaviour as instagram embeds).
const browserHeaders = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "en-GB,en;q=0.9",
"Cache-Control": "max-age=0",
"Dnt": "1",
"Priority": "u=0, i",
"Sec-Ch-Ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": "macOS",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": genericUserAgent,
}
const sjsRegex = /<script type="application\/json"[^>]*\bdata-sjs\b[^>]*>(.*?)<\/script>/gs;
// recursively collect every thread_items[*].post object found in a parsed
// data-sjs payload. threads pages embed the requested post alongside its
// parent posts and replies, so callers must match on post.code themselves.
function collectPosts(node, out) {
if (!node || typeof node !== "object") return;
if (Array.isArray(node.thread_items)) {
for (const item of node.thread_items) {
if (item?.post?.code) out.push(item.post);
}
}
for (const value of Object.values(node)) {
collectPosts(value, out);
}
}
// parse the embedded data-sjs json blocks and return the post object
// whose shortcode matches postId exactly (or null if not present).
function findPostData(html, postId) {
if (typeof html !== "string") return null;
const posts = [];
for (const [, block] of html.matchAll(sjsRegex)) {
let parsed;
try {
parsed = JSON.parse(block);
} catch {
continue;
}
collectPosts(parsed, posts);
}
return posts.find(post => post.code === postId) || null;
}
// threads video_versions entries carry no dimensions and (for a given
// post) all point at the same file, so the first valid url is the pick.
const pickVideo = (versions) => versions?.find(v => v?.url)?.url;
// image_versions2.candidates are ordered largest-first, same as instagram
const pickImage = (candidates) => candidates?.find(c => c?.url)?.url;
function mediaFromItem(item) {
const video = pickVideo(item?.video_versions);
if (video) return { type: "video", url: video };
const photo = pickImage(item?.image_versions2?.candidates);
if (photo) return { type: "photo", url: photo };
}
// turn a threads post object into a cobalt extractor result
function extractPost(post, id, { alwaysProxy } = {}) {
if (!post) return { error: "fetch.empty" };
// GIF: giphy serves these as an actual .gif, no conversion needed
const gif = post.giphy_media_info?.images?.fixed_height?.url;
if (gif) {
return {
urls: gif,
isPhoto: true,
filename: `threads_${id}.gif`,
}
}
// carousel (multi-media post)
if (Array.isArray(post.carousel_media) && post.carousel_media.length) {
const picker = post.carousel_media
.map((item, i) => {
const media = mediaFromItem(item);
if (!media) return;
const itemExt = media.type === "video" ? "mp4" : "jpg";
let url = media.url;
if (alwaysProxy) url = createStream({
service: "threads",
type: "proxy",
url,
filename: `threads_${id}_${i + 1}.${itemExt}`,
});
const thumb = pickImage(item.image_versions2?.candidates);
return {
type: media.type,
url,
/* thumbnails are served with a restrictive
** Cross-Origin-Resource-Policy, so we proxy them */
thumb: thumb && createStream({
service: "threads",
type: "proxy",
url: thumb,
filename: `threads_${id}_${i + 1}.jpg`,
}),
}
})
.filter(Boolean);
if (picker.length) return { picker };
return { error: "fetch.empty" };
}
// single video
const video = pickVideo(post.video_versions);
if (video) {
return {
urls: video,
filename: `threads_${id}.mp4`,
audioFilename: `threads_${id}_audio`,
}
}
// single photo
const photo = pickImage(post.image_versions2?.candidates);
if (photo) {
return {
urls: photo,
isPhoto: true,
filename: `threads_${id}.jpg`,
}
}
// reshares wrap the original post's media inside text_post_app_info.
// threads uses (at least) two distinct fields for this:
// - share_info.quoted_attachment_post : the "use media" reshare
// - linked_inline_media : a linked-inline repost
// both carry a full post-shaped object, so we recurse into whichever is
// present and return the first that resolves to media.
const tpa = post.text_post_app_info;
const wrappers = [
tpa?.share_info?.quoted_attachment_post,
tpa?.linked_inline_media,
];
for (const wrapper of wrappers) {
if (!wrapper) continue;
const result = extractPost(wrapper, id, { alwaysProxy });
if (!result.error) return result;
}
return { error: "fetch.empty" };
}
async function getPost({ user, postId, dispatcher }) {
let html;
try {
const response = await fetch(
`https://www.threads.com/@${user}/post/${postId}/`,
{ headers: browserHeaders, dispatcher }
);
if (response.status === 404 || response.status === 410)
return { error: "content.post.unavailable" };
if (response.status === 403 || response.status === 429)
return { error: "fetch.rate" };
if (!response.ok)
return { error: "fetch.fail" };
html = await response.text();
} catch {
return { error: "fetch.fail" };
}
const post = findPostData(html, postId);
if (!post) return { error: "fetch.empty" };
return post;
}
export default async function threads({ user, postId, alwaysProxy, dispatcher }) {
if (!user || !postId) return { error: "fetch.empty" };
const post = await getPost({ user, postId, dispatcher });
if (post.error) return post;
return extractPost(post, postId, { alwaysProxy });
}

View file

@ -111,6 +111,14 @@ function aliasURL(url) {
url = new URL(`https://www.reddit.com/video/${parts[1]}`);
}
break;
case "threads":
/* the threads service is keyed on the .com tld; rewrite the
** legacy threads.net domain so those links resolve too. */
if (host.tld === 'net') {
url = new URL(`https://www.threads.com${url.pathname}${url.search}`);
}
break;
}
return url;

View file

@ -0,0 +1,126 @@
[
{
"name": "single video post",
"url": "https://www.threads.com/@mds/post/DObJvDoDkQI",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "single video post (with /media suffix)",
"url": "https://www.threads.com/@threads/post/C04KN6fODek/media",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "single photo post",
"url": "https://www.threads.com/@mosseri/post/DDupwppSjcp",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "GIF post",
"url": "https://www.threads.com/@sabrina_lugara/post/C1ChyVKLzna",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "video post, audio only",
"url": "https://www.threads.com/@mds/post/DObJvDoDkQI",
"canFail": true,
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "video post, muted",
"url": "https://www.threads.com/@mds/post/DObJvDoDkQI",
"canFail": true,
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "threads.net domain link",
"url": "https://www.threads.net/@mds/post/DObJvDoDkQI",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "video post with tracking query",
"url": "https://www.threads.com/@mds/post/DObJvDoDkQI?xmt=AQF0blabla",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "reshare ('use media') — video lives under quoted_attachment_post",
"url": "https://www.threads.com/@tahitinui_sovereignty/post/DYkLxYWjbnR",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "repost — video lives under linked_inline_media",
"url": "https://www.threads.com/@collinrealestateri/post/DYYEwRSD53Z",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "non-existent post",
"url": "https://www.threads.com/@mds/post/XXXXXXXXXXX",
"canFail": true,
"params": {},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "malformed oversized id (rejected before network)",
"url": "https://www.threads.com/@mds/post/AAAAAAAAAAAAAAAAAAAAAAAAAAA",
"params": {},
"expected": {
"code": 400,
"status": "error",
"errorCode": "error.api.link.unsupported"
}
}
]