diff --git a/docs/spec/fail-open-to-original.md b/docs/spec/fail-open-to-original.md index 86e309a..bf462ee 100644 --- a/docs/spec/fail-open-to-original.md +++ b/docs/spec/fail-open-to-original.md @@ -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/` 端點的錯誤處理不在範圍內。 diff --git a/src/instagram.ts b/src/instagram.ts index 48234fe..1028805 100644 --- a/src/instagram.ts +++ b/src/instagram.ts @@ -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 }; diff --git a/test/fail-open-to-original.test.ts b/test/fail-open-to-original.test.ts index 040d5c0..94fe9ca 100644 --- a/test/fail-open-to-original.test.ts +++ b/test/fail-open-to-original.test.ts @@ -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(''); + 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 () => { @@ -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, diff --git a/test/instagram.test.ts b/test/instagram.test.ts index b9f7730..3d1cc9a 100644 --- a/test/instagram.test.ts +++ b/test/instagram.test.ts @@ -155,24 +155,49 @@ function richPage(type: string, node: unknown): string { return `
u
`; } -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("&"); +}); + +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( 'vatsalya_therapy', 'vatsalya_therapyWatch on Instagram', ); - 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 () => {