mirror of
https://github.com/zedeus/nitter
synced 2026-09-06 07:09:31 +00:00
Add tweet embed and oEmbed API support
This commit is contained in:
parent
06c52473ab
commit
9be0b8f826
10 changed files with 389 additions and 31 deletions
|
|
@ -1,18 +1,45 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, strutils, strformat, options
|
||||
import asyncdispatch, strutils, strformat, json
|
||||
import jester, karax/vdom
|
||||
import ".."/[types, api]
|
||||
import ".."/[types, api, formatters]
|
||||
import ../views/[embed, tweet, general]
|
||||
include "../views/oembed.nimf"
|
||||
import router_utils
|
||||
|
||||
export api, embed, vdom, tweet, general, router_utils
|
||||
|
||||
proc parseTweetUrl*(url: string): 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:
|
||||
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 ("", "")
|
||||
|
||||
proc createEmbedRouter*(cfg: Config) =
|
||||
router embed:
|
||||
get "/i/videos/tweet/@id":
|
||||
let tweet = await getGraphTweetResult(@"id")
|
||||
if tweet == nil or not tweet.hasVideos:
|
||||
resp Http404
|
||||
let
|
||||
tweet = await getGraphTweetResult(@"id")
|
||||
prefs = requestPrefs()
|
||||
|
||||
if tweet == nil:
|
||||
resp renderErrorEmbed("Tweet not found", prefs, cfg, request)
|
||||
|
||||
if not tweet.hasVideos:
|
||||
resp renderErrorEmbed("No video in tweet", prefs, cfg, request)
|
||||
|
||||
resp renderVideoEmbed(tweet, cfg, request)
|
||||
|
||||
|
|
@ -23,7 +50,7 @@ proc createEmbedRouter*(cfg: Config) =
|
|||
path = getPath()
|
||||
|
||||
if tweet == nil:
|
||||
resp Http404
|
||||
resp renderErrorEmbed("Tweet not found", prefs, cfg, request)
|
||||
|
||||
resp renderTweetEmbed(tweet, path, prefs, cfg, request)
|
||||
|
||||
|
|
@ -34,3 +61,35 @@ proc createEmbedRouter*(cfg: Config) =
|
|||
redirect(&"/i/status/{id}/embed")
|
||||
else:
|
||||
resp Http404
|
||||
|
||||
get "/api/oembed":
|
||||
let url = @"url"
|
||||
if url.len == 0:
|
||||
resp Http400, "Missing url parameter"
|
||||
|
||||
let (username, tweetId) = parseTweetUrl(url)
|
||||
if username.len == 0 or tweetId.len == 0:
|
||||
resp Http400, "Invalid tweet URL"
|
||||
|
||||
let tweet = await getGraphTweetResult(tweetId)
|
||||
if tweet == nil:
|
||||
resp Http404
|
||||
|
||||
let
|
||||
embedUrl = getUrlPrefix(cfg) & "/" & username & "/status/" & tweetId & "/embed"
|
||||
authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username
|
||||
|
||||
responseHeaders().get.add(("Access-Control-Allow-Origin", "*"))
|
||||
respJson %*{
|
||||
"version": "1.0",
|
||||
"type": "rich",
|
||||
"provider_name": cfg.title,
|
||||
"provider_url": getUrlPrefix(cfg),
|
||||
"author_name": tweet.user.fullname,
|
||||
"author_url": authorUrl,
|
||||
"url": embedUrl,
|
||||
"width": 550,
|
||||
"height": nil,
|
||||
"cache_age": "3153600000",
|
||||
"html": renderOembedIframe(embedUrl)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from jester import Request, cookies
|
|||
|
||||
import ../views/general
|
||||
import ".."/[utils, prefs, types]
|
||||
export utils, prefs, types, uri
|
||||
export utils, prefs, types, uri, json
|
||||
|
||||
template savePref*(pref, value: string; req: Request; expire=false) =
|
||||
if not expire or pref in cookies(req):
|
||||
|
|
|
|||
|
|
@ -118,30 +118,114 @@
|
|||
}
|
||||
}
|
||||
|
||||
body:has(> .tweet-embed) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html:has(body > .tweet-embed) {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tweet-embed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--bg_panel);
|
||||
border: 1px solid var(--border_grey);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_hover);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tweet-content {
|
||||
font-size: 18px;
|
||||
pointer-events: none;
|
||||
|
||||
a {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
pointer-events: none;
|
||||
|
||||
a, video, .video-overlay {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 0.75em * 2);
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
.avatar:not(.mini) {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.quote-media-container {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.quote-media-container .gallery-row .attachment,
|
||||
.quote-media-container .gallery-row .attachment > video,
|
||||
.quote-media-container .gallery-row .attachment > img,
|
||||
.quote-media-container .still-image,
|
||||
.quote-media-container .still-image img,
|
||||
.still-image,
|
||||
.still-image img {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
&.error-embed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80px;
|
||||
padding: 20px;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_panel);
|
||||
}
|
||||
|
||||
.error-panel {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.embed-footer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border_grey);
|
||||
background: var(--bg_elements);
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
pointer-events: all;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg_hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attribution {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import options
|
||||
import karax/[karaxdsl, vdom]
|
||||
from jester import Request
|
||||
|
||||
import ".."/[types, formatters]
|
||||
import general, tweet
|
||||
|
||||
const doctype = "<!DOCTYPE html>\n"
|
||||
const
|
||||
doctype = "<!DOCTYPE html>\n"
|
||||
embedResizeJs = staticRead("../../public/js/embedResize.js")
|
||||
embedTweetJs = embedResizeJs & staticRead("../../public/js/embedTweet.js")
|
||||
embedErrorJs = embedResizeJs & ";requestAnimationFrame(arguments[0]);"
|
||||
|
||||
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||
let
|
||||
let
|
||||
video = tweet.getVideos()[0]
|
||||
thumb = video.thumb
|
||||
vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
|
|
@ -22,4 +25,37 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
|||
tdiv(class="embed-video"):
|
||||
renderVideo(video, prefs, "")
|
||||
|
||||
script:
|
||||
verbatim embedResizeJs
|
||||
|
||||
result = doctype & $node
|
||||
|
||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req)
|
||||
|
||||
body:
|
||||
tdiv(class="tweet-embed"):
|
||||
a(class="tweet-link", href=getLink(tweet), target="_blank")
|
||||
renderTweet(tweet, prefs, path, mainTweet=true)
|
||||
a(class="embed-footer", href=getLink(tweet), target="_blank"):
|
||||
text "Read more on " & cfg.hostname
|
||||
|
||||
script:
|
||||
verbatim embedTweetJs
|
||||
|
||||
result = doctype & $node
|
||||
|
||||
proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req)
|
||||
|
||||
body:
|
||||
tdiv(class="tweet-embed error-embed"):
|
||||
tdiv(class="error-panel"):
|
||||
span: text error
|
||||
|
||||
script:
|
||||
verbatim embedErrorJs
|
||||
|
||||
result = doctype & $node
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||
|
||||
buildHtml(head):
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=51")
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=52")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
|
||||
|
||||
if theme.len > 0:
|
||||
|
|
|
|||
6
src/views/oembed.nimf
Normal file
6
src/views/oembed.nimf
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
## SPDX-License-Identifier: AGPL-3.0-only
|
||||
#proc renderOembedIframe*(embedUrl: string): 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>
|
||||
#end proc
|
||||
|
|
@ -5,7 +5,6 @@ from jester import Request
|
|||
|
||||
import renderutils
|
||||
import ".."/[types, utils, formatters]
|
||||
import general
|
||||
|
||||
const doctype = "<!DOCTYPE html>\n"
|
||||
|
||||
|
|
@ -464,13 +463,3 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||
|
||||
if not prefs.hideTweetStats:
|
||||
renderStats(tweet.stats)
|
||||
|
||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req)
|
||||
|
||||
body:
|
||||
tdiv(class="tweet-embed"):
|
||||
renderTweet(tweet, prefs, path, mainTweet=true)
|
||||
|
||||
result = doctype & $node
|
||||
|
|
|
|||
Loading…
Reference in a new issue