diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index ff7175c9..21a6c53e 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -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; } diff --git a/api/src/processing/match.js b/api/src/processing/match.js index e97f7600..27e4a9f0 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -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" diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index fbe25805..c91e9e7a 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -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", diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js index 07bef989..17a7e421 100644 --- a/api/src/processing/service-patterns.js +++ b/api/src/processing/service-patterns.js @@ -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, diff --git a/api/src/processing/services/threads.js b/api/src/processing/services/threads.js new file mode 100644 index 00000000..d8b7dbb4 --- /dev/null +++ b/api/src/processing/services/threads.js @@ -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 = /