diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ec51ec2..b4464b2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,7 +13,7 @@ better-link is a single Cloudflare Worker with three kinds of request. A convers - `src/fix-services.ts` maps other platforms to fix services (`fixServiceUrl`). - `src/instagram.ts` fetches the `/embed/captioned/` page and parses it into a `Post` (`instagramPost`, `parseEmbed`). The `Post` and `PostCache` types live here. - `src/facebook.ts` fetches `plugins/post.php` and `plugins/video.php` and parses them into a `Post` (`facebookPost`, `facebookVideo`). -- `src/og.ts` renders a `Post` into og and twitter card HTML (`renderOgPage`). +- `src/og.ts` renders a `Post` into og and twitter card HTML (`renderOgPage`), with the whole post in an `
` body that the Telegram Instant View template `docs/telegram-instant-view.iv` reads. - `cloudflare.config.ts` holds the Worker settings that `cf` reads. It declares the `SHARED_LINKS` KV binding only when the build variable `LOG_SHARED_LINKS` is `1`. - `src/shared-link-log.ts` (`logSharedLink`) writes one record per successful `GET /?url=` to `env.SHARED_LINKS` through `ctx.waitUntil`, after waiting up to 10 seconds for `summarize`. Without the binding it does nothing. - `src/summary.ts` (`summarize`) fetches a post's author and text for the log: Instagram and Facebook through their parsers, YouTube and X through oEmbed, Bluesky through its public API, Threads and the fix-service platforms through og tags. diff --git a/README.md b/README.md index e476dea..25568cc 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,26 @@ You can also skip the API and replace `https://` in the original URL with `https The `url` parameter must be percent-encoded in both modes. The examples assume the Worker is deployed at `https://link.example`. +### Telegram Instant View + +The og page of an Instagram or Facebook post also carries the whole post in its body: the full caption and every image and video. Telegram's Instant View can show that page inside Telegram, with multi-image posts as a slideshow. Telegram applies only templates it has approved to ordinary links, so better-link uses a link that names your own template instead: + +1. Open [My Templates](https://instantview.telegram.org/my) on a desktop browser, enter a share link from your Worker, and paste [`docs/telegram-instant-view.iv`](docs/telegram-instant-view.iv) as the rules. +2. Press **View in Telegram** and copy the `rhash` value from the `t.me/iv` link it gives you. +3. Add `&iv=` when converting. + +```sh +curl 'https://link.example/?iv=&url=https%3A%2F%2Fwww.instagram.com%2Fp%2FABC%2F' +# https://t.me/iv?url=https%3A%2F%2Flink.example%2Fwww.instagram.com%2Fp%2FABC%2F&rhash= +``` + +Only Instagram and Facebook links are wrapped; other links come back as the plain share link, because their previews come from fix services that your template does not cover. A `t.me/iv` link is for Telegram only: other chat apps show it with an empty Telegram preview. + ## Endpoints | Request | Response | | --- | --- | -| `GET /?url=[&raw=1]` | `200 text/plain` with the link; `400` for an invalid URL | +| `GET /?url=[&raw=1][&iv=]` | `200 text/plain` with the link; `400` for an invalid URL or `iv` | | `GET //` | Crawlers: og preview page or 302 to a fix service. Regular browsers: 302 to the original URL | | `GET /www.instagram.com/p//` | The n-th image of a multi-image post (1-based) | | `GET /media/...` | 302 to a freshly signed CDN image or video URL, used by og tags | diff --git a/README.zh-TW.md b/README.zh-TW.md index 20e97b4..b4b623e 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -57,11 +57,26 @@ curl 'https://link.example/?url=https%3A%2F%2Fwww.instagram.com%2Fp%2FABC%2F%3Fi 兩種模式的 `url` 參數都要 percent-encode。範例以部署網域 `https://link.example` 為例。 +### Telegram Instant View + +Instagram 和 Facebook 貼文的 og 頁在內文裡也放了整篇貼文:完整說明文字和每一張圖片、每一支影片。Telegram 的 Instant View 可以在 Telegram 裡直接顯示這一頁,多圖貼文會變成輪播。Telegram 只對一般連結套用它審核過的模板,所以 better-link 改用一種指定你自己模板的連結: + +1. 用桌機瀏覽器打開 [My Templates](https://instantview.telegram.org/my),輸入你的 Worker 產生的分享連結,把 [`docs/telegram-instant-view.iv`](docs/telegram-instant-view.iv) 貼成規則。 +2. 按 **View in Telegram**,從它給的 `t.me/iv` 連結複製 `rhash` 的值。 +3. 轉換時加上 `&iv=`。 + +```sh +curl 'https://link.example/?iv=&url=https%3A%2F%2Fwww.instagram.com%2Fp%2FABC%2F' +# https://t.me/iv?url=https%3A%2F%2Flink.example%2Fwww.instagram.com%2Fp%2FABC%2F&rhash= +``` + +只有 Instagram 和 Facebook 的連結會被包起來;其他連結照樣回傳一般分享連結,因為它們的預覽來自修正服務,你的模板套不上去。`t.me/iv` 連結只適合在 Telegram 裡用:其他聊天軟體只會顯示一個空白的 Telegram 預覽。 + ## 端點 | 請求 | 回應 | | --- | --- | -| `GET /?url=<網址>[&raw=1]` | `200 text/plain`,內容是連結;網址無效時回 `400` | +| `GET /?url=<網址>[&raw=1][&iv=]` | `200 text/plain`,內容是連結;網址或 `iv` 無效時回 `400` | | `GET /<原網域>/<路徑>` | 爬蟲:og 預覽頁或 302 到修正服務。一般瀏覽器:302 回原網址 | | `GET /www.instagram.com/p//` | 多圖貼文的第 n 張(1 起算) | | `GET /media/...` | 302 到當下重新簽章的 CDN 圖片或影片網址,供 og 標籤使用 | 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/docs/telegram-instant-view.iv b/docs/telegram-instant-view.iv new file mode 100644 index 0000000..24d29ae --- /dev/null +++ b/docs/telegram-instant-view.iv @@ -0,0 +1,7 @@ +~version: "2.1" + +?exists: //article/h1 + +title: //article/h1 +: //div[has-class("carousel")] +body: //article 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/src/index.ts b/src/index.ts index 98fb6c9..e739613 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ import { hasSharedLinkLog, sharedLinkLogPage } from "./shared-link-log-page"; import { summarize } from "./summary"; const CONVERT_HINT = "pass a percent-encoded http(s) URL as ?url="; +const IV_HINT = "pass the hex rhash of your Telegram Instant View template as &iv="; +const RHASH = /^[0-9a-f]+$/i; const ITEM_SUFFIX = /^\/(p|reel)\/([^/]+)\/([1-9]\d?)\/?$/; function landingUrl(cleaned: URL): URL { @@ -44,14 +46,31 @@ async function convert( return text(400, CONVERT_HINT); } if (!isShareable(target)) return text(400, CONVERT_HINT); - const cleaned = cleanUrl((await expandShareLink(target)) ?? target); const rawMode = requestUrl.searchParams.get("raw") === "1"; + const rhash = rawMode ? "" : (requestUrl.searchParams.get("iv") ?? ""); + if (rhash !== "" && !RHASH.test(rhash)) return text(400, IV_HINT); + const cleaned = cleanUrl((await expandShareLink(target)) ?? target); const prefix = rawMode ? "https://" : `${requestUrl.origin}/`; const link = `${cleaned.host}${cleaned.pathname}${cleaned.search}`; logSharedLink(env, ctx, `https://${link}`, rawMode, () => summarize(new URL(`https://${link}`), { origin: requestUrl.origin, cache: deps.cache() }), ); - return text(200, `${prefix}${link}`); + const shareLink = `${prefix}${link}`; + // Only pages the Worker renders itself carry the article an Instant View template reads; + // a fix service's page would not match it. + if (rhash === "" || ownPreview(cleaned) === null) return text(200, shareLink); + return text(200, `https://t.me/iv?url=${encodeURIComponent(shareLink)}&rhash=${rhash}`); +} + +type OwnPreview = { platform: "instagram"; shortcode: string; index: number } | { platform: "facebook" }; + +function ownPreview(cleaned: URL): OwnPreview | null { + if (INSTAGRAM_HOSTS.has(cleaned.hostname)) { + const instagram = instagramShortcode(cleaned.pathname); + return instagram === null ? null : { platform: "instagram", ...instagram }; + } + if (isFacebookPostUrl(cleaned) || isFacebookVideoUrl(cleaned)) return { platform: "facebook" }; + return null; } async function loadPost( @@ -92,11 +111,13 @@ async function facebookOg( const item = post?.media[0]; if (!post || item === undefined) return Response.redirect(cleaned.href, 302); const mediaUrl = `${requestUrl.origin}/media/${cleaned.hostname}${cleaned.pathname}${cleaned.search}`; + const media = { ...item, url: mediaUrl }; const page = renderOgPage({ title: post.username, description: post.caption, url: cleaned.href, - media: { ...item, url: mediaUrl }, + media, + article: { heading: post.username, media: [media] }, }); return new Response(page, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); } @@ -119,6 +140,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" } }); } @@ -143,11 +168,11 @@ async function shareRedirect( } const cleaned = cleanUrl(candidate); const landing = landingUrl(cleaned); - 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); + const own = ownPreview(cleaned); + if (isCrawler(userAgent) && own?.platform === "instagram") { + return instagramOg(requestUrl, landing, own.shortcode, own.index, deps); } - if (isCrawler(userAgent) && (isFacebookPostUrl(cleaned) || isFacebookVideoUrl(cleaned))) { + if (isCrawler(userAgent) && own?.platform === "facebook") { return facebookOg(requestUrl, cleaned, deps); } const fixed = isCrawler(userAgent) ? fixServiceUrl(cleaned) : null; 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/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/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 c9e3b10..cee6c21 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -369,6 +369,50 @@ test("a mobile facebook share link converts to the cleaned mobile canonical url" } }); +test("iv wraps an instagram share link in a telegram instant view link", async () => { + const res = await call( + "https://bl.example/?iv=9d9dcd0763add6&url=https%3A%2F%2Fwww.instagram.com%2Fp%2FDOBXTYNklfi%2F%3Figsh%3Dx", + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe( + `https://t.me/iv?url=${encodeURIComponent("https://bl.example/www.instagram.com/p/DOBXTYNklfi/")}&rhash=9d9dcd0763add6`, + ); +}); + +test("iv wraps a facebook video share link in a telegram instant view link", async () => { + const res = await call( + `https://bl.example/?iv=9d9dcd0763add6&url=${encodeURIComponent("https://www.facebook.com/reel/1016339268064528?mibextid=x")}`, + ); + expect(await res.text()).toBe( + `https://t.me/iv?url=${encodeURIComponent("https://bl.example/www.facebook.com/reel/1016339268064528")}&rhash=9d9dcd0763add6`, + ); +}); + +test("iv leaves a share link that the worker does not render unwrapped", async () => { + const res = await call("https://bl.example/?iv=9d9dcd0763add6&url=https%3A%2F%2Fx.com%2Fjack%2Fstatus%2F20%3Fs%3D20"); + expect(await res.text()).toBe("https://bl.example/x.com/jack/status/20"); +}); + +test("iv is ignored with raw=1", async () => { + const res = await call( + "https://bl.example/?raw=1&iv=9d9dcd0763add6&url=https%3A%2F%2Fwww.instagram.com%2Fp%2FDOBXTYNklfi%2F", + ); + expect(await res.text()).toBe("https://www.instagram.com/p/DOBXTYNklfi/"); +}); + +test("an invalid iv is ignored with raw=1 like a valid one", async () => { + const res = await call("https://bl.example/?raw=1&iv=zz&url=https%3A%2F%2Fwww.instagram.com%2Fp%2FDOBXTYNklfi%2F"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("https://www.instagram.com/p/DOBXTYNklfi/"); +}); + +test("an iv that is not a hex rhash is rejected", async () => { + const res = await call( + "https://bl.example/?iv=9d9d%26x%3D1&url=https%3A%2F%2Fwww.instagram.com%2Fp%2FDOBXTYNklfi%2F", + ); + expect(res.status).toBe(400); +}); + test("raw=1 returns the cleaned original instead of a share link", async () => { const res = await call( "https://bl.example/?raw=1&url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ%26t%3D42%26si%3Dabc", @@ -1051,6 +1095,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('