Skip to content

Added retweeters and favoriters user lists and turned quote stat into link to quotes #897

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions nitter.example.conf
Original file line number Diff line number Diff line change
@@ -33,6 +33,9 @@ tokenCount = 10
# always at least `tokenCount` usable tokens. only increase this if you receive
# major bursts all the time and don't have a rate limiting setup via e.g. nginx

#cookieHeader = "ct0=XXXXXXXXXXXXXXXXX; auth_token=XXXXXXXXXXXXXX" # authentication cookie of a logged in account, required for the likes tab and NSFW content
#xCsrfToken = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # required for the likes tab and NSFW content

# Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences]
theme = "Nitter"
26 changes: 26 additions & 0 deletions src/api.nim
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
import packedjson
import types, query, formatters, consts, apiutils, parser
import experimental/parser as newParser
import config

proc getGraphUser*(username: string): Future[User] {.async.} =
if username.len == 0: return
@@ -69,6 +70,13 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)

proc getFavorites*(id: string; cfg: Config; after=""): Future[Timeline] {.async.} =
if id.len == 0: return
let
ps = genParams({"userId": id}, after)
url = consts.favorites / (id & ".json") ? ps
result = parseTimeline(await fetch(url, Api.favorites), after)

proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return
let
@@ -86,6 +94,24 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
js = await fetch(graphTweet ? params, Api.tweetDetail)
result = parseGraphConversation(js, id)

proc getGraphFavoriters*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = reactorsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFavoriters ? params, Api.favoriters)
result = parseGraphFavoritersTimeline(js, id)

proc getGraphRetweeters*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = reactorsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphRetweeters ? params, Api.retweeters)
result = parseGraphRetweetersTimeline(js, id)

proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
result = (await getGraphTweet(id, after)).replies
result.beginning = after.len == 0
22 changes: 16 additions & 6 deletions src/apiutils.nim
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import httpclient, asyncdispatch, options, strutils, uri
import jsony, packedjson, zippy
import types, tokens, consts, parserutils, http_pool
import experimental/types/common
import config

const
rlRemaining = "x-rate-limit-remaining"
@@ -50,7 +51,7 @@ template updateToken() =
reset = parseInt(resp.headers[rlReset])
token.setRateLimit(api, remaining, reset)

template fetchImpl(result, fetchBody) {.dirty.} =
template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
once:
pool = HttpPool()

@@ -60,7 +61,10 @@ template fetchImpl(result, fetchBody) {.dirty.} =

try:
var resp: AsyncResponse
pool.use(genHeaders(token)):
var headers = genHeaders(token)
for key, value in additional_headers.pairs():
headers.add(key, value)
pool.use(headers):
template getContent =
resp = await c.get($url)
result = await resp.body
@@ -94,9 +98,15 @@ template fetchImpl(result, fetchBody) {.dirty.} =
release(token, invalid=true)
raise rateLimitError()

proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =

if len(cfg.cookieHeader) != 0:
additional_headers.add("Cookie", cfg.cookieHeader)
if len(cfg.xCsrfToken) != 0:
additional_headers.add("x-csrf-token", cfg.xCsrfToken)

var body: string
fetchImpl body:
fetchImpl(body, additional_headers):
if body.startsWith('{') or body.startsWith('['):
result = parseJson(body)
else:
@@ -111,8 +121,8 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
release(token, invalid=true)
raise rateLimitError()

proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} =
fetchImpl result:
proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
fetchImpl(result, additional_headers):
if not (result.startsWith('{') or result.startsWith('[')):
echo resp.status, ": ", result, " --- url: ", url
result.setLen(0)
9 changes: 8 additions & 1 deletion src/config.nim
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
import parsecfg except Config
import types, strutils
from os import getEnv

proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
let val = config.getSectionValue(section, key)
@@ -40,7 +41,13 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
enableRss: cfg.get("Config", "enableRSS", true),
enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""),
proxyAuth: cfg.get("Config", "proxyAuth", "")
proxyAuth: cfg.get("Config", "proxyAuth", ""),
cookieHeader: cfg.get("Config", "cookieHeader", ""),
xCsrfToken: cfg.get("Config", "xCsrfToken", "")
)

return (conf, cfg)


let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
let (cfg*, fullCfg*) = getConfig(configPath)
11 changes: 11 additions & 0 deletions src/consts.nim
Original file line number Diff line number Diff line change
@@ -8,6 +8,9 @@ const
activate* = $(api / "1.1/guest/activate.json")

