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
4 changes: 3 additions & 1 deletion docs/spec/fail-open-to-original.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ adr: null
---
分享連結端點 `GET /<原網域>/<路徑>` 在抓取或解析失敗時,一律 302 回清理後的原網址,不回錯誤頁,也不回缺少媒體的 og 頁。

抓取和解析包在同一個失敗邊界裡。邊界內的例外、上游非 200 回應、找不到必要欄位,都走同一個 302。已知的失敗訊號包括:Instagram 被導到 `/accounts/login` 或 `unsupportedbrowser`、embed 頁只顯示 `WatchOnInstagram`、Facebook `post.php` 找不到 `t39.30808-6` 貼文圖或作者、Facebook `video.php` 找不到 `hd_src`/`sd_src`、`original_width`/`original_height` 或作者。Facebook 的失敗頁一律回 200,有些完全沒有提示文字,所以只能靠缺欄位判定,不能靠比對字樣。
抓取和解析包在同一個失敗邊界裡。邊界內的例外、上游非 200 回應、找不到必要欄位,都走同一個 302。已知的失敗訊號包括:Instagram 被導到 `/accounts/login` 或 `unsupportedbrowser`、圖片貼文的 embed 頁只顯示 `WatchOnInstagram`、多圖貼文被標為版權封鎖(`copyright_blocked` 或 `WatchOnInstagram`)、被標為版權封鎖又沒有封面圖的影片、Facebook `post.php` 找不到 `t39.30808-6` 貼文圖或作者、Facebook `video.php` 找不到 `hd_src`/`sd_src`、`original_width`/`original_height` 或作者。Facebook 的失敗頁一律回 200,有些完全沒有提示文字,所以只能靠缺欄位判定,不能靠比對字樣。

被標為版權封鎖的 Instagram 影片,embed 頁沒有 `video_url`,但仍有封面圖,這時改用封面圖產生 og 頁,不算缺少媒體。

這條只管分享連結端點。`GET /?url=` 轉換端點和 `/media/` 端點的錯誤處理不在範圍內。

Expand Down
20 changes: 13 additions & 7 deletions src/instagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,21 +277,27 @@ function richItems(type: string, context: unknown): Media[] | null {
return items;
}

function imagePost(username: string, html: string): Post | null {
const mediaUrl = mediaUrlOf(html);
if (mediaUrl === null) return null;
return { username, caption: captionOf(html), media: [{ kind: "image", url: mediaUrl }] };
}

export function parseEmbed(html: string): Post | null {
try {
if (html.includes("WatchOnInstagram")) return null;
const watchOnly = html.includes("WatchOnInstagram");
const type = mediaType(html);
const username = usernameOf(html);
if (!username) return null;
if (type === "GraphImage") {
const mediaUrl = mediaUrlOf(html);
if (mediaUrl === null) return null;
return { username, caption: captionOf(html), media: [{ kind: "image", url: mediaUrl }] };
}
if (type === "GraphImage") return watchOnly ? null : imagePost(username, html);
if (type !== "GraphVideo" && type !== "GraphSidecar") return null;
const context = readContext(html);
if (context === null) return null;
if (asRecord(asRecord(context)?.context)?.copyright_blocked === true) return null;
// A copyright-blocked video has no video_url in the embed, but the page still
// shows its cover frame, which beats no preview at all.
if (watchOnly || asRecord(asRecord(context)?.context)?.copyright_blocked === true) {
return type === "GraphVideo" ? imagePost(username, html) : null;
}
const media = richItems(type, context);
if (media === null) return null;
return { username, caption: captionOf(html), media };
Expand Down
25 changes: 20 additions & 5 deletions test/fail-open-to-original.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,25 @@ test("a crawler is redirected when the cache binding throws", async () => {
}
});

test("a crawler is redirected when the embed video is blocked", async () => {
test("a crawler receives an og page with the cover frame for a blocked reel", async () => {
const html = await fixture("embed-Dd0M_ifNfXO.html");
const fake = await expectOpen(() => Promise.resolve(new Response(html, { status: 200 })), 1);
expect(fake.entries.size).toBe(0);
const app = createWorker({ cache: () => fakeCache().cache });
const spy = spyOn(globalThis, "fetch").mockImplementation(
(() => Promise.resolve(new Response(html, { status: 200 }))) as unknown as typeof fetch,
);
try {
const res = await app.fetch(
new Request("https://bl.example/www.instagram.com/reel/Dd0M_ifNfXO/", { headers: { "User-Agent": DISCORD } }),
{} as Env,
ctx,
);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain('<meta property="og:image" content="https://bl.example/media/Dd0M_ifNfXO/1">');
expect(body).not.toContain("og:video");
} finally {
spy.mockRestore();
}
});

test("a crawler on an instagram item link is sent to the post when fetch fails", async () => {
Expand Down Expand Up @@ -149,8 +164,8 @@ test("a crawler asking for a missing carousel item is sent to the post", async (
);
});

test("a crawler on a blocked reel is sent to the reel", async () => {
const html = await fixture("embed-Dd0M_ifNfXO.html");
test("a crawler on a blocked reel without a cover frame is sent to the reel", async () => {
const html = (await fixture("embed-Dd0M_ifNfXO.html")).replace('class="EmbeddedMediaImage"', 'class="Other"');
const fake = await expectOpen(
() => Promise.resolve(new Response(html, { status: 200 })),
1,
Expand Down
37 changes: 31 additions & 6 deletions test/instagram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,24 +155,49 @@ function richPage(type: string, node: unknown): string {
return `<div data-media-type="${type}"><span class="UsernameText">u</span><script>{"contextJSON":${literal}}</script></div>`;
}

test("a copyright-blocked video embed yields no post", async () => {
expect(parseEmbed(await fixture("embed-Dd0M_ifNfXO.html"))).toBeNull();
const COVER_FRAME = [
{
kind: "image" as const,
url: expect.stringMatching(
/^https:\/\/scontent\.cdninstagram\.com\/v\/t51\.82787-15\/825269646_18629137711044933_165147988821785013_n\.jpg\?/,
),
},
];

test("a copyright-blocked video embed yields its cover frame as an image", async () => {
const post = parseEmbed(await fixture("embed-Dd0M_ifNfXO.html"));
expect(post?.username).toBe("atlanticrecords");
expect(post?.media).toEqual(COVER_FRAME);
expect(post?.media[0]?.url).not.toContain("&amp;");
});

test("a copyright-blocked video embed without a cover frame yields no post", async () => {
const html = (await fixture("embed-Dd0M_ifNfXO.html")).replace('class="EmbeddedMediaImage"', 'class="Other"');
expect(parseEmbed(html)).toBeNull();
});

test("a watch-on-instagram marker on a playable video yields no post", async () => {
test("a copyright-blocked carousel yields no post", async () => {
const html = (await fixture("embed-Dd0M_ifNfXO.html")).replace(
'data-media-type="GraphVideo"',
'data-media-type="GraphSidecar"',
);
expect(parseEmbed(html)).toBeNull();
});

test("a watch-on-instagram marker on a playable video yields its cover frame", async () => {
const html = (await fixture("embed-DJvkjAlvNc8.html")).replace(
'<span class="UsernameText">vatsalya_therapy</span>',
'<span class="UsernameText">vatsalya_therapy</span><span class="WatchOnInstagram">Watch on Instagram</span>',
);
expect(parseEmbed(html)).toBeNull();
expect(parseEmbed(html)?.media).toEqual([{ kind: "image", url: expect.any(String) }]);
});

test("a video whose context is copyright blocked yields no post", async () => {
test("a video whose context is copyright blocked yields its cover frame", async () => {
const html = (await fixture("embed-DJvkjAlvNc8.html")).replaceAll(
'\\"copyright_blocked\\":false',
'\\"copyright_blocked\\":true',
);
expect(parseEmbed(html)).toBeNull();
expect(parseEmbed(html)?.media).toEqual([{ kind: "image", url: expect.any(String) }]);
});

test("a video without a video url yields no post", async () => {
Expand Down
Loading