Przeglądaj źródła

Add oEmbed discovery and standard support

Fixes #696
Zed 3 tygodni temu
rodzic
commit
7f6c083dce

+ 49 - 19
src/routes/embed.nim

@@ -8,24 +8,32 @@ import 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
   if path.startsWith("https://"):
     path = path[8..^1]
   elif path.startsWith("http://"):
     path = path[7..^1]
 
-  const prefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/",
-                    "www.twitter.com/", "www.x.com/"]
-  for prefix in prefixes:
+  const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/",
+                           "www.twitter.com/", "www.x.com/"]
+
+  for prefix in twitterPrefixes:
     if path.startsWith(prefix):
-      path = path[prefix.len..^1]
-      let parts = path.split('/')
-      if parts.len >= 3 and parts[1] == "status":
-        let tweetId = parts[2].split('?')[0].split('#')[0]
-        if tweetId.len > 0 and tweetId.allCharsInSet(Digits):
-          return (parts[0], tweetId)
-      break
+      return parseTweetPath(path[prefix.len..^1])
+
+  let nitterPrefix = cfg.hostname & "/"
+  if path.startsWith(nitterPrefix):
+    return parseTweetPath(path[nitterPrefix.len..^1])
+
   return ("", "")
 
 proc createEmbedRouter*(cfg: Config) =
@@ -68,11 +76,19 @@ proc createEmbedRouter*(cfg: Config) =
         resp Http404
 
     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:
         resp Http400, "Missing url parameter"
 
-      let (username, tweetId) = parseTweetUrl(url)
+      let (username, tweetId) = parseTweetUrl(url, cfg)
       if username.len == 0 or tweetId.len == 0:
         resp Http400, "Invalid tweet URL"
 
@@ -81,20 +97,34 @@ proc createEmbedRouter*(cfg: Config) =
         resp Http404
 
       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
+        title = stripHtml(tweet.text)
 
-      responseHeaders().get.add(("Access-Control-Allow-Origin", "*"))
-      respJson %*{
+      var response = %*{
         "version": "1.0",
         "type": "rich",
         "provider_name": cfg.title,
         "provider_url": getUrlPrefix(cfg),
+        "title": title,
         "author_name": tweet.user.fullname,
         "author_url": authorUrl,
         "url": embedUrl,
-        "width": 550,
-        "height": nil,
+        "width": maxwidth,
+        "height": newJNull(),
         "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

+ 5 - 1
src/routes/status.nim

@@ -66,9 +66,13 @@ proc createStatusRouter*(cfg: Config) =
         elif card.video.isSome():
           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)
       resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
-                      images=images, video=video)
+                      images=images, video=video, oembed=oembedUrl)
 
     get "/@name/status/@id/history/?":
       cond '.' notin @"name"

+ 7 - 3
src/views/general.nim

@@ -38,7 +38,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
 
 proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
                  video=""; images: seq[string] = @[]; banner=""; ogTitle="";
-                 rss=""; alternate=""): VNode =
+                 rss=""; alternate=""; oembed=""): VNode =
   let theme = prefs.theme.toTheme
     
   let ogType =
@@ -70,6 +70,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
     if rss.len > 0:
       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:
       script(src="/js/hls.min.js", `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;
                  titleText=""; desc=""; ogTitle=""; rss=""; video="";
                  images: seq[string] = @[]; banner="";
-                 twitterLink=""): string =
+                 twitterLink=""; oembed=""): string =
 
   let 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")):
     renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
-               rss, twitterLink)
+               rss, twitterLink, oembed)
 
     let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
     body(class=bodyClass):

+ 2 - 2
src/views/oembed.nimf

@@ -1,7 +1,7 @@
 #? stdtmpl(subsChar = '$', metaChar = '#')
 ## SPDX-License-Identifier: AGPL-3.0-only
-#proc renderOembedIframe*(embedUrl: string): string =
+#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string =
 #  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()
 #end proc

+ 1 - 1
tests/pyproject.toml

@@ -8,4 +8,4 @@ python = "^3.14"
 seleniumbase = "4.46.5"
 
 [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"

+ 71 - 0
tests/test_embed.py

@@ -178,12 +178,83 @@ class OEmbedApiTest(BaseTestCase):
         resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=400')
         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):
         resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}')
         data = resp.json()
         self.assertIn('author_url', data)
         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):
     """Test video embed route (/i/videos/tweet/{id})."""