photoRail* = api / "1.1/statuses/media_timeline.json"

timelineApi = api / "2/timeline"
favorites* = timelineApi / "favorites"
userSearch* = api / "1.1/users/search.json"

graphql = api / "graphql"
@@ -23,6 +26,8 @@ const
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
graphListTweets* = graphql / "jZntL0oVJSdjhmPcdbw_eA/ListLatestTweetsTimeline"
graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters"
graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters"

timelineParams* = {
"include_profile_interstitial_type": "0",
@@ -119,3 +124,9 @@ const
"withReactionsPerspective": false,
"withVoice": false
}"""

reactorsVariables* = """{
"tweetId" : "$1", $2
"count" : 20,
"includePromotedContent": false
}"""
4 changes: 0 additions & 4 deletions src/nitter.nim
Original file line number Diff line number Diff line change
@@ -2,7 +2,6 @@
import asyncdispatch, strformat, logging
from net import Port
from htmlgen import a
from os import getEnv

import jester

@@ -15,9 +14,6 @@ import routes/[
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues"

let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
let (cfg, fullCfg) = getConfig(configPath)

if not cfg.enableDebug:
# Silence Jester's query warning
addHandler(newConsoleLogger())
27 changes: 27 additions & 0 deletions src/parser.nim
Original file line number Diff line number Diff line change
@@ -493,6 +493,33 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline =
elif entryId.startsWith("cursor-bottom"):
result.bottom = e{"content", "value"}.getStr

proc parseGraphUsersTimeline(js: JsonNode; root: string; key: string; after=""): UsersTimeline =
result = UsersTimeline(beginning: after.len == 0)

let instructions = ? js{"data", key, "timeline", "instructions"}

if instructions.len == 0:
return

for i in instructions:
if i{"type"}.getStr == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e{"entryId"}.getStr
if entryId.startsWith("user"):
with graphUser, e{"content", "itemContent"}:
let user = parseGraphUser(graphUser)
result.content.add user
elif entryId.startsWith("cursor-bottom"):
result.bottom = e{"content", "value"}.getStr
elif entryId.startsWith("cursor-top"):
result.top = e{"content", "value"}.getStr

proc parseGraphFavoritersTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
return parseGraphUsersTimeline(js, root, "favoriters_timeline", after)

proc parseGraphRetweetersTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
return parseGraphUsersTimeline(js, root, "retweeters_timeline", after)

proc parseGraphSearch*(js: JsonNode; after=""): Timeline =
result = Timeline(beginning: after.len == 0)

7 changes: 7 additions & 0 deletions src/query.nim
Original file line number Diff line number Diff line change
@@ -40,6 +40,13 @@ proc getMediaQuery*(name: string): Query =
sep: "OR"
)


proc getFavoritesQuery*(name: string): Query =
Query(
kind: favorites,
fromUser: @[name]
)

proc getReplyQuery*(name: string): Query =
Query(
kind: replies,
5 changes: 3 additions & 2 deletions src/routes/rss.nim
Original file line number Diff line number Diff line change
@@ -23,7 +23,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
names = getNames(name)

if names.len == 1:
profile = await fetchProfile(after, query, skipRail=true, skipPinned=true)
profile = await fetchProfile(after, query, cfg, skipRail=true, skipPinned=true)
else:
var q = query
q.fromUser = names
@@ -104,14 +104,15 @@ proc createRssRouter*(cfg: Config) =
get "/@name/@tab/rss":
cond cfg.enableRss
cond '.' notin @"name"
cond @"tab" in ["with_replies", "media", "search"]
cond @"tab" in ["with_replies", "media", "favorites", "search"]
let
name = @"name"
tab = @"tab"
query =
case tab
of "with_replies": getReplyQuery(name)
of "media": getMediaQuery(name)
of "favorites": getFavoritesQuery(name)
of "search": initQuery(params(request), name=name)
else: Query(fromUser: @[name])

2 changes: 1 addition & 1 deletion src/routes/search.nim
Original file line number Diff line number Diff line change
@@ -37,7 +37,7 @@ proc createSearchRouter*(cfg: Config) =
let
tweets = await getGraphSearch(query, getCursor())
rss = "/search/rss?" & genQueryUrl(query)
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
resp renderMain(renderTweetSearch(tweets, cfg, prefs, getPath()),
request, cfg, prefs, title, rss=rss)
else:
resp Http404, showError("Invalid search", cfg)
25 changes: 24 additions & 1 deletion src/routes/status.nim
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import jester, karax/vdom

import router_utils
import ".."/[types, formatters, api]
import ../views/[general, status]
import ../views/[general, status, timeline, search]

export uri, sequtils, options, sugar
export router_utils
@@ -14,6 +14,29 @@ export status

proc createStatusRouter*(cfg: Config) =
router status:
get "/@name/status/@id/@reactors":
cond '.' notin @"name"
let id = @"id"

if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, showError("Invalid tweet ID", cfg)

let prefs = cookiePrefs()

# used for the infinite scroll feature
if @"scroll".len > 0:
let replies = await getReplies(id, getCursor())
if replies.content.len == 0:
resp Http404, ""
resp $renderReplies(replies, prefs, getPath())

if @"reactors" == "favoriters":
resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
request, cfg, prefs)
elif @"reactors" == "retweeters":
resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
request, cfg, prefs)

get "/@name/status/@id/?":
cond '.' notin @"name"
let id = @"id"
16 changes: 9 additions & 7 deletions src/routes/timeline.nim
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ proc getQuery*(request: Request; tab, name: string): Query =
case tab
of "with_replies": getReplyQuery(name)
of "media": getMediaQuery(name)
of "favorites": getFavoritesQuery(name)
of "search": initQuery(params(request), name=name)
else: Query(fromUser: @[name])

@@ -27,7 +28,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
else:
body

proc fetchProfile*(after: string; query: Query; skipRail=false;
proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
skipPinned=false): Future[Profile] {.async.} =
let
name = query.fromUser[0]
@@ -50,6 +51,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false;
of posts: getGraphUserTweets(userId, TimelineKind.tweets, after)
of replies: getGraphUserTweets(userId, TimelineKind.replies, after)
of media: getGraphUserTweets(userId, TimelineKind.media, after)
of favorites: getFavorites(userId, cfg, after)
else: getGraphSearch(query, after)

rail =
@@ -84,18 +86,18 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
if query.fromUser.len != 1:
let
timeline = await getGraphSearch(query, after)
html = renderTweetSearch(timeline, prefs, getPath())
html = renderTweetSearch(timeline, cfg, prefs, getPath())
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)

var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins)
var profile = await fetchProfile(after, query, cfg, skipPinned=prefs.hidePins)
template u: untyped = profile.user

if u.suspended:
return showError(getSuspended(u.username), cfg)

if profile.user.id.len == 0: return

let pHtml = renderProfile(profile, prefs, getPath())
let pHtml = renderProfile(profile, cfg, prefs, getPath())
result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u),
rss=rss, images = @[u.getUserPic("_400x400")],
banner=u.banner)
@@ -125,7 +127,7 @@ proc createTimelineRouter*(cfg: Config) =
get "/@name/?@tab?/?":
cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
cond @"tab" in ["with_replies", "media", "search", ""]
cond @"tab" in ["with_replies", "media", "search", "favorites", ""]
let
prefs = cookiePrefs()
after = getCursor()
@@ -141,9 +143,9 @@ proc createTimelineRouter*(cfg: Config) =
var timeline = await getGraphSearch(query, after)
if timeline.content.len == 0: resp Http404
timeline.beginning = true
resp $renderTweetSearch(timeline, prefs, getPath())
resp $renderTweetSearch(timeline, cfg, prefs, getPath())
else:
var profile = await fetchProfile(after, query, skipRail=true)
var profile = await fetchProfile(after, query, cfg, skipRail=true)
if profile.tweets.content.len == 0: resp Http404
profile.tweets.beginning = true
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
1 change: 1 addition & 0 deletions src/sass/tweet/_base.scss
Original file line number Diff line number Diff line change
@@ -207,6 +207,7 @@
padding-top: 5px;
min-width: 1em;
margin-right: 0.8em;
pointer-events: all;
}

.show-thread {
2 changes: 1 addition & 1 deletion src/tokens.nim
Original file line number Diff line number Diff line change
@@ -45,7 +45,7 @@ proc getPoolJson*(): JsonNode =
of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets,
Api.userTweets, Api.userTweetsAndReplies, Api.userMedia,
Api.userRestId, Api.userScreenName,
Api.tweetDetail, Api.tweetResult, Api.search: 500
Api.tweetDetail, Api.tweetResult, Api.search, Api.favorites, Api.retweeters, Api.favoriters: 500
of Api.userSearch: 900
reqs = maxReqs - token.apis[api].remaining

9 changes: 8 additions & 1 deletion src/types.nim
Original file line number Diff line number Diff line change
@@ -26,9 +26,12 @@ type
listTweets
userRestId
userScreenName
favorites
userTweets
userTweetsAndReplies
userMedia
favoriters
retweeters

RateLimit* = object
remaining*: int
@@ -106,7 +109,7 @@ type
variants*: seq[VideoVariant]

QueryKind* = enum
posts, replies, media, users, tweets, userList
posts, replies, media, users, tweets, userList, favorites

Query* = object
kind*: QueryKind
@@ -223,6 +226,7 @@ type
replies*: Result[Chain]

Timeline* = Result[Tweet]
UsersTimeline* = Result[User]

Profile* = object
user*: User
@@ -269,6 +273,9 @@ type
redisMaxConns*: int
redisPassword*: string

cookieHeader*: string
xCsrfToken*: string

Rss* = object
feed*, cursor*: string

4 changes: 2 additions & 2 deletions src/views/profile.nim
Original file line number Diff line number Diff line change
@@ -99,7 +99,7 @@ proc renderProtected(username: string): VNode =
h2: text "This account's tweets are protected."
p: text &"Only confirmed followers have access to @{username}'s tweets."

proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: string): VNode =
profile.tweets.query.fromUser = @[profile.user.username]

buildHtml(tdiv(class="profile-tabs")):
@@ -116,4 +116,4 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
if profile.user.protected:
renderProtected(profile.user.username)
else:
renderTweetSearch(profile.tweets, prefs, path, profile.pinned)
renderTweetSearch(profile.tweets, cfg, prefs, path, profile.pinned)
14 changes: 11 additions & 3 deletions src/views/search.nim
Original file line number Diff line number Diff line change
@@ -29,7 +29,7 @@ proc renderSearch*(): VNode =
placeholder="Enter username...", dir="auto")
button(`type`="submit"): icon "search"

proc renderProfileTabs*(query: Query; username: string): VNode =
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =
let link = "/" & username
buildHtml(ul(class="tab")):
li(class=query.getTabClass(posts)):
@@ -38,6 +38,9 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
a(href=(link & "/with_replies")): text "Tweets & Replies"
li(class=query.getTabClass(media)):
a(href=(link & "/media")): text "Media"
if len(cfg.xCsrfToken) != 0 and len(cfg.cookieHeader) != 0:
li(class=query.getTabClass(favorites)):
a(href=(link & "/favorites")): text "Likes"
li(class=query.getTabClass(tweets)):
a(href=(link & "/search")): text "Search"

@@ -88,7 +91,7 @@ proc renderSearchPanel*(query: Query): VNode =
span(class="search-title"): text "Near"
genInput("near", "", query.near, "Location...", autofocus=false)

proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
proc renderTweetSearch*(results: Result[Tweet]; cfg: Config; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode =
let query = results.query
buildHtml(tdiv(class="timeline-container")):
@@ -97,7 +100,7 @@ proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
text query.fromUser.join(" | ")

if query.fromUser.len > 0:
renderProfileTabs(query, query.fromUser.join(","))
renderProfileTabs(query, query.fromUser.join(","), cfg)

if query.fromUser.len == 0 or query.kind == tweets:
tdiv(class="timeline-header"):
@@ -118,3 +121,8 @@ proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =

renderSearchTabs(results.query)
renderTimelineUsers(results, prefs)

proc renderUserList*(results: Result[User]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header")
renderTimelineUsers(results, prefs)
21 changes: 13 additions & 8 deletions src/views/tweet.nim
Original file line number Diff line number Diff line change
@@ -181,14 +181,19 @@ func formatStat(stat: int): string =
if stat > 0: insertSep($stat, ',')
else: ""

proc renderStats(stats: TweetStats; views: string): VNode =
proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
buildHtml(tdiv(class="tweet-stats")):
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
if views.len > 0:
span(class="tweet-stat"): icon "play", insertSep(views, ',')
a(href=getLink(tweet)):
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
a(href=getLink(tweet, false) & "/retweeters"):
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
a(href="/search?q=quoted_tweet_id:" & $tweet.id):
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
a(href=getLink(tweet, false) & "/favoriters"):
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
a(href=getLink(tweet)):
if views.len > 0:
span(class="tweet-stat"): icon "play", insertSep(views, ',')

proc renderReply(tweet: Tweet): VNode =
buildHtml(tdiv(class="replying-to")):
@@ -344,7 +349,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderMediaTags(tweet.mediaTags)

if not prefs.hideTweetStats:
renderStats(tweet.stats, views)
renderStats(tweet.stats, views, tweet)

if showThread:
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):