Better video/gif support
This commit is contained in:
parent
8f7c61eab6
commit
861ac7a593
7 changed files with 151 additions and 39 deletions
|
@ -593,3 +593,8 @@ nav {
|
||||||
top: calc(50% - 20px);
|
top: calc(50% - 20px);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
81
src/api.nim
81
src/api.nim
|
@ -5,12 +5,18 @@ import nimquery, regex
|
||||||
import ./types, ./parser
|
import ./types, ./parser
|
||||||
|
|
||||||
const
|
const
|
||||||
base = parseUri("https://twitter.com/")
|
|
||||||
agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"
|
agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"
|
||||||
|
auth = "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw"
|
||||||
|
|
||||||
|
base = parseUri("https://twitter.com/")
|
||||||
|
apiBase = parseUri("https://api.twitter.com/1.1/")
|
||||||
|
|
||||||
timelineUrl = "i/profiles/show/$1/timeline/tweets?include_available_features=1&include_entities=1&include_new_items_bar=true"
|
timelineUrl = "i/profiles/show/$1/timeline/tweets?include_available_features=1&include_entities=1&include_new_items_bar=true"
|
||||||
profilePopupUrl = "i/profiles/popup"
|
profilePopupUrl = "i/profiles/popup"
|
||||||
profileIntentUrl = "intent/user"
|
profileIntentUrl = "intent/user"
|
||||||
tweetUrl = "i/status/"
|
tweetUrl = "i/status/"
|
||||||
|
videoUrl = "videos/tweet/config/$1.json"
|
||||||
|
tokenUrl = "guest/activate.json"
|
||||||
|
|
||||||
proc fetchHtml(url: Uri; headers: HttpHeaders; jsonKey = ""): Future[XmlNode] {.async.} =
|
proc fetchHtml(url: Uri; headers: HttpHeaders; jsonKey = ""): Future[XmlNode] {.async.} =
|
||||||
var client = newAsyncHttpClient()
|
var client = newAsyncHttpClient()
|
||||||
|
@ -30,6 +36,20 @@ proc fetchHtml(url: Uri; headers: HttpHeaders; jsonKey = ""): Future[XmlNode] {.
|
||||||
else:
|
else:
|
||||||
return parseHtml(resp)
|
return parseHtml(resp)
|
||||||
|
|
||||||
|
proc fetchJson(url: Uri; headers: HttpHeaders): Future[JsonNode] {.async.} =
|
||||||
|
var client = newAsyncHttpClient()
|
||||||
|
defer: client.close()
|
||||||
|
|
||||||
|
client.headers = headers
|
||||||
|
|
||||||
|
var resp = ""
|
||||||
|
try:
|
||||||
|
resp = await client.getContent($url)
|
||||||
|
except:
|
||||||
|
return nil
|
||||||
|
|
||||||
|
return parseJson(resp)
|
||||||
|
|
||||||
proc getProfileFallback(username: string; headers: HttpHeaders): Future[Profile] {.async.} =
|
proc getProfileFallback(username: string; headers: HttpHeaders): Future[Profile] {.async.} =
|
||||||
let
|
let
|
||||||
url = base / profileIntentUrl ? {"screen_name": username}
|
url = base / profileIntentUrl ? {"screen_name": username}
|
||||||
|
@ -61,6 +81,63 @@ proc getProfile*(username: string): Future[Profile] {.async.} =
|
||||||
|
|
||||||
result = parsePopupProfile(html)
|
result = parsePopupProfile(html)
|
||||||
|
|
||||||
|
proc getGuestToken(): Future[string] {.async.} =
|
||||||
|
let headers = newHttpHeaders({
|
||||||
|
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||||
|
"Referer": $base,
|
||||||
|
"User-Agent": agent,
|
||||||
|
"Authorization": auth
|
||||||
|
})
|
||||||
|
|
||||||
|
let client = newAsyncHttpClient()
|
||||||
|
client.headers = headers
|
||||||
|
|
||||||
|
let
|
||||||
|
url = apibase / tokenUrl
|
||||||
|
json = parseJson(await client.postContent($url))
|
||||||
|
|
||||||
|
result = json["guest_token"].to(string)
|
||||||
|
|
||||||
|
proc getVideo*(tweet: Tweet; token: string) {.async.} =
|
||||||
|
let headers = newHttpHeaders({
|
||||||
|
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||||
|
"Referer": tweet.link,
|
||||||
|
"User-Agent": agent,
|
||||||
|
"Authorization": auth,
|
||||||
|
"x-guest-token": token
|
||||||
|
})
|
||||||
|
|
||||||
|
let
|
||||||
|
url = apiBase / (videoUrl % tweet.id)
|
||||||
|
json = await fetchJson(url, headers)
|
||||||
|
|
||||||
|
|
||||||
|
tweet.video = some(parseVideo(json))
|
||||||
|
|
||||||
|
proc getVideos*(tweets: Tweets; token="") {.async.} =
|
||||||
|
if not tweets.anyIt(it.video.isSome): return
|
||||||
|
|
||||||
|
var
|
||||||
|
token = if token.len > 0: token else: await getGuestToken()
|
||||||
|
videoFuts: seq[Future[void]]
|
||||||
|
|
||||||
|
for tweet in tweets:
|
||||||
|
if tweet.video.isSome:
|
||||||
|
videoFuts.add getVideo(tweet, token)
|
||||||
|
|
||||||
|
await all(videoFuts)
|
||||||
|
|
||||||
|
proc getConversationVideos*(convo: Conversation) {.async.} =
|
||||||
|
var token = await getGuestToken()
|
||||||
|
var futs: seq[Future[void]]
|
||||||
|
|
||||||
|
futs.add getVideo(convo.tweet, token)
|
||||||
|
futs.add getVideos(convo.before, token=token)
|
||||||
|
futs.add getVideos(convo.after, token=token)
|
||||||
|
futs.add convo.replies.mapIt(getVideos(it, token=token))
|
||||||
|
|
||||||
|
await all(futs)
|
||||||
|
|
||||||
proc getTimeline*(username: string; after=""): Future[Tweets] {.async.} =
|
proc getTimeline*(username: string; after=""): Future[Tweets] {.async.} =
|
||||||
let headers = newHttpHeaders({
|
let headers = newHttpHeaders({
|
||||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||||
|
@ -78,6 +155,7 @@ proc getTimeline*(username: string; after=""): Future[Tweets] {.async.} =
|
||||||
let html = await fetchHtml(base / url, headers, jsonKey="items_html")
|
let html = await fetchHtml(base / url, headers, jsonKey="items_html")
|
||||||
|
|
||||||
result = parseTweets(html)
|
result = parseTweets(html)
|
||||||
|
await getVideos(result)
|
||||||
|
|
||||||
proc getTweet*(id: string): Future[Conversation] {.async.} =
|
proc getTweet*(id: string): Future[Conversation] {.async.} =
|
||||||
let headers = newHttpHeaders({
|
let headers = newHttpHeaders({
|
||||||
|
@ -96,3 +174,4 @@ proc getTweet*(id: string): Future[Conversation] {.async.} =
|
||||||
html = await fetchHtml(url, headers)
|
html = await fetchHtml(url, headers)
|
||||||
|
|
||||||
result = parseConversation(html)
|
result = parseConversation(html)
|
||||||
|
await getConversationVideos(result)
|
||||||
|
|
|
@ -65,12 +65,6 @@ proc getUserpic*(userpic: string; style=""): string =
|
||||||
proc getUserpic*(profile: Profile; style=""): string =
|
proc getUserpic*(profile: Profile; style=""): string =
|
||||||
getUserPic(profile.userpic, style)
|
getUserPic(profile.userpic, style)
|
||||||
|
|
||||||
proc getGifSrc*(tweet: Tweet): string =
|
|
||||||
fmt"https://video.twimg.com/tweet_video/{tweet.gif.get()}.mp4"
|
|
||||||
|
|
||||||
proc getGifThumb*(tweet: Tweet): string =
|
|
||||||
fmt"https://pbs.twimg.com/tweet_video_thumb/{tweet.gif.get()}.jpg"
|
|
||||||
|
|
||||||
proc formatName*(profile: Profile): string =
|
proc formatName*(profile: Profile): string =
|
||||||
result = xmltree.escape(profile.fullname)
|
result = xmltree.escape(profile.fullname)
|
||||||
if profile.verified:
|
if profile.verified:
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import xmltree, sequtils, strtabs, strutils, strformat
|
import xmltree, sequtils, strtabs, strutils, strformat, json
|
||||||
import nimquery
|
import nimquery
|
||||||
|
|
||||||
import ./types, ./parserutils
|
import ./types, ./parserutils
|
||||||
|
@ -40,7 +40,6 @@ proc parseTweetProfile*(profile: XmlNode): Profile =
|
||||||
|
|
||||||
proc parseQuote*(tweet: XmlNode): Tweet =
|
proc parseQuote*(tweet: XmlNode): Tweet =
|
||||||
let tweet = tweet.querySelector(".QuoteTweet-innerContainer")
|
let tweet = tweet.querySelector(".QuoteTweet-innerContainer")
|
||||||
|
|
||||||
result = Tweet(
|
result = Tweet(
|
||||||
id: tweet.getAttr("data-item-id"),
|
id: tweet.getAttr("data-item-id"),
|
||||||
link: tweet.getAttr("href"),
|
link: tweet.getAttr("href"),
|
||||||
|
@ -77,8 +76,10 @@ proc parseTweets*(node: XmlNode): Tweets =
|
||||||
node.querySelectorAll(".tweet").map(parseTweet)
|
node.querySelectorAll(".tweet").map(parseTweet)
|
||||||
|
|
||||||
proc parseConversation*(node: XmlNode): Conversation =
|
proc parseConversation*(node: XmlNode): Conversation =
|
||||||
result.tweet = parseTweet(node.querySelector(".permalink-tweet-container > .tweet"))
|
result = Conversation(
|
||||||
result.before = parseTweets(node.querySelector(".in-reply-to"))
|
tweet: parseTweet(node.querySelector(".permalink-tweet-container > .tweet")),
|
||||||
|
before: parseTweets(node.querySelector(".in-reply-to"))
|
||||||
|
)
|
||||||
|
|
||||||
let replies = node.querySelector(".replies-to")
|
let replies = node.querySelector(".replies-to")
|
||||||
if replies.isNil: return
|
if replies.isNil: return
|
||||||
|
@ -89,3 +90,14 @@ proc parseConversation*(node: XmlNode): Conversation =
|
||||||
let thread = parseTweets(reply)
|
let thread = parseTweets(reply)
|
||||||
if not thread.anyIt(it in result.after):
|
if not thread.anyIt(it in result.after):
|
||||||
result.replies.add thread
|
result.replies.add thread
|
||||||
|
|
||||||
|
proc parseVideo*(node: JsonNode): Video =
|
||||||
|
let track = node{"track"}
|
||||||
|
result = Video(
|
||||||
|
thumb: node["posterImage"].to(string),
|
||||||
|
id: track["contentId"].to(string),
|
||||||
|
length: track["durationMs"].to(int),
|
||||||
|
views: track["viewCount"].to(string),
|
||||||
|
url: track["playbackUrl"].to(string),
|
||||||
|
available: track{"mediaAvailability"}["status"].to(string) == "available"
|
||||||
|
)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import xmltree, strtabs, times
|
import xmltree, strtabs, strformat, times
|
||||||
import nimquery, regex
|
import nimquery, regex
|
||||||
|
|
||||||
import ./types, ./formatters
|
import ./types, ./formatters, ./api
|
||||||
|
|
||||||
const
|
const
|
||||||
thumbRegex = re".+:url\('([^']+)'\)"
|
thumbRegex = re".+:url\('([^']+)'\)"
|
||||||
|
@ -84,11 +84,10 @@ proc getIntentStats*(profile: var Profile; node: XmlNode) =
|
||||||
of "followers": profile.followers = text
|
of "followers": profile.followers = text
|
||||||
of "following": profile.following = text
|
of "following": profile.following = text
|
||||||
|
|
||||||
proc getTweetStats*(tweet: var Tweet; node: XmlNode) =
|
proc getTweetStats*(tweet: Tweet; node: XmlNode) =
|
||||||
tweet.replies = "0"
|
tweet.replies = "0"
|
||||||
tweet.retweets = "0"
|
tweet.retweets = "0"
|
||||||
tweet.likes = "0"
|
tweet.likes = "0"
|
||||||
|
|
||||||
for action in node.querySelectorAll(".ProfileTweet-actionCountForAria"):
|
for action in node.querySelectorAll(".ProfileTweet-actionCountForAria"):
|
||||||
let text = action.innerText.split()
|
let text = action.innerText.split()
|
||||||
case text[1]
|
case text[1]
|
||||||
|
@ -96,16 +95,22 @@ proc getTweetStats*(tweet: var Tweet; node: XmlNode) =
|
||||||
of "likes": tweet.likes = text[0]
|
of "likes": tweet.likes = text[0]
|
||||||
of "retweets": tweet.retweets = text[0]
|
of "retweets": tweet.retweets = text[0]
|
||||||
|
|
||||||
proc getTweetMedia*(tweet: var Tweet; node: XmlNode) =
|
proc getGif(player: XmlNode): Gif =
|
||||||
|
let
|
||||||
|
thumb = player.getAttr("style").replace(thumbRegex, "$1")
|
||||||
|
id = thumb.replace(gifRegex, "$1")
|
||||||
|
url = fmt"https://video.twimg.com/tweet_video/{id}.mp4"
|
||||||
|
Gif(url: url, thumb: thumb)
|
||||||
|
|
||||||
|
proc getTweetMedia*(tweet: Tweet; node: XmlNode) =
|
||||||
for photo in node.querySelectorAll(".AdaptiveMedia-photoContainer"):
|
for photo in node.querySelectorAll(".AdaptiveMedia-photoContainer"):
|
||||||
tweet.photos.add photo.attrs["data-image-url"]
|
tweet.photos.add photo.attrs["data-image-url"]
|
||||||
|
|
||||||
let player = node.selectAttr(".PlayableMedia-player", "style")
|
let player = node.querySelector(".PlayableMedia")
|
||||||
if player.len == 0:
|
if player.isNil:
|
||||||
return
|
return
|
||||||
|
|
||||||
let thumb = player.replace(thumbRegex, "$1")
|
if "gif" in player.getAttr("class"):
|
||||||
if "tweet_video" in thumb:
|
tweet.gif = some(getGif(player.querySelector(".PlayableMedia-player")))
|
||||||
tweet.gif = some(thumb.replace(gifRegex, "$1"))
|
|
||||||
else:
|
else:
|
||||||
tweet.videoThumb = some(thumb)
|
tweet.video = some(Video())
|
||||||
|
|
|
@ -31,7 +31,26 @@ db("cache.db", "", "", ""):
|
||||||
.}: Time
|
.}: Time
|
||||||
|
|
||||||
type
|
type
|
||||||
Tweet* = object
|
Video* = object
|
||||||
|
id*: string
|
||||||
|
url*: string
|
||||||
|
thumb*: string
|
||||||
|
length*: int
|
||||||
|
views*: string
|
||||||
|
available*: bool
|
||||||
|
|
||||||
|
Gif* = object
|
||||||
|
url*: string
|
||||||
|
thumb*: string
|
||||||
|
|
||||||
|
Quote* = ref object
|
||||||
|
id*: string
|
||||||
|
profile*: Profile
|
||||||
|
link*: string
|
||||||
|
text*: string
|
||||||
|
video*: Option[Video]
|
||||||
|
|
||||||
|
Tweet* = ref object
|
||||||
id*: string
|
id*: string
|
||||||
profile*: Profile
|
profile*: Profile
|
||||||
link*: string
|
link*: string
|
||||||
|
@ -42,16 +61,16 @@ type
|
||||||
retweets*: string
|
retweets*: string
|
||||||
likes*: string
|
likes*: string
|
||||||
pinned*: bool
|
pinned*: bool
|
||||||
photos*: seq[string]
|
quote*: Option[Quote]
|
||||||
retweetBy*: Option[string]
|
retweetBy*: Option[string]
|
||||||
gif*: Option[string]
|
|
||||||
video*: Option[string]
|
|
||||||
videoThumb*: Option[string]
|
|
||||||
retweetId*: Option[string]
|
retweetId*: Option[string]
|
||||||
|
gif*: Option[Gif]
|
||||||
|
video*: Option[Video]
|
||||||
|
photos*: seq[string]
|
||||||
|
|
||||||
Tweets* = seq[Tweet]
|
Tweets* = seq[Tweet]
|
||||||
|
|
||||||
Conversation* = object
|
Conversation* = ref object
|
||||||
tweet*: Tweet
|
tweet*: Tweet
|
||||||
before*: Tweets
|
before*: Tweets
|
||||||
after*: Tweets
|
after*: Tweets
|
||||||
|
|
|
@ -54,11 +54,11 @@
|
||||||
</div>
|
</div>
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderVideo(tweet: Tweet): string =
|
#proc renderVideo(video: Video): string =
|
||||||
<div class="attachments media-body">
|
<div class="attachments media-body">
|
||||||
<div class="gallery-row" style="max-height: unset;">
|
<div class="gallery-row" style="max-height: unset;">
|
||||||
<div class="attachment image">
|
<div class="attachment image">
|
||||||
<video poster=${tweet.videoThumb.get()} style="width: 100%; height: 100%;" autoplay muted loop></video>
|
<video poster=${video.thumb.getSigUrl("pic")} autoplay muted loop></video>
|
||||||
<div class="video-overlay">
|
<div class="video-overlay">
|
||||||
<p>Video playback not supported</p>
|
<p>Video playback not supported</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -67,14 +67,12 @@
|
||||||
</div>
|
</div>
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderGif(tweet: Tweet): string =
|
#proc renderGif(gif: Gif): string =
|
||||||
#let thumbUrl = getGifThumb(tweet).getSigUrl("pic")
|
<div class="attachments media-body" style="display: table-cell;">
|
||||||
#let videoUrl = getGifSrc(tweet).getSigUrl("video")
|
|
||||||
<div class="attachments media-body">
|
|
||||||
<div class="gallery-row" style="max-height: unset;">
|
<div class="gallery-row" style="max-height: unset;">
|
||||||
<div class="attachment image">
|
<div class="attachment image">
|
||||||
<video poster=${thumbUrl} style="width: 100%; height: 100%;" autoplay muted loop>
|
<video class="gif" poster=${gif.thumb.getSigUrl("pic")} autoplay muted loop>
|
||||||
<source src=${videoUrl} type="video/mp4">
|
<source src=${gif.url.getSigUrl("video")} type="video/mp4">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -103,10 +101,10 @@
|
||||||
</div>
|
</div>
|
||||||
#if tweet.photos.len > 0:
|
#if tweet.photos.len > 0:
|
||||||
${renderMediaGroup(tweet)}
|
${renderMediaGroup(tweet)}
|
||||||
#elif tweet.videoThumb.isSome:
|
#elif tweet.video.isSome:
|
||||||
${renderVideo(tweet)}
|
${renderVideo(tweet.video.get())}
|
||||||
#elif tweet.gif.isSome:
|
#elif tweet.gif.isSome:
|
||||||
${renderGif(tweet)}
|
${renderGif(tweet.gif.get())}
|
||||||
#end if
|
#end if
|
||||||
${renderStats(tweet)}
|
${renderStats(tweet)}
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in a new issue