mirror of
https://github.com/zedeus/nitter
synced 2026-09-05 14:49:32 +00:00
parent
989b1f5f64
commit
7f6c083dce
6 changed files with 135 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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('&', '&')
|
||||
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})."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue