From cea5cc0523171bc3eb0967ae83ddbb92f74da94b Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 20 Jun 2019 16:16:20 +0200 Subject: [PATCH] Initial commit --- .gitignore | 2 + nitter.nimble | 14 + public/style.css | 561 +++++++++++++++++++++++++++++++++++++ src/api.nim | 105 +++++++ src/cache.nim | 74 +++++ src/formatters.nim | 89 ++++++ src/nitter.nim | 75 +++++ src/parser.nim | 100 +++++++ src/types.nim | 40 +++ src/utils.nim | 23 ++ src/views/conversation.nim | 38 +++ src/views/general.nim | 50 ++++ src/views/tweet.nim | 99 +++++++ src/views/user.nim | 100 +++++++ 14 files changed, 1370 insertions(+) create mode 100644 .gitignore create mode 100644 nitter.nimble create mode 100644 public/style.css create mode 100644 src/api.nim create mode 100644 src/cache.nim create mode 100644 src/formatters.nim create mode 100644 src/nitter.nim create mode 100644 src/parser.nim create mode 100644 src/types.nim create mode 100644 src/utils.nim create mode 100644 src/views/conversation.nim create mode 100644 src/views/general.nim create mode 100644 src/views/tweet.nim create mode 100644 src/views/user.nim diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c7021e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +nitter +*.html \ No newline at end of file diff --git a/nitter.nimble b/nitter.nimble new file mode 100644 index 0000000..6ec42c4 --- /dev/null +++ b/nitter.nimble @@ -0,0 +1,14 @@ +# Package + +version = "0.1.0" +author = "zedeus" +description = "An alternative front-end for Twitter" +license = "AGPL-3.0" +srcDir = "src" +bin = @["nitter"] + + +# Dependencies + +requires "nim >= 0.19.9" +requires "regex", "nimquery", "nimcrypto", "norm", "jester" diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..3881143 --- /dev/null +++ b/public/style.css @@ -0,0 +1,561 @@ +body { + background-color: #121212; + color: #f8f8f2; + margin: 0; + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; +} + +#tweets { + background-color: #161616; +} + +#user { + background-color: #242424; + width: 100%; + padding: 5pt; +} + +#user > h1 { + color: #ffffff; +} + +h1 { + margin: 0; + display: inline; +} + +h2 { + margin: 0; + font-weight: normal; +} + +h3 { + margin-bottom: 0; + font-weight: normal; +} + +h4 { + margin: 0; +} + +a { + color: #ff6c60; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.status-el { + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + border-left-width: 0; + min-width: 0; + padding: .75em; + display: flex; +} + +.timeline-tweet { + border-bottom: 1px solid #3e3e35; +} + +.status-body { + flex: 1; + min-width: 0; +} + +.media-heading a { + display: inline-block; + word-break: break-all; +} + +.status-el .media-heading { + padding: 0; + vertical-align: bottom; + flex-basis: 100%; + margin-bottom: .2em; +} + +.status-el .media-heading .heading-name-row { + padding: 0; + display: flex; + justify-content: space-between; + line-height: 18px; +} + +.status-el .media-heading .heading-name-row .name-and-account-name { + display: flex; + min-width: 0; +} + +.status-el .media-heading .heading-name-row .username { + flex-shrink: 1; + margin-right: .4em; + overflow: hidden; + text-overflow: ellipsis; +} + +.status-el .username { + white-space: nowrap; + font-size: 14px; + overflow: hidden; + flex-shrink: 0; + font-weight: 700; +} + +.username { + color: #f8f8f2; +} + +.status-el .media-heading .heading-right { + display: flex; + flex-shrink: 0; +} + +.status-el .media-heading .heading-name-row .account-name { + min-width: 1.6em; + margin-right: .4em; + flex: 1 1 0; + white-space: nowrap; + word-wrap: normal; + overflow: hidden; + text-overflow: ellipsis; +} + +.status-el .media-heading a { + display: inline-block; + word-break: break-all; +} + +.status-el .status-content { + font-family: sans-serif; + line-height: 1.4em; +} + +.status-el .media-body { + flex: 1; + padding: 0; +} + +.container, .item { + display: flex; +} + +.container { + flex-wrap: wrap; + margin: 0; +} + +#content { + box-sizing: border-box; + padding-top: 50px; + margin: auto; + min-height: 100vh; + background-color: rgba(0,0,0,.15); +} + +nav { + z-index: 1000; + background-color: #1f1f1f; + color: hsla(240,1%,73%,.5); + box-shadow: 0 0 4px rgba(0,0,0,.6); +} + +.nav-bar { + padding: 0; + width: 100%; + align-items: center; + position: fixed; + height: 50px; +} + +.nav-bar .inner-nav { + margin: auto; + box-sizing: border-box; + padding-left: 10px; + padding-right: 10px; + display: flex; + align-items: center; + height: 50px; +} + +.item { + flex: 1; + line-height: 50px; + height: 50px; + overflow: hidden; + flex-wrap: wrap; +} + +.attachments { + margin-top: .5em; + display: flex; + flex-direction: row; + width: 100%; + max-height: 600px; + border-radius: 7px; + overflow: hidden; + flex-flow: column; +} + +.gallery-row { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + overflow: hidden; + flex-grow: 1; + max-height: 379.5px; + max-width: 506px; +} + +.gallery-row .attachment, .gallery-row .attachments { + margin: 0 .25em 0 0; + flex-grow: 1; + box-sizing: border-box; + min-width: 2em; +} + +.gallery-row .attachment:last-child, .gallery-row .attachments:last-child { + margin: 0; +} + +.attachments .attachment { + position: relative; + line-height: 0; + border-color: #222; + overflow: hidden; +} + +.gallery-row .image-attachment { + width: 100%; +} + +.attachments .image-attachment { + width: 100%; +} + +.still-image { + max-height: 379.5px; + max-width: 506px; + justify-content: center; +} + +.still-image img { + object-fit: cover; + max-width: 506px; + max-height: 379.5px; + border-color: #222; + flex-basis: 300px; +} + +.status-body { + margin-left: 58px; +} + +.avatar { + float: left; + margin-top: 3px; + margin-left: -58px; + position: absolute; + width: 48px; + height: 48px; + border-radius: 50%; +} + +.retweet, .pinned, .tweet-stats { + align-content: center; + color: hsla(240,1%,73%,.7); + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + font-size: 14px; + font-weight: 600; + line-height: 22px; + margin: 0; + max-width: 85%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tweet-stat { + padding-top: 5px; + padding-right: 8px; +} + +.show-more { + text-align: center; + padding: .75em 0; + display: block; +} + +.show-more.status-el { + border-bottom: 1px solid #3e3e35; +} + +.show-more a { + background-color: #242424; + display: inline-block; + height: 2em; + padding: 0 2em; + line-height: 2em; +} +.show-more a:hover { + background-color: #282828; +} + +.profile-tabs { + max-width: 846px; + margin: 0 auto; + float: none; + border-radius: 0; + position: relative; + width: auto; +} + +.timeline-tab { + float: right; + padding: 0 4px; + box-sizing: border-box; + display: inline-block; + font-size: 14px; + margin: 0; + text-align: left; + vertical-align: top; + width: 70% !important; +} + +.profile-tab { + padding: 0 4px; + box-sizing: border-box; + display: inline-block; + font-size: 14px; + margin: 0; + text-align: left; + vertical-align: top; + width: 30% !important; +} + +.profile-banner { + padding: 0 4px; +} + +.profile-banner img { + width: 100%; + padding-bottom: 3px; +} + +.profile-banner-color { + width: 100%; + padding-bottom: 25%; + margin-bottom: 8px; +} + +.profile-card { + float: left; + flex-wrap: wrap; + margin-top: 0; + background: #161616; + border-radius: 0 0 4px 4px; + padding: 12px; + position: relative; + display: flex; + justify-content: flex-start; +} + +.profile-card-tabs { + display: flex; + justify-content: space-between; + align-items: center; + flex: 1 1 auto; +} + +.profile-card-tabs-name { + padding-top: 0; + padding-bottom: 0; +} + +.profile-card-name, .profile-card-username { + color: #f8f8f2; +} + +.profile-card-name { + font-size: 16px; + word-wrap: break-word; + text-shadow: none; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.profile-card-username { + font-size: 14px; +} + +.profile-card-avatar { + display: block; + width: 100%; + padding-bottom: 16px; + margin-right: 4px; +} + +.profile-card-avatar img { + display: block; + width: calc(100% - 4px); + height: 100%; + margin: 0; + border: 4px solid #282828; + background: #040404; +} + +.profile-card-extra { + display: block; + flex: 100%; + margin-top: 4px; + } + +.profile-bio { + border-radius: 0; + box-shadow: none; + background: transparent; + overflow: hidden; + margin-right: -5px +} + +.profile-description { + font-size: 14px; + font-weight: 400; + overflow: hidden; + word-break: normal; + word-wrap: break-word; +} + +.conversation { + max-width: 580px; + margin: 0 auto; + float: none; + border-radius: 0; + position: relative; + width: auto; + background-color: #0f0f0f !important; +} + +.main-thread { + margin-bottom: 20px; + background-color: #161616; +} + +.main-tweet .status-content { + font-size: 22px; + line-height: 30px; + letter-spacing: .01em; +} + +.thread { + background-color: #161616; + margin-bottom: 10px; +} + +.panel { + margin: auto; + font-size: 130%; +} + +.error-panel { + background-color: #420a05 !important; +} + +.error-panel, .search-panel > form { + padding: 12px; + border-radius: 4px; + display: flex; + background: #222222; + box-shadow: 0 0 15px rgba(0,0,0,.2); + margin: auto; + margin-top: -50px; +} + +.search-panel > form > button { + font-size: 14px; + line-height: 24px; + display: block; + border: 0; + border-radius: 4px; + background: #2f2f2f; + color: #f8f8f2; + outline: 0; + text-decoration: none; + text-align: center; + box-sizing: border-box; + cursor: pointer; + font-weight: bold; + width: 37px; + height: 32px; + padding: 0; +} + +.search-panel > form > input { + box-sizing: border-box; + font-size: 16px; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + background: #131419; + color: #f8f8f2; + border: 1px solid #0a0b0e; + border-radius: 4px; + padding: 4px; + margin-right: 8px; +} + +.profile-card-extra-links { + margin-top: 8px; + font-size: 14px; +} + +.profile-statlist { + vertical-align: bottom; + table-layout: fixed; + box-sizing: border-box; + display: flex; + margin: 0; + padding: 0; + width: 100%; +} + +.profile-statlist > li { + display: table-cell; + text-align: center; +} + +.profile-statlist .tweets { + flex-shrink: 2; +} + +.profile-statlist .followers { + flex-grow: 2; +} + +.profile-statlist .following { + flex-shrink: 1.5; +} + +.profile-stat-header { + font-weight: bold; +} + +.timeline-protected { + max-width: 550px; + margin: 0 auto; + padding: 6px 0px; +} + +.timeline-protected-header { + color: #d0564c; + font-size: 21px; + font-weight: bold; +} diff --git a/src/api.nim b/src/api.nim new file mode 100644 index 0000000..4aa3851 --- /dev/null +++ b/src/api.nim @@ -0,0 +1,105 @@ +import httpclient, asyncdispatch, htmlparser, times +import sequtils, strutils, strformat, json, xmltree, uri +import nimquery, regex + +import ./types, ./parser + +const base = parseUri("https://twitter.com/") +const agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36" + +const timelineUrl = "i/profiles/show/$1/timeline/tweets?include_available_features=1&include_entities=1&include_new_items_bar=true" +const profileUrl = "i/profiles/popup" +const tweetUrl = "i/status/" + +proc getProfile*(username: string): Future[Profile] {.async.} = + let client = newAsyncHttpClient() + defer: client.close() + + client.headers = newHttpHeaders({ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9", + "Referer": $(base / username), + "User-Agent": agent, + "X-Twitter-Active-User": "yes", + "X-Requested-With": "XMLHttpRequest", + "Accept-Language": "en-US,en;q=0.9" + }) + + let params = { + "screen_name": username, + "wants_hovercard": "true", + "_": $(epochTime().int) + } + + let url = base / profileUrl ? params + var resp = "" + + try: + resp = await client.getContent($url) + except: + return Profile() + + let + json = parseJson(resp)["html"].str + html = parseHtml(json) + + result = parseProfile(html) + +proc getTimeline*(username: string; after=""): Future[Tweets] {.async.} = + let client = newAsyncHttpClient() + defer: client.close() + + client.headers = newHttpHeaders({ + "Accept": "application/json, text/javascript, */*; q=0.01", + "Referer": $(base / username), + "User-Agent": agent, + "X-Twitter-Active-User": "yes", + "X-Requested-With": "XMLHttpRequest", + "Accept-Language": "en-US,en;q=0.9" + }) + + var url = timelineUrl % username + if after != "": + url &= "&max_position=" & after + + var resp = "" + try: + resp = await client.getContent($(base / url)) + except: + return + + var json: string = "" + var html: XmlNode + json = parseJson(resp)["items_html"].str + html = parseHtml(json) + + writeFile("epic.html", $html) + + result = parseTweets(html) + +proc getTweet*(id: string): Future[Conversation] {.async.} = + let client = newAsyncHttpClient() + defer: client.close() + + client.headers = newHttpHeaders({ + "Accept": "application/json, text/javascript, */*; q=0.01", + "Referer": $base, + "User-Agent": agent, + "X-Twitter-Active-User": "yes", + "X-Requested-With": "XMLHttpRequest", + "Accept-Language": "en-US,en;q=0.9", + "pragma": "no-cache", + "x-previous-page-name": "profile" + }) + + let url = base / tweetUrl / id + + var resp: string = "" + try: + resp = await client.getContent($url) + except: + return Conversation() + + var html: XmlNode + html = parseHtml(resp) + + result = parseConversation(html) diff --git a/src/cache.nim b/src/cache.nim new file mode 100644 index 0000000..8ac3ada --- /dev/null +++ b/src/cache.nim @@ -0,0 +1,74 @@ +import sharedtables, times, hashes +import types, api + +# var +# profileCache: SharedTable[int, Profile] +# profileCacheTime = initDuration(seconds=10) + +# profileCache.init() + +proc getCachedProfile*(username: string; force=false): Profile = + return getProfile(username) + # let index = username.hash + + # try: + # result = profileCache.mget(index) + # # if force or getTime() - result.lastUpdated > profileCacheTime: + # # result = getProfile(username) + # # profileCache[username.hash] = deepCopy(result) + # # return + # except KeyError: + # # result = getProfile(username) + # # profileCache.add(username.hash, deepCopy(result)) + + + + # var profile: Profile + # profileCache.withKey(index) do (k: int, v: var Profile, pairExists: var bool): + # v = getProfile(username) + # profile = v + # echo v + # pairExists = true + # echo profile.username + # return profile + + # profileCache.withValue(hash(username), value) do: + # if getTime() - value.lastUpdated > profileCacheTime or force: + # result = getProfile(username) + # value = result + # else: + # result = value + # do: + # result = getProfile(username) + # value = result + + # var profile: Profile + + # profileCache.withKey(username.hash) do (k: int, v: var Profile, pairExists: var bool): + # if pairExists and getTime() - v.lastUpdated < profileCacheTime and not force: + # profile = deepCopy(v) + # echo "cached" + # else: + # profile = getProfile(username) + # v = deepCopy(profile) + # pairExists = true + # echo "fetched" + + # return profile + + # try: + # result = profileCache.mget(username.hash) + # if force or getTime() - result.lastUpdated > profileCacheTime: + # result = getProfile(username) + # profileCache[username.hash] = deepCopy(result) + # return + # except KeyError: + # result = getProfile(username) + # profileCache.add(username.hash, deepCopy(result)) + + # if not result.isNil or force or + # getTime() - result.lastUpdated > profileCacheTime: + # result = getProfile(username) + # profileCache[username] = result + # return + diff --git a/src/formatters.nim b/src/formatters.nim new file mode 100644 index 0000000..0cd05b4 --- /dev/null +++ b/src/formatters.nim @@ -0,0 +1,89 @@ +import strutils, strformat, htmlgen, xmltree +import regex + +import ./types, ./utils + +const + urlRegex = re"((https?|ftp)://(-\.)?([^\s/?\.#]+\.?)+(/[^\s]*)?)" + emailRegex = re"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)" + usernameRegex = re"(^|[^\S\n]|\.)@([A-z0-9_]+)" + picRegex = re"pic.twitter.com/[^ ]+" + cardRegex = re"(https?://)?cards.twitter.com/[^ ]+" + ellipsisRegex = re" ?…" + +proc shortLink*(text: string; length=28): string = + result = text.replace(re"https?://(www.)?", "") + if result.len > length: + result = result[0 ..< length] & "…" + +proc toLink*(url, text: string; class="timeline-link"): string = + htmlgen.a(text, class=class, href=url) + +proc reUrlToLink*(m: RegexMatch; s: string): string = + let url = s[m.group(0)[0]] + toLink(url, " " & shortLink(url)) + +proc reEmailToLink*(m: RegexMatch; s: string): string = + let url = s[m.group(0)[0]] + toLink("mailto://" & url, url) + +proc reUsernameToLink*(m: RegexMatch; s: string): string = + var + username = "" + pretext = "" + + let + pre = m.group(0) + match = m.group(1) + + username = s[match[0]] + + if pre.len > 0: + pretext = s[pre[0]] + + pretext & toLink("/" & username, "@" & username) + +proc linkifyText*(text: string): string = + result = text.replace("\n", "
") + result = result.replace(ellipsisRegex, "") + result = result.replace(usernameRegex, reUsernameToLink) + result = result.replace(emailRegex, reEmailToLink) + result = result.replace(urlRegex, reUrlToLink) + +proc stripTwitterUrls*(text: string): string = + result = text + result = result.replace(picRegex, "") + result = result.replace(cardRegex, "") + result = result.replace(ellipsisRegex, "") + +proc getUserpic*(userpic: string; style=""): string = + let pic = userpic.replace(re"_(normal|bigger|mini|200x200)(\.[A-z]+)$", "$2") + pic.replace(re"(\.[A-z]+)$", style & "$1") + +proc getUserpic*(profile: Profile; style=""): string = + getUserPic(profile.userpic, style) + +proc getGifSrc*(tweet: Tweet): string = + fmt"https://video.twimg.com/tweet_video/{tweet.gif}.mp4" + +proc getGifThumb*(tweet: Tweet): string = + fmt"https://pbs.twimg.com/tweet_video_thumb/{tweet.gif}.jpg" + +proc formatName(profile: Profile): string = + result = profile.fullname + if profile.verified: + result &= " 🔹" + elif profile.protected: + result &= " 🔒" + result = xmltree.escape(result) + +proc linkUser*(profile: Profile; h: string; username=true; class=""): string = + let text = + if username: "@" & profile.username + else: formatName(profile) + + if h == "": + return htmlgen.a(text, href = &"/{profile.username}", class=class) + + let element = &"<{h} class=\"{class}\">{text}" + htmlgen.a(element, href = &"/{profile.username}") diff --git a/src/nitter.nim b/src/nitter.nim new file mode 100644 index 0000000..4fef4e0 --- /dev/null +++ b/src/nitter.nim @@ -0,0 +1,75 @@ +import asyncdispatch, httpclient, times, strutils, hashes, random, uri +import jester, regex + +import api, utils, types +import views/[user, general, conversation] + +proc showTimeline(name: string; num=""): Future[string] {.async.} = + let + username = name.strip(chars={'/'}) + profileFut = getProfile(username) + tweetsFut = getTimeline(username, after=num) + + let profile = await profileFut + if profile.username == "": + return "" + + return renderMain(renderProfile(profile, await tweetsFut, num == "")) + +routes: + get "/": + resp renderMain(renderSearchPanel()) + + post "/search": + if @"query".len == 0: + resp Http404, showError("Please enter a username.") + + redirect("/" & @"query") + + get "/@name/?": + cond '.' notin @"name" + let timeline = await showTimeline(@"name", @"after") + if timeline == "": + resp Http404, showError("User \"" & @"name" & "\" not found") + + resp timeline + + get "/@name/status/@id": + cond '.' notin @"name" + let conversation = await getTweet(@"id") + if conversation.tweet.id == "": + resp Http404, showError("Tweet not found") + + resp renderMain(renderConversation(conversation)) + + get "/pic/@sig/@url": + cond "http" in @"url" + cond "twimg" in @"url" + let url = decodeUrl(@"url") + + if getHmac(url) != @"sig": + resp showError("Failed to verify signature") + + let + client = newAsyncHttpClient() + pic = await client.getContent(url) + + defer: client.close() + resp pic, mimetype(url) + + get "/video/@sig/@url": + cond "http" in @"url" + cond "video.twimg" in @"url" + let url = decodeUrl(@"url") + + if getHmac(url) != @"sig": + resp showError("Failed to verify signature") + + let + client = newAsyncHttpClient() + pic = await client.getContent(url) + + defer: client.close() + resp pic, mimetype(url) + +runForever() diff --git a/src/parser.nim b/src/parser.nim new file mode 100644 index 0000000..3a732e7 --- /dev/null +++ b/src/parser.nim @@ -0,0 +1,100 @@ +import xmltree, sequtils, strtabs, strutils, strformat, json, times +import nimquery, regex + +import ./types, ./formatters + +proc getAttr(node: XmlNode; attr: string; default=""): string = + if node.isNIl or node.attrs.isNil: return default + return node.attrs.getOrDefault(attr) + +proc selectAttr(node: XmlNode; selector: string; attr: string; default=""): string = + let res = node.querySelector(selector) + return res.getAttr(attr, default) + +proc selectText(node: XmlNode; selector: string): string = + let res = node.querySelector(selector) + result = if res == nil: "" else: res.innerText() + +proc parseProfile*(node: XmlNode): Profile = + let profile = node.querySelector(".profile-card") + result.fullname = profile.selectText(".fullname") + result.username = profile.selectText(".username").strip(chars={'@', ' '}) + result.description = profile.selectText(".bio") + result.verified = profile.selectText("li.verified").len > 0 + result.protected = profile.selectText(".Icon.Icon--protected").len > 0 + result.userpic = profile.selectAttr(".ProfileCard-avatarImage", "src").getUserpic() + result.banner = profile.selectAttr("svg > image", "xlink:href").replace("600x200", "1500x500") + if result.banner == "": + result.banner = profile.selectAttr(".ProfileCard-bg", "style") + + let stats = profile.querySelectorAll(".ProfileCardStats-statLink") + for s in stats: + let text = s.getAttr("title").split(" ")[0] + case s.getAttr("href").split("/")[^1] + of "followers": result.followers = text + of "following": result.following = text + else: result.tweets = text + +proc parseTweetProfile*(tweet: XmlNode): Profile = + result = Profile( + fullname: tweet.getAttr("data-name"), + username: tweet.getAttr("data-screen-name"), + userpic: tweet.selectAttr(".avatar", "src").getUserpic(), + verified: tweet.selectText(".Icon.Icon--verified").len > 0 + ) + +proc parseTweet*(tweet: XmlNode): Tweet = + result.id = tweet.getAttr("data-item-id") + result.link = tweet.getAttr("data-permalink-path") + result.text = tweet.selectText(".tweet-text").stripTwitterUrls() + result.retweetBy = tweet.selectText(".js-retweet-text > a > b") + result.pinned = "pinned" in tweet.getAttr("class") + result.profile = parseTweetProfile(tweet) + + let time = tweet.querySelector(".js-short-timestamp") + result.time = fromUnix(parseInt(time.getAttr("data-time", "0"))) + result.shortTime = time.innerText() + + result.replies = "0" + result.likes = "0" + result.retweets = "0" + + for action in tweet.querySelectorAll(".ProfileTweet-actionCountForAria"): + let + text = action.innerText.split() + num = text[0] + act = text[1] + + case act + of "replies": result.replies = num + of "likes": result.likes = num + of "retweets": result.retweets = num + else: discard + + for photo in tweet.querySelectorAll(".AdaptiveMedia-photoContainer"): + result.photos.add photo.attrs["data-image-url"] + + let gif = tweet.selectAttr(".PlayableMedia-player", "style") + if gif != "": + result.gif = gif.replace(re".+thumb/([^\.']+)\.jpg.+", "$1") + +proc parseTweets*(node: XmlNode): Tweets = + if node.isNil: return + node.querySelectorAll(".tweet").map(parseTweet) + +template selectTweets*(node: XmlNode; class: string): untyped = + parseTweets(node.querySelector(class)) + +proc parseConversation*(node: XmlNode): Conversation = + result.tweet = parseTweet(node.querySelector(".permalink-tweet-container > .tweet")) + result.before = node.selectTweets(".in-reply-to") + + let replies = node.querySelector(".replies-to") + if replies.isNil: return + + result.after = replies.selectTweets(".ThreadedConversation--selfThread") + + for reply in replies.querySelectorAll("li > .stream-items"): + let thread = parseTweets(reply) + if not thread.anyIt(it in result.after): + result.replies.add thread diff --git a/src/types.nim b/src/types.nim new file mode 100644 index 0000000..75597d1 --- /dev/null +++ b/src/types.nim @@ -0,0 +1,40 @@ +import times, sequtils + +type + Profile* = object + username*: string + fullname*: string + description*: string + userpic*: string + banner*: string + following*: string + followers*: string + tweets*: string + verified*: bool + protected*: bool + + Tweet* = object + id*: string + profile*: Profile + link*: string + text*: string + time*: Time + shortTime*: string + replies*: string + retweets*: string + likes*: string + retweetBy*: string + pinned*: bool + photos*: seq[string] + gif*: string + + Tweets* = seq[Tweet] + + Conversation* = object + tweet*: Tweet + before*: Tweets + after*: Tweets + replies*: seq[Tweets] + +proc contains*(thread: Tweets; tweet: Tweet): bool = + thread.anyIt(it.id == tweet.id) diff --git a/src/utils.nim b/src/utils.nim new file mode 100644 index 0000000..13f2b60 --- /dev/null +++ b/src/utils.nim @@ -0,0 +1,23 @@ +import strutils, strformat, uri +import nimcrypto + +const key = "supersecretkey" + +proc mimetype*(filename: string): string = + if ".png" in filename: + return "image/" & "png" + elif ".jpg" in filename or ".jpeg" in filename: + return "image/" & "jpg" + elif ".mp4" in filename: + return "video/" & "mp4" + else: + return "text/plain" + +proc getHmac*(data: string): string = + ($hmac(sha256, key, data))[0 .. 12] + +proc getSigUrl*(link: string; path: string): string = + let + sig = getHmac(link) + url = encodeUrl(link) + &"/{path}/{sig}/{url}" diff --git a/src/views/conversation.nim b/src/views/conversation.nim new file mode 100644 index 0000000..aa4ec27 --- /dev/null +++ b/src/views/conversation.nim @@ -0,0 +1,38 @@ +#? stdtmpl(subsChar = '$', metaChar = '#') +#import xmltree, strutils, uri +#import ../types, ../formatters, ./tweet +# +#proc renderConversation*(conversation: Conversation): string = +
+
+ #if conversation.before.len > 0: +
+ #for tweet in conversation.before: + ${renderTweet(tweet)} + #end for +
+ #end if +
+ ${renderTweet(conversation.tweet)} +
+ #if conversation.after.len > 0: +
+ #for tweet in conversation.after: + ${renderTweet(tweet)} + #end for +
+ #end if +
+ #if conversation.replies.len > 0: +
+ #for thread in conversation.replies: +
+ #for tweet in thread: + ${renderTweet(tweet)} + #end for +
+ #end for +
+ #end if +
+#end proc diff --git a/src/views/general.nim b/src/views/general.nim new file mode 100644 index 0000000..9234d6e --- /dev/null +++ b/src/views/general.nim @@ -0,0 +1,50 @@ +#? stdtmpl(subsChar = '$', metaChar = '#') +#import user +#import xmltree +# +#proc renderMain*(body: string): string = + + + + Nitter + + + + + + +
+ ${body} +
+ + +#end proc +# +#proc renderSearchPanel*(): string = +
+
+
+ + +
+
+
+#end proc +# +#proc renderError*(error: string): string = +
+
+ ${error} +
+
+#end proc +# +#proc showError*(error: string): string = +${renderMain(renderError(error))} +#end proc diff --git a/src/views/tweet.nim b/src/views/tweet.nim new file mode 100644 index 0000000..99ffe74 --- /dev/null +++ b/src/views/tweet.nim @@ -0,0 +1,99 @@ +#? stdtmpl(subsChar = '$', metaChar = '#') +#import xmltree, strutils, times, sequtils, uri +#import ../types, ../formatters, ../utils +# +#proc renderHeading(tweet: Tweet): string = +#if tweet.retweetBy != "": +
+ 🔄 ${tweet.retweetBy} retweeted +
+#end if +#if tweet.pinned: +
+ 📌 Pinned Tweet +
+#end if +
+
+ + + + + + + +
+
+#end proc +# +#proc renderMediaGroup(tweet: Tweet): string = +#let groups = if tweet.photos.len > 2: tweet.photos.distribute(2) else: @[tweet.photos] +#let groupStyle = if groups.len == 1 and groups[0].len < 2: "" else: "background-color: #0f0f0f;" +#var first = true +
+#for photos in groups: + #let style = if first: "" else: "margin-top: .25em;" + + #first = false +#end for +
+#end proc +# +#proc renderGif(tweet: Tweet): string = +#let thumbUrl = getGifThumb(tweet).getSigUrl("pic") +#let videoUrl = getGifSrc(tweet).getSigUrl("video") +
+ +
+#end proc +# +#proc renderStats(tweet: Tweet): string = +
+ 💬 ${$tweet.replies} + 🔄 ${$tweet.retweets} + 👍 ${$tweet.likes} +
+#end proc +# +#proc renderTweet*(tweet: Tweet; class=""): string = +
+
+
+ ${renderHeading(tweet)} +
+
+ ${linkifyText(tweet.text)} +
+
+ #if tweet.photos.len > 0: + ${renderMediaGroup(tweet)} + #elif tweet.gif.len > 0: + ${renderGif(tweet)} + #end if + ${renderStats(tweet)} +
+
+
+#end proc diff --git a/src/views/user.nim b/src/views/user.nim new file mode 100644 index 0000000..39ede0c --- /dev/null +++ b/src/views/user.nim @@ -0,0 +1,100 @@ +#? stdtmpl(subsChar = '$', metaChar = '#') +#import xmltree, strutils, uri, htmlgen +#import ../types, ../formatters, ../utils +#import ./tweet +# +#proc renderProfileCard*(profile: Profile): string = +#let pic = profile.getUserpic().getSigUrl("pic") +#let smallPic = profile.getUserpic("_200x200").getSigUrl("pic") +
+ + + +
+
+ ${linkUser(profile, "h1", class="profile-card-name", username=false)} + ${linkUser(profile, "h2", class="profile-card-username")} +
+
+
+
+ #if profile.description.len > 0: +
+

${linkifyText(xmltree.escape(profile.description))}

+
+ #end if +
+ + +
+
+#end proc +# +#proc renderBanner(profile: Profile): string = +#if "#" in profile.banner: +
+#else: +#let url = getSigUrl(profile.banner, "pic") + + + +#end if +#end proc +# +#proc renderTimeline*(tweets: Tweets; profile: Profile; beginning: bool): string = +
+ #if profile.protected: +
+

This account's Tweets are protected.

+

Only confirmed followers have access to @${profile.username}'s Tweets. +

+ #end if + #if not beginning: + + #end if + #var retweets: Tweets + #for tweet in tweets: + #if tweet in retweets: continue + #end if + #if tweet.retweetBy.len > 0: retweets.add tweet + #end if + ${renderTweet(tweet, "timeline-tweet")} + #end for + #if tweets.len > 0: + + #end if +
+#end proc +# +#proc renderProfile*(profile: Profile; tweets: Tweets; beginning: bool): string = +
+
+ ${renderBanner(profile)} +
+
+ ${renderProfileCard(profile)} +
+
+ ${renderTimeline(tweets, profile, beginning)} +
+
+#end proc