Add oEmbed discovery and standard support

Fixes #696
This commit is contained in:
Zed 2026-08-14 02:27:05 +07:00
commit 7f6c083dce
6 changed files with 135 additions and 26 deletions

View file

@ -8,24 +8,32 @@ import router_utils
export api, embed, vdom, tweet, general, router_utils export api, embed, vdom, tweet, general, router_utils
proc parseTweetUrl*(url: string): tuple[username, id: string] = proc parseTweetPath(path: string): tuple[username, id: string] =
let parts = path.split('/')
if parts.len >= 3 and parts[1] in ["status", "statuses"]:
let tweetId = parts[2].split('?')[0].split('#')[0]
if tweetId.len > 0 and tweetId.allCharsInSet(Digits):
return (parts[0], tweetId)
return ("", "")
proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] =
var path = url var path = url
if path.startsWith("https://"): if path.startsWith("https://"):
path = path[8..^1] path = path[8..^1]
elif path.startsWith("http://"): elif path.startsWith("http://"):
path = path[7..^1] path = path[7..^1]
const prefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/", const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/",
"www.twitter.com/", "www.x.com/"] "www.twitter.com/", "www.x.com/"]
for prefix in prefixes:
for prefix in twitterPrefixes:
if path.startsWith(prefix): if path.startsWith(prefix):
path = path[prefix.len..^1] return parseTweetPath(path[prefix.len..^1])
let parts = path.split('/')
if parts.len >= 3 and parts[1] == "status": let nitterPrefix = cfg.hostname & "/"
let tweetId = parts[2].split('?')[0].split('#')[0] if path.startsWith(nitterPrefix):
if tweetId.len > 0 and tweetId.allCharsInSet(Digits): return parseTweetPath(path[nitterPrefix.len..^1])
return (parts[0], tweetId)
break
return ("", "") return ("", "")
proc createEmbedRouter*(cfg: Config) = proc createEmbedRouter*(cfg: Config) =
@ -68,11 +76,19 @@ proc createEmbedRouter*(cfg: Config) =
resp Http404 resp Http404
get "/api/oembed": get "/api/oembed":
let url = @"url" responseHeaders().get.add(("Access-Control-Allow-Origin", "*"))
let
url = @"url"
format = @"format"
if format.len > 0 and format != "json":
resp Http501, "Only JSON format is supported"
if url.len == 0: if url.len == 0:
resp Http400, "Missing url parameter" resp Http400, "Missing url parameter"
let (username, tweetId) = parseTweetUrl(url) let (username, tweetId) = parseTweetUrl(url, cfg)
if username.len == 0 or tweetId.len == 0: if username.len == 0 or tweetId.len == 0:
resp Http400, "Invalid tweet URL" resp Http400, "Invalid tweet URL"
@ -81,20 +97,34 @@ proc createEmbedRouter*(cfg: Config) =
resp Http404 resp Http404
let let
embedUrl = getUrlPrefix(cfg) & "/" & username & "/status/" & tweetId & "/embed" maxwidthParam = @"maxwidth"
maxwidth = if maxwidthParam.len > 0:
try: clamp(parseInt(maxwidthParam), 220, 550)
except ValueError: 550
else: 550
embedUrl = getUrlPrefix(cfg) & "/" & tweet.user.username & "/status/" & tweetId & "/embed"
authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username
title = stripHtml(tweet.text)
responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) var response = %*{
respJson %*{
"version": "1.0", "version": "1.0",
"type": "rich", "type": "rich",
"provider_name": cfg.title, "provider_name": cfg.title,
"provider_url": getUrlPrefix(cfg), "provider_url": getUrlPrefix(cfg),
"title": title,
"author_name": tweet.user.fullname, "author_name": tweet.user.fullname,
"author_url": authorUrl, "author_url": authorUrl,
"url": embedUrl, "url": embedUrl,
"width": 550, "width": maxwidth,
"height": nil, "height": newJNull(),
"cache_age": "3153600000", "cache_age": "3153600000",
"html": renderOembedIframe(embedUrl) "html": renderOembedIframe(embedUrl, maxwidth)
} }
if tweet.media.len > 0:
let thumbUrl = getUrlPrefix(cfg) & getPicUrl(tweet.media[0].getThumb)
response["thumbnail_url"] = %thumbUrl
response["thumbnail_width"] = %maxwidth
response["thumbnail_height"] = %maxwidth
respJson response

View file

@ -66,9 +66,13 @@ proc createStatusRouter*(cfg: Config) =
elif card.video.isSome(): elif card.video.isSome():
images = @[card.video.get().thumb] images = @[card.video.get().thumb]
let
tweetUrl = getUrlPrefix(cfg) & "/" & conv.tweet.user.username & "/status/" & $conv.tweet.id
oembedUrl = getUrlPrefix(cfg) & "/api/oembed?url=" & encodeUrl(tweetUrl)
let html = renderConversation(conv, prefs, getPath() & "#m", sort) let html = renderConversation(conv, prefs, getPath() & "#m", sort)
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
images=images, video=video) images=images, video=video, oembed=oembedUrl)
get "/@name/status/@id/history/?": get "/@name/status/@id/history/?":
cond '.' notin @"name" cond '.' notin @"name"

View file

