diff --git a/src/index.ts b/src/index.ts index 70f73a9..b2dcbf0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,25 @@ import { isCrawler } from "./crawler"; import { FACEBOOK_HOSTS, expandShareLink, isShareable } from "./expand"; import { facebookPost, facebookVideo, isFacebookPostUrl, isFacebookVideoUrl } from "./facebook"; import { fixServiceUrl } from "./fix-services"; -import { instagramPost, isShortcode, type Post, type PostCache } from "./instagram"; +import { instagramPost, isShortcode, shortcodeOfMediaId, type Post, type PostCache } from "./instagram"; import { renderOgPage } from "./og"; const CONVERT_HINT = "pass a percent-encoded http(s) URL as ?url="; const POST_PATH = /^\/(p|reel)\/([^/]+)(?:\/([1-9]\d?))?\/?$/; const ITEM_SUFFIX = /^\/(p|reel)\/([^/]+)\/([1-9]\d?)\/?$/; +const STORY_PATH = /^\/stories\/([^/]+)\/([^/]+)\/?$/; + +function instagramShortcode(pathname: string): { shortcode: string; index: number } | null { + const post = POST_PATH.exec(pathname); + if (post?.[2] !== undefined && isShortcode(post[2])) { + return { shortcode: post[2], index: post[3] === undefined ? 1 : Number(post[3]) }; + } + const story = STORY_PATH.exec(pathname); + // A highlight id names a collection, not a media, so it has no embed page. + if (story?.[1] === undefined || story[1] === "highlights" || story[2] === undefined) return null; + const shortcode = shortcodeOfMediaId(story[2]); + return shortcode === null ? null : { shortcode, index: 1 }; +} function landingUrl(cleaned: URL): URL { if (!INSTAGRAM_HOSTS.has(cleaned.hostname)) return cleaned; @@ -131,15 +144,9 @@ async function shareRedirect( } const cleaned = cleanUrl(candidate); const landing = landingUrl(cleaned); - const postMatch = POST_PATH.exec(cleaned.pathname); - if ( - isCrawler(userAgent) && - INSTAGRAM_HOSTS.has(cleaned.hostname) && - postMatch?.[2] !== undefined && - isShortcode(postMatch[2]) - ) { - const index = postMatch[3] === undefined ? 1 : Number(postMatch[3]); - return instagramOg(requestUrl, landing, postMatch[2], index, deps); + const instagram = INSTAGRAM_HOSTS.has(cleaned.hostname) ? instagramShortcode(cleaned.pathname) : null; + if (isCrawler(userAgent) && instagram !== null) { + return instagramOg(requestUrl, landing, instagram.shortcode, instagram.index, deps); } if (isCrawler(userAgent) && (isFacebookPostUrl(cleaned) || isFacebookVideoUrl(cleaned))) { return facebookOg(requestUrl, cleaned, deps); diff --git a/src/instagram.ts b/src/instagram.ts index 46ebe60..327f5af 100644 --- a/src/instagram.ts +++ b/src/instagram.ts @@ -112,6 +112,22 @@ export function isShortcode(value: string): boolean { return SHORTCODE.test(value); } +const SHORTCODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; +const MEDIA_ID = /^[1-9]\d{0,19}$/; + +// A story has no shortcode of its own, but its media id written in Instagram's +// base64 alphabet is one the /p//embed/ page answers to. +export function shortcodeOfMediaId(id: string): string | null { + if (!MEDIA_ID.test(id)) return null; + let rest = BigInt(id); + let shortcode = ""; + while (rest > 0n) { + shortcode = SHORTCODE_ALPHABET[Number(rest % 64n)] + shortcode; + rest /= 64n; + } + return shortcode; +} + function isMedia(value: unknown): value is Media { if (typeof value !== "object" || value === null) return false; const record = value as Record; diff --git a/test/fixtures/instagram/embed-Deg-53RDHbL.html b/test/fixtures/instagram/embed-Deg-53RDHbL.html new file mode 100644 index 0000000..7990a27 --- /dev/null +++ b/test/fixtures/instagram/embed-Deg-53RDHbL.html @@ -0,0 +1,13 @@ + + +Instagram + + + + + + + + \ No newline at end of file diff --git a/test/instagram.test.ts b/test/instagram.test.ts index 5a36b9b..b9f7730 100644 --- a/test/instagram.test.ts +++ b/test/instagram.test.ts @@ -2,7 +2,7 @@ import { expect, spyOn, test } from "bun:test"; import { readdir } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import type { Fetcher } from "../src/expand"; -import { instagramPost, parseEmbed } from "../src/instagram"; +import { instagramPost, parseEmbed, shortcodeOfMediaId } from "../src/instagram"; import { fakeCache } from "./support/fake-cache"; const EGG_MEDIA = @@ -707,3 +707,20 @@ test("saved embed fixtures do not contain session tokens", async () => { expect(text.match(CSRF_COOKIE)).toBeNull(); } }); + +test("a story media id converts to the shortcode its embed page answers to", () => { + expect(shortcodeOfMediaId("4008480323595433675")).toBe("Deg-53RDHbL"); +}); + +test("a media id outside the digit grammar has no shortcode", () => { + for (const id of ["", "0", "012", "12a", "-1", "1".repeat(21)]) { + expect(shortcodeOfMediaId(id)).toBeNull(); + } +}); + +test("a story's embed page yields the account and its video", async () => { + const post = parseEmbed(await fixture("embed-Deg-53RDHbL.html")); + expect(post?.username).toBe("mkcrtw"); + expect(post?.media).toHaveLength(1); + expect(post?.media[0]?.kind).toBe("video"); +}); diff --git a/test/worker.test.ts b/test/worker.test.ts index da847de..c9e3b10 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1933,3 +1933,65 @@ test("facebook video caller credentials are not written to the console", async ( errors.mockRestore(); } }); + +test("a crawler preview of a story fetches the embed of its media id and keeps the story url", async () => { + const html = await instagramFixture("embed-Deg-53RDHbL.html"); + const app = createWorker({ cache: () => fakeCache().cache }); + const spy = stubFetch(() => Promise.resolve(new Response(html, { status: 200 }))); + try { + const res = await callWorker( + app, + "https://bl.example/www.instagram.com/stories/mkcrtw/4008480323595433675/?igsh=x", + { "User-Agent": DISCORD }, + ); + expect(res.status).toBe(200); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]?.[0]).toBe("https://www.instagram.com/p/Deg-53RDHbL/embed/captioned/"); + const body = await res.text(); + expect(body).toContain(''); + expect(body).toContain( + '', + ); + } finally { + spy.mockRestore(); + } +}); + +test("a crawler on an unavailable story gets the story url", async () => { + const app = createWorker({ cache: () => fakeCache().cache }); + const spy = stubFetch(() => Promise.resolve(new Response("", { status: 404 }))); + try { + const res = await callWorker(app, "https://bl.example/www.instagram.com/stories/mkcrtw/4008480323595433675", { + "User-Agent": DISCORD, + }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("https://www.instagram.com/stories/mkcrtw/4008480323595433675"); + } finally { + spy.mockRestore(); + } +}); + +test("a crawler on a story highlight does not fetch or touch the cache", async () => { + const fake = fakeCache(); + const app = createWorker({ cache: () => fake.cache }); + const spy = stubFetch(() => Promise.reject(new TypeError("fetch failed"))); + try { + const res = await callWorker(app, "https://bl.example/www.instagram.com/stories/highlights/17861234567890123/", { + "User-Agent": DISCORD, + }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("https://www.instagram.com/stories/highlights/17861234567890123/"); + expect(spy).toHaveBeenCalledTimes(0); + expect(fake.calls.match).toBe(0); + } finally { + spy.mockRestore(); + } +}); + +test("a browser on a story lands on the story", async () => { + const res = await call("https://bl.example/www.instagram.com/stories/mkcrtw/4008480323595433675/", { + "User-Agent": CHROME, + }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("https://www.instagram.com/stories/mkcrtw/4008480323595433675/"); +});