Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions src/instagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<shortcode>/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<string, unknown>;
Expand Down
13 changes: 13 additions & 0 deletions test/fixtures/instagram/embed-Deg-53RDHbL.html

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion test/instagram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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");
});
62 changes: 62 additions & 0 deletions test/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<meta property="og:video" content="https://bl.example/media/Deg-53RDHbL/1">');
expect(body).toContain(
'<meta property="og:url" content="https://www.instagram.com/stories/mkcrtw/4008480323595433675/">',
);
} 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/");
});
Loading