@ -38,7 +38,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle=""; video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; alternate=""): VNode = rss=""; alternate=""; oembed=""): VNode =
let theme = prefs.theme.toTheme let theme = prefs.theme.toTheme
let ogType = let ogType =
@ -70,6 +70,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
if rss.len > 0: if rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
if oembed.len > 0:
let oembedTitle = if titleText.len > 0: titleText else: "oEmbed"
link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle)
if prefs.hlsPlayback: if prefs.hlsPlayback:
script(src="/js/hls.min.js", `defer`="") script(src="/js/hls.min.js", `defer`="")
script(src="/js/hlsPlayback.js?v=1", `defer`="") script(src="/js/hlsPlayback.js?v=1", `defer`="")
@ -124,7 +128,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video=""; titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""; images: seq[string] = @[]; banner="";
twitterLink=""): string = twitterLink=""; oembed=""): string =
let twitterLink = let twitterLink =
if twitterLink.len > 0: twitterLink if twitterLink.len > 0: twitterLink
@ -132,7 +136,7 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, twitterLink) rss, twitterLink, oembed)
let bodyClass = if prefs.stickyNav: "fixed-nav" else: "" let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
body(class=bodyClass): body(class=bodyClass):

View file

@ -1,7 +1,7 @@
#? stdtmpl(subsChar = '$', metaChar = '#') #? stdtmpl(subsChar = '$', metaChar = '#')
## SPDX-License-Identifier: AGPL-3.0-only ## SPDX-License-Identifier: AGPL-3.0-only
#proc renderOembedIframe*(embedUrl: string): string = #proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string =
# result = "" # result = ""
<iframe src="$embedUrl" style="width:100%;max-width:550px;height:250px;border:none" scrolling="no" loading="lazy" onload="let c=new MessageChannel;c.port1.onmessage=e=>this.style.height=e.data+'px';this.contentWindow.postMessage('','*',[c.port2])"></iframe> <iframe src="$embedUrl" style="width:100%;max-width:${maxwidth}px;height:250px;border:none" scrolling="no" loading="lazy" onload="let c=new MessageChannel;c.port1.onmessage=e=>this.style.height=e.data+'px';this.contentWindow.postMessage('','*',[c.port2])"></iframe>
# result = result.strip() # result = result.strip()
#end proc #end proc

View file

@ -8,4 +8,4 @@ python = "^3.14"
seleniumbase = "4.46.5" seleniumbase = "4.46.5"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "--pls=eager --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --timeout_multiplier=5" addopts = "--pls=eager --rcs --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --only-rerun=WebDriverException --timeout_multiplier=5"

View file

@ -178,12 +178,83 @@ class OEmbedApiTest(BaseTestCase):
resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=400') resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=400')
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
def test_oembed_maxwidth_clamps_to_range(self):
resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=100')
data = resp.json()
self.assertEqual(data['width'], 220)
self.assertIn('max-width:220px', data['html'])
def test_oembed_maxwidth_caps_at_550(self):
resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=9999')
data = resp.json()
self.assertEqual(data['width'], 550)
def test_oembed_maxwidth_invalid_uses_default(self):
resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=abc')
data = resp.json()
self.assertEqual(data['width'], 550)
def test_oembed_author_url_present(self): def test_oembed_author_url_present(self):
resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}')
data = resp.json() data = resp.json()
self.assertIn('author_url', data) self.assertIn('author_url', data)
self.assertIn('elonmusk', data['author_url']) self.assertIn('elonmusk', data['author_url'])
def test_oembed_accepts_nitter_url(self):
nitter_url = f'{self.base_url}/elonmusk/status/1141367104702038016'
resp = requests.get(f'{self.base_url}/api/oembed?url={nitter_url}')
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn('html', data)
def test_oembed_format_json_accepted(self):
resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&format=json')
self.assertEqual(resp.status_code, 200)
def test_oembed_format_xml_returns_501(self):
resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&format=xml')
self.assertEqual(resp.status_code, 501)
def test_oembed_has_title(self):
resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}')
data = resp.json()
self.assertIn('title', data)
self.assertIsInstance(data['title'], str)
self.assertGreater(len(data['title']), 0)
def test_oembed_has_null_height(self):
resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}')
data = resp.json()
self.assertIsNone(data['height'])
class OEmbedDiscoveryTest(BaseTestCase):
"""Test oEmbed discovery link tags on tweet pages."""
def test_tweet_page_has_oembed_link_tag(self):
self.open_nitter('elonmusk/status/1141367104702038016')
self.assert_element_present('link[type="application/json+oembed"]')
def test_oembed_link_tag_points_to_api(self):
resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016')
self.assertIn('application/json+oembed', resp.text)
self.assertIn('/api/oembed?url=', resp.text)
self.assertIn('1141367104702038016', resp.text)
def test_oembed_discovery_roundtrip(self):
"""Fetch a tweet page, extract oEmbed URL, call it, verify response."""
import re
resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016')
match = re.search(
r'type="application/json\+oembed"\s+href="([^"]*)"', resp.text)
self.assertIsNotNone(match, "No oEmbed discovery link found in page")
oembed_url = match.group(1).replace('&amp;', '&')
oembed_resp = requests.get(oembed_url)
self.assertEqual(oembed_resp.status_code, 200)
data = oembed_resp.json()
self.assertEqual(data['type'], 'rich')
self.assertIn('html', data)
class VideoEmbedTest(BaseTestCase): class VideoEmbedTest(BaseTestCase):
"""Test video embed route (/i/videos/tweet/{id}).""" """Test video embed route (/i/videos/tweet/{id})."""