diff --git a/apps/browser-extension/utils/twitter-expand-text.test.ts b/apps/browser-extension/utils/twitter-expand-text.test.ts new file mode 100644 index 00000000..721abbf6 --- /dev/null +++ b/apps/browser-extension/utils/twitter-expand-text.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test" +import { expandTweetText } from "./twitter-utils" + +const link = (url: string, expanded_url: string, display_url: string) => ({ + url, + expanded_url, + display_url, + indices: [0, url.length] as [number, number], +}) + +describe("expandTweetText", () => { + it("returns the text unchanged when there are no url entities", () => { + expect(expandTweetText("just text", undefined)).toBe("just text") + expect(expandTweetText("just text", [])).toBe("just text") + }) + + it("replaces a t.co shortlink with a markdown link to the expanded url", () => { + const text = "check this https://t.co/abc123 out" + const urls = [ + link( + "https://t.co/abc123", + "https://example.com/article", + "example.com/article", + ), + ] + expect(expandTweetText(text, urls)).toBe( + "check this [example.com/article](https://example.com/article) out", + ) + }) + + it("expands multiple shortlinks including repeats", () => { + const text = "a https://t.co/aaa b https://t.co/bbb c https://t.co/aaa" + const urls = [ + link("https://t.co/aaa", "https://a.com", "a.com"), + link("https://t.co/bbb", "https://b.com", "b.com"), + ] + expect(expandTweetText(text, urls)).toBe( + "a [a.com](https://a.com) b [b.com](https://b.com) c [a.com](https://a.com)", + ) + }) + + it("falls back to the expanded url as label when display_url is empty", () => { + const text = "see https://t.co/xyz" + const urls = [link("https://t.co/xyz", "https://long.example.com/path", "")] + expect(expandTweetText(text, urls)).toBe( + "see [https://long.example.com/path](https://long.example.com/path)", + ) + }) + + it("skips entries missing a url or expanded_url", () => { + const text = "keep https://t.co/keep here" + const urls = [ + link("", "https://nope.com", "nope.com"), + link("https://t.co/keep", "", "keep.com"), + ] + expect(expandTweetText(text, urls)).toBe("keep https://t.co/keep here") + }) +}) diff --git a/apps/browser-extension/utils/twitter-utils.ts b/apps/browser-extension/utils/twitter-utils.ts index 2682e6ed..c3295db4 100644 --- a/apps/browser-extension/utils/twitter-utils.ts +++ b/apps/browser-extension/utils/twitter-utils.ts @@ -399,6 +399,27 @@ export function extractNextCursor( return null } +/** + * Tweet `full_text` embeds links as opaque `t.co` shortlinks, while + * `entities.urls` carries the real destination. Replace each shortlink with a + * markdown link to its expanded URL (labelled with the human-readable + * display_url) so imported tweets keep working, searchable links instead of + * `https://t.co/xxxx`. + */ +export function expandTweetText( + text: string, + urls: Tweet["entities"]["urls"], +): string { + if (!urls || urls.length === 0) return text + let expanded = text + for (const link of urls) { + if (!link?.url || !link.expanded_url) continue + const label = link.display_url || link.expanded_url + expanded = expanded.split(link.url).join(`[${label}](${link.expanded_url})`) + } + return expanded +} + /** * Convert Tweet object to markdown format for storage */ @@ -412,8 +433,8 @@ export function tweetToMarkdown(tweet: Tweet): string { markdown += `**Date:** ${date} ${time}\n` markdown += `**Likes:** ${tweet.favorite_count} | **Retweets:** ${tweet.retweet_count || 0} | **Replies:** ${tweet.reply_count || 0}\n\n` - // Add tweet text - markdown += `${tweet.text}\n\n` + // Add tweet text with t.co shortlinks expanded to their real destinations + markdown += `${expandTweetText(tweet.text, tweet.entities.urls)}\n\n` // Add media if present if (tweet.photos && tweet.photos.length > 0) {