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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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('&', '&')
|
||||||
|
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})."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue