From 7240c9332433026a5e52a161f462704c0b20d08c Mon Sep 17 00:00:00 2001 From: musingfox Date: Mon, 19 Oct 2026 17:09:24 +0800 Subject: [PATCH 1/4] fix(clean): drop the tg_rhash that telegram instant view appends Co-Authored-By: Claude Opus 5.5 --- .../tracking-params-cleaned-at-conversion.md | 2 +- src/clean.ts | 1 + test/clean.test.ts | 6 ++++++ test/worker.test.ts | 18 ++++++++++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/spec/tracking-params-cleaned-at-conversion.md b/docs/spec/tracking-params-cleaned-at-conversion.md index dc79702..426413c 100644 --- a/docs/spec/tracking-params-cleaned-at-conversion.md +++ b/docs/spec/tracking-params-cleaned-at-conversion.md @@ -10,7 +10,7 @@ adr: null --- 追蹤碼在產生分享連結時清除:Worker 支援的平台用白名單,其他網址用黑名單,Facebook 和 Instagram 的 `/share/` 短連結在轉換時展開成正式網址。 -白名單只保留有功能的參數:Instagram 的 `img_index`,YouTube 的 `v`、`t`、`list`、`index`。黑名單至少包含 `utm_*`、`fbclid`、`gclid`、`igsh`、`igshid`、`mibextid`、`si`、`rdid`、`share_url`、`__cft__`、`__tn__`、`is_from_webapp`、`sender_device`、`refsrc`、`_rdr`;X 與 Twitter 網域另外去掉分享參數 `s`、`t`(只限這些網域,其他網站的 `s`、`t` 可能有功能);Threads 網域另外去掉分享參數 `xmt`,理由相同。黑名單寫在 `src/clean.ts`,展開寫在 `src/expand.ts`。短連結用 HEAD 請求加 `Go-http-client/1.1` UA 取 `Location` 展開;Instagram 轉到 `/accounts/login` 代表展開失敗;Facebook 展開後還要再去掉 `rdid` 和 `share_url`。 +白名單只保留有功能的參數:Instagram 的 `img_index`,YouTube 的 `v`、`t`、`list`、`index`。黑名單至少包含 `utm_*`、`fbclid`、`gclid`、`igsh`、`igshid`、`mibextid`、`si`、`rdid`、`share_url`、`__cft__`、`__tn__`、`is_from_webapp`、`sender_device`、`refsrc`、`_rdr`、`tg_rhash`(Telegram Instant View 抓取時附加在分享連結後,不屬於原網址);X 與 Twitter 網域另外去掉分享參數 `s`、`t`(只限這些網域,其他網站的 `s`、`t` 可能有功能);Threads 網域另外去掉分享參數 `xmt`,理由相同。黑名單寫在 `src/clean.ts`,展開寫在 `src/expand.ts`。短連結用 HEAD 請求加 `Go-http-client/1.1` UA 取 `Location` 展開;Instagram 轉到 `/accounts/login` 代表展開失敗;Facebook 展開後還要再去掉 `rdid` 和 `share_url`。 清除規則只寫在 Worker 一處。非爬蟲請求 302 回去的原網址,和失敗時 302 回去的原網址,都經過同一套規則。例外有兩個:展開失敗時(沒有轉址、轉到登入頁或 `unsupportedbrowser`、轉到其他平台、逾時 5 秒、超過 3 次請求),`GET /?url=` 退回用清過 query 的短連結產生分享連結,這是 2026-09-28 的人工決定;分享連結轉址端點 `GET /<原網域>/<路徑>` 不展開短連結。 diff --git a/src/clean.ts b/src/clean.ts index 366f0ae..169f91d 100644 --- a/src/clean.ts +++ b/src/clean.ts @@ -39,6 +39,7 @@ const BLOCKED_NAMES = new Set([ "sender_device", "refsrc", "_rdr", + "tg_rhash", ]); function segmentName(segment: string): string { diff --git a/test/clean.test.ts b/test/clean.test.ts index 3081282..d7a68df 100644 --- a/test/clean.test.ts +++ b/test/clean.test.ts @@ -192,3 +192,9 @@ test("threads drops the share parameter xmt", () => { test("xmt is kept on hosts other than threads", () => { expect(cleanUrl(new URL("https://example.com/?xmt=1")).href).toBe("https://example.com/?xmt=1"); }); + +test("telegram's instant view tg_rhash is removed like a tracking parameter", () => { + expect(cleanUrl(new URL("https://www.facebook.com/story.php?story_fbid=1&id=2&tg_rhash=9d9dcd0763add6")).href).toBe( + "https://www.facebook.com/story.php?story_fbid=1&id=2", + ); +}); diff --git a/test/worker.test.ts b/test/worker.test.ts index c9e3b10..3a0e730 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1737,6 +1737,24 @@ test("a discord crawler receives a facebook og page", async () => { } }); +test("the instant view bot's tg_rhash does not reach the facebook og page or upstream", async () => { + const html = await facebookFixture("post-1Fu5ScGFUZ.zh-Hant.html"); + const app = createWorker({ cache: () => fakeCache().cache }); + const spy = stubFetch(() => Promise.resolve(new Response(html, { status: 200 }))); + try { + const res = await callWorker(app, `${MANNY_SHARE}&tg_rhash=9d9dcd0763add6`, { + "User-Agent": "TelegramBot (like TwitterBot)", + }); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain(``); + expect(body).toContain(``); + expect(spy.mock.calls[0]?.[0]).toBe(MANNY_PLUGIN); + } finally { + spy.mockRestore(); + } +}); + test("a facebook story preview points at the cleaned media url", async () => { const html = await facebookFixture("post-1Fu5ScGFUZ.zh-Hant.html"); const app = createWorker({ cache: () => fakeCache().cache }); From a91fdba954ccda82602051e427b3df1cd6f39a22 Mon Sep 17 00:00:00 2001 From: musingfox Date: Mon, 19 Oct 2026 17:10:16 +0800 Subject: [PATCH 2/4] feat(og): carry the whole post in an article for telegram instant view Co-Authored-By: Claude Opus 5.5 --- src/index.ts | 5 ++++ src/og.ts | 26 +++++++++++++++- test/og.test.ts | 72 +++++++++++++++++++++++++++++++++++++++++++++ test/worker.test.ts | 37 +++++++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 98fb6c9..a465aff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,6 +97,7 @@ async function facebookOg( description: post.caption, url: cleaned.href, media: { ...item, url: mediaUrl }, + article: { heading: post.username, media: [{ ...item, url: mediaUrl }] }, }); return new Response(page, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); } @@ -119,6 +120,10 @@ async function instagramOg( description: post.caption, url: landing.href, media: { ...item, url: mediaUrl }, + article: { + heading: `@${post.username}`, + media: post.media.map((each, i) => ({ ...each, url: `${requestUrl.origin}/media/${shortcode}/${i + 1}` })), + }, }); return new Response(page, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); } diff --git a/src/og.ts b/src/og.ts index c46b7e1..d494af3 100644 --- a/src/og.ts +++ b/src/og.ts @@ -23,6 +23,7 @@ type OgPageMeta = { title: string; description: string; url: string; + article?: { heading: string; media: PageMedia[] }; } & ( | { image: string } | { video: { url: string; width: number; height: number } } @@ -35,6 +36,29 @@ function pageMedia(meta: OgPageMeta): PageMedia { return { kind: "image", url: meta.image }; } +function mediaTag(media: PageMedia): string { + const src = escapeHtml(media.url); + if (media.kind === "image") return ``; + return ``; +} + +// Telegram Instant View builds its page from this body; the template is in +// docs/telegram-instant-view.iv and turns div.carousel into a slideshow. +function renderArticle(article: { heading: string; media: PageMedia[] }, caption: string): string { + const paragraphs = caption + .replace(/\r\n?/g, "\n") + .split(/\n\s*\n/) + .map((paragraph) => paragraph.trim()) + .filter((paragraph) => paragraph !== "") + .map((paragraph) => `

${escapeHtml(paragraph).replaceAll("\n", "
")}

`); + const tags = article.media.map(mediaTag); + const media = + tags.length === 1 ? [`
${tags[0]}
`] : ['"]; + return ["
", `

${escapeHtml(article.heading)}

`, ...paragraphs, ...media, "
", ""].join( + "\n", + ); +} + export function renderOgPage(meta: OgPageMeta): string { const title = escapeHtml(meta.title); const description = escapeHtml(truncateDescription(meta.description)); @@ -70,7 +94,7 @@ ${ogMedia} ${twitterMedia} -${url} +${meta.article ? renderArticle(meta.article, meta.description) : ""}${url} `; diff --git a/test/og.test.ts b/test/og.test.ts index cb9b0df..88268ad 100644 --- a/test/og.test.ts +++ b/test/og.test.ts @@ -187,3 +187,75 @@ test("ampersands in a video url are escaped in og:video", () => { test("an image preview is byte-identical to the page captured before video tags", () => { expect(renderOgPage(imagePage)).toBe(imagePageBeforeVideo); }); + +const articlePage = { + title: "@egg (2/3)", + description: "first line\nsecond \n\nnext paragraph", + url: "https://www.instagram.com/p/X/", + image: "https://bl.example/media/X/2", + article: { + heading: "@egg", + media: [ + { kind: "image" as const, url: "https://bl.example/media/X/1" }, + { kind: "video" as const, url: "https://bl.example/media/X/2?a=1&b=2", width: 720, height: 1280 }, + { kind: "image" as const, url: "https://bl.example/media/X/3" }, + ], + }, +}; + +function articleOf(html: string): string { + const match = html.match(/
\n([\s\S]*)\n<\/article>/); + expect(match).not.toBeNull(); + return match![1]; +} + +test("an article page puts the heading, caption and every media item in the body for instant view", () => { + expect(articleOf(renderOgPage(articlePage))).toBe( + [ + "

@egg

", + "

first line
second <line>

", + "

next paragraph

", + '", + ].join("\n"), + ); +}); + +test("an article page keeps the head of the page it previews", () => { + const { article: _, ...head } = articlePage; + const page = renderOgPage(articlePage); + expect(page.slice(0, page.indexOf(""))).toBe( + renderOgPage(head).slice(0, renderOgPage(head).indexOf("")), + ); +}); + +test("an article with one media item is a figure, not a carousel", () => { + const body = articleOf( + renderOgPage({ ...articlePage, article: { heading: "@egg", media: [articlePage.article.media[0]!] } }), + ); + expect(body).toContain('
'); + expect(body).not.toContain("carousel"); +}); + +test("an article keeps the whole caption that the og description truncates", () => { + const description = "a".repeat(300); + expect(articleOf(renderOgPage({ ...articlePage, description }))).toContain(`

${description}

`); +}); + +test("an article without a caption has no paragraph", () => { + expect(articleOf(renderOgPage({ ...articlePage, description: "" }))).not.toContain("

"); +}); + +test("an article heading is escaped", () => { + const html = renderOgPage({ ...articlePage, article: { ...articlePage.article, heading: "&" } }); + expect(articleOf(html)).toContain("

<b>&

"); +}); + +test("an article splits paragraphs on crlf and whitespace-only blank lines", () => { + expect(articleOf(renderOgPage({ ...articlePage, description: "a\r\nb\r\n\r\nc\n \nd" }))).toContain( + "

a
b

\n

c

\n

d

", + ); +}); diff --git a/test/worker.test.ts b/test/worker.test.ts index 3a0e730..5807641 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1051,6 +1051,28 @@ test("a crawler preview of carousel item 2 points at that item", async () => { } }); +test("a crawler preview of a carousel item carries every item in its article", async () => { + const html = await instagramFixture("embed-DOBXTYNklfi.html"); + const count = parseEmbed(html)?.media.length ?? 0; + expect(count).toBeGreaterThan(1); + 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/p/DOBXTYNklfi/2", { + "User-Agent": "TelegramBot (like TwitterBot)", + }); + const body = await res.text(); + expect(body).toContain('