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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<article>` 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.
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<rhash>` when converting.

```sh
curl 'https://link.example/?iv=<rhash>&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=<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=<url>[&raw=1]` | `200 text/plain` with the link; `400` for an invalid URL |
| `GET /?url=<url>[&raw=1][&iv=<rhash>]` | `200 text/plain` with the link; `400` for an invalid URL or `iv` |
| `GET /<original host>/<path>` | Crawlers: og preview page or 302 to a fix service. Regular browsers: 302 to the original URL |
| `GET /www.instagram.com/p/<code>/<n>` | 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 |
Expand Down
17 changes: 16 additions & 1 deletion README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<rhash>`。

```sh
curl 'https://link.example/?iv=<rhash>&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=<rhash>
```

只有 Instagram 和 Facebook 的連結會被包起來;其他連結照樣回傳一般分享連結,因為它們的預覽來自修正服務,你的模板套不上去。`t.me/iv` 連結只適合在 Telegram 裡用:其他聊天軟體只會顯示一個空白的 Telegram 預覽。

## 端點

| 請求 | 回應 |
| --- | --- |
| `GET /?url=<網址>[&raw=1]` | `200 text/plain`,內容是連結;網址無效時回 `400` |
| `GET /?url=<網址>[&raw=1][&iv=<rhash>]` | `200 text/plain`,內容是連結;網址或 `iv` 無效時回 `400` |
| `GET /<原網域>/<路徑>` | 爬蟲:og 預覽頁或 302 到修正服務。一般瀏覽器:302 回原網址 |
| `GET /www.instagram.com/p/<code>/<n>` | 多圖貼文的第 n 張(1 起算) |
| `GET /media/...` | 302 到當下重新簽章的 CDN 圖片或影片網址,供 og 標籤使用 |
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/tracking-params-cleaned-at-conversion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<原網域>/<路徑>` 不展開短連結。

Expand Down
7 changes: 7 additions & 0 deletions docs/telegram-instant-view.iv
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
~version: "2.1"

?exists: //article/h1

title: //article/h1
<slideshow>: //div[has-class("carousel")]
body: //article
1 change: 1 addition & 0 deletions src/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const BLOCKED_NAMES = new Set([
"sender_device",
"refsrc",
"_rdr",
"tg_rhash",
]);

function segmentName(segment: string): string {
Expand Down
39 changes: 32 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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" } });
}
Expand All @@ -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" } });
}
Expand All @@ -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;
Expand Down
26 changes: 25 additions & 1 deletion src/og.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand All @@ -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 `<img src="${src}">`;
return `<video src="${src}" width="${media.width}" height="${media.height}"></video>`;
}

// 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) => `<p>${escapeHtml(paragraph).replaceAll("\n", "<br>")}</p>`);
const tags = article.media.map(mediaTag);
const media =
tags.length === 1 ? [`<figure>${tags[0]}</figure>`] : ['<div class="carousel">', ...tags, "</div>"];
return ["<article>", `<h1>${escapeHtml(article.heading)}</h1>`, ...paragraphs, ...media, "</article>", ""].join(
"\n",
);
}

export function renderOgPage(meta: OgPageMeta): string {
const title = escapeHtml(meta.title);
const description = escapeHtml(truncateDescription(meta.description));
Expand Down Expand Up @@ -70,7 +94,7 @@ ${ogMedia}
${twitterMedia}
</head>
<body>
<a href="${url}">${url}</a>
${meta.article ? renderArticle(meta.article, meta.description) : ""}<a href="${url}">${url}</a>
</body>
</html>
`;
Expand Down
6 changes: 6 additions & 0 deletions test/clean.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
72 changes: 72 additions & 0 deletions test/og.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <line>\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(/<article>\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(
[
"<h1>@egg</h1>",
"<p>first line<br>second &lt;line&gt;</p>",
"<p>next paragraph</p>",
'<div class="carousel">',
'<img src="https://bl.example/media/X/1">',
'<video src="https://bl.example/media/X/2?a=1&amp;b=2" width="720" height="1280"></video>',
'<img src="https://bl.example/media/X/3">',
"</div>",
].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("<body>"))).toBe(
renderOgPage(head).slice(0, renderOgPage(head).indexOf("<body>")),
);
});

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('<figure><img src="https://bl.example/media/X/1"></figure>');
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(`<p>${description}</p>`);
});

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

test("an article heading is escaped", () => {
const html = renderOgPage({ ...articlePage, article: { ...articlePage.article, heading: "<b>&" } });
expect(articleOf(html)).toContain("<h1>&lt;b&gt;&amp;</h1>");
});

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(
"<p>a<br>b</p>\n<p>c</p>\n<p>d</p>",
);
});
Loading
Loading