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
Expand Up @@ -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"
Expand Down
26 changes: 26 additions & 0 deletions src/api.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 16 additions & 6 deletions src/apiutils.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
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)
Expand Down Expand Up @@ -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
Expand Up @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Up @@ -2,7 +2,6 @@
import asyncdispatch, strformat, logging
from net import Port
from htmlgen import a
from os import getEnv

import jester

Expand All @@ -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())
Expand Down
27 changes: 27 additions & 0 deletions src/parser.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions src/query.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/routes/rss.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])

Expand Down
2 changes: 1 addition & 1 deletion src/routes/search.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion src/routes/status.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
16 changes: 9 additions & 7 deletions src/routes/timeline.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand All @@ -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]
Expand All @@ -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 =
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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())
Expand Down
1 change: 1 addition & 0 deletions src/sass/tweet/_base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@
padding-top: 5px;
min-width: 1em;
margin-right: 0.8em;
pointer-events: all;
}

.show-thread {
Expand Down
2 changes: 1 addition & 1 deletion src/tokens.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading