1
0
mirror of https://github.com/zedeus/nitter.git synced 2026-01-31 07:42:51 -05:00

12 Commits

Author SHA1 Message Date
cmj
a45227b883 Add user-agent to guest_token request (#1359) 2026-01-29 17:27:41 +01:00
yav
a92e79ebc3 Fix the checkmark position (#1347)
Co-authored-by: yav <796176@protonmail.com>
2025-12-24 02:22:20 -05:00
jackyzy823
baeaf685d3 Make maxConcurrentReqs configurable (#1341) 2025-12-08 04:05:08 -05:00
Zed
51b54852dc Add preliminary support for nitter-proxy 2025-12-06 05:15:01 +01:00
Zed
663f5a52e1 Improve headers 2025-12-06 05:00:34 +01:00
Zed
17fc2628f9 Minor fix 2025-11-30 18:07:27 +01:00
Zed
e741385828 Allow , in username to support multiple users
Fixes #1329
2025-11-30 18:06:22 +01:00
Zed
693a189462 Add heuristics to detect when to show "Load more"
Fixes #1328
2025-11-30 05:43:17 +01:00
Zed
7734d976f7 Add username validation
Fixes #1317
2025-11-30 04:12:38 +01:00
Zed
a62ec9cbb4 Normalize headers 2025-11-30 03:58:43 +01:00
Zed
4b9aec6fde Use graphTweet for cookie sessions for now 2025-11-30 02:57:34 +01:00
Zed
064ec88080 Transition to ID-only RSS GUIDs on Dec 14, 2025
Fixes #447
2025-11-30 02:56:19 +01:00
17 changed files with 92 additions and 25 deletions

View File

@@ -26,7 +26,9 @@ enableRSS = true # set this to false to disable RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.sessions) enableDebug = false # enable request logs and debug endpoints (/.sessions)
proxy = "" # http/https url, SOCKS proxies are not supported proxy = "" # http/https url, SOCKS proxies are not supported
proxyAuth = "" proxyAuth = ""
apiProxy = "" # nitter-proxy host, e.g. localhost:7000
disableTid = false # enable this if cookie-based auth is failing disableTid = false # enable this if cookie-based auth is failing
maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions
# Change default preferences here, see src/prefs_impl.nim for a complete list # Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences] [Preferences]

View File

@@ -42,7 +42,8 @@ proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
proc tweetDetailUrl(id: string; cursor: string): ApiReq = proc tweetDetailUrl(id: string; cursor: string): ApiReq =
let cookieVars = tweetDetailVars % [id, cursor] let cookieVars = tweetDetailVars % [id, cursor]
result = ApiReq( result = ApiReq(
cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
cookie: apiUrl(graphTweet, tweetVars % [id, cursor]),
oauth: apiUrl(graphTweet, tweetVars % [id, cursor]) oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
) )

View File

@@ -13,10 +13,17 @@ const
var var
pool: HttpPool pool: HttpPool
disableTid: bool disableTid: bool
apiProxy: string
proc setDisableTid*(disable: bool) = proc setDisableTid*(disable: bool) =
disableTid = disable disableTid = disable
proc setApiProxy*(url: string) =
if url.len > 0:
apiProxy = url.strip(chars={'/'}) & "/"
if "http" notin apiProxy:
apiProxy = "http://" & apiProxy
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri = proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
case sessionKind case sessionKind
of oauth: of oauth:
@@ -48,26 +55,31 @@ proc getCookieHeader(authToken, ct0: string): string =
proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
result = newHttpHeaders({ result = newHttpHeaders({
"accept": "*/*",
"accept-encoding": "gzip",
"accept-language": "en-US,en;q=0.9",
"connection": "keep-alive", "connection": "keep-alive",
"content-type": "application/json", "content-type": "application/json",
"origin": "https://x.com",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
"x-twitter-active-user": "yes", "x-twitter-active-user": "yes",
"x-twitter-client-language": "en", "x-twitter-client-language": "en",
"origin": "https://x.com", "priority": "u=1, i"
"accept-encoding": "gzip",
"accept-language": "en-US,en;q=0.5",
"accept": "*/*",
"DNT": "1",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
}) })
case session.kind case session.kind
of SessionKind.oauth: of SessionKind.oauth:
result["authority"] = "api.x.com"
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret) result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
of SessionKind.cookie: of SessionKind.cookie:
result["x-twitter-auth-type"] = "OAuth2Session" result["x-twitter-auth-type"] = "OAuth2Session"
result["x-csrf-token"] = session.ct0 result["x-csrf-token"] = session.ct0
result["cookie"] = getCookieHeader(session.authToken, session.ct0) result["cookie"] = getCookieHeader(session.authToken, session.ct0)
result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24""""
result["sec-ch-ua-mobile"] = "?0"
result["sec-ch-ua-platform"] = "Windows"
result["sec-fetch-dest"] = "empty"
result["sec-fetch-mode"] = "cors"
result["sec-fetch-site"] = "same-site"
if disableTid: if disableTid:
result["authorization"] = bearerToken2 result["authorization"] = bearerToken2
else: else:
@@ -94,7 +106,11 @@ template fetchImpl(result, fetchBody) {.dirty.} =
var resp: AsyncResponse var resp: AsyncResponse
pool.use(await genHeaders(session, url)): pool.use(await genHeaders(session, url)):
template getContent = template getContent =
resp = await c.get($url) # TODO: this is a temporary simple implementation
if apiProxy.len > 0:
resp = await c.get(($url).replace("https://", apiProxy))
else:
resp = await c.get($url)
result = await resp.body result = await resp.body
getContent() getContent()

View File

@@ -3,14 +3,17 @@ import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os
import types, consts import types, consts
import experimental/parser/session import experimental/parser/session
# max requests at a time per session to avoid race conditions const hourInSeconds = 60 * 60
const
maxConcurrentReqs = 2
hourInSeconds = 60 * 60
var var
sessionPool: seq[Session] sessionPool: seq[Session]
enableLogging = false enableLogging = false
# max requests at a time per session to avoid race conditions
maxConcurrentReqs = 2
proc setMaxConcurrentReqs*(reqs: int) =
if reqs > 0:
maxConcurrentReqs = reqs
template log(str: varargs[string, `$`]) = template log(str: varargs[string, `$`]) =
echo "[sessions] ", str.join("") echo "[sessions] ", str.join("")

View File

@@ -41,7 +41,9 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
enableDebug: cfg.get("Config", "enableDebug", false), enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""), proxy: cfg.get("Config", "proxy", ""),
proxyAuth: cfg.get("Config", "proxyAuth", ""), proxyAuth: cfg.get("Config", "proxyAuth", ""),
disableTid: cfg.get("Config", "disableTid", false) apiProxy: cfg.get("Config", "apiProxy", ""),
disableTid: cfg.get("Config", "disableTid", false),
maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2)
) )
return (conf, cfg) return (conf, cfg)

View File

@@ -37,7 +37,9 @@ setHmacKey(cfg.hmacKey)
setProxyEncoding(cfg.base64Media) setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns) setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth) setHttpProxy(cfg.proxy, cfg.proxyAuth)
setApiProxy(cfg.apiProxy)
setDisableTid(cfg.disableTid) setDisableTid(cfg.disableTid)
setMaxConcurrentReqs(cfg.maxConcurrentReqs)
initAboutPage(cfg.staticDir) initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg) waitFor initRedisPool(cfg)

View File

@@ -52,10 +52,10 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
"" ""
let headers = newHttpHeaders({ let headers = newHttpHeaders({
"Content-Type": res.headers["content-type", 0], "content-type": res.headers["content-type", 0],
"Content-Length": contentLength, "content-length": contentLength,
"Cache-Control": maxAge, "cache-control": maxAge,
"ETag": hashed "etag": hashed
}) })
respond(request, headers) respond(request, headers)

View File

@@ -114,6 +114,7 @@ proc createTimelineRouter*(cfg: Config) =
get "/@name/?@tab?/?": get "/@name/?@tab?/?":
cond '.' notin @"name" cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
cond @"tab" in ["with_replies", "media", "search", ""] cond @"tab" in ["with_replies", "media", "search", ""]
let let
prefs = cookiePrefs() prefs = cookiePrefs()

View File

@@ -275,7 +275,9 @@ type
enableDebug*: bool enableDebug*: bool
proxy*: string proxy*: string
proxyAuth*: string proxyAuth*: string
apiProxy*: string
disableTid*: bool disableTid*: bool
maxConcurrentReqs*: int
rssCacheTime*: int rssCacheTime*: int
listCacheTime*: int listCacheTime*: int

View File

@@ -26,6 +26,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
tdiv(class="profile-card-tabs-name"): tdiv(class="profile-card-tabs-name"):
linkUser(user, class="profile-card-fullname") linkUser(user, class="profile-card-fullname")
verifiedIcon(user)
linkUser(user, class="profile-card-username") linkUser(user, class="profile-card-username")
tdiv(class="profile-card-extra"): tdiv(class="profile-card-extra"):

View File

@@ -42,7 +42,6 @@ proc linkUser*(user: User, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)): buildHtml(a(href=href, class=class, title=nameText)):
text nameText text nameText
if isName: if isName:
verifiedIcon(user)
if user.protected: if user.protected:
text " " text " "
icon "lock", title="Protected account" icon "lock", title="Protected account"

View File

@@ -2,6 +2,9 @@
## SPDX-License-Identifier: AGPL-3.0-only ## SPDX-License-Identifier: AGPL-3.0-only
#import strutils, xmltree, strformat, options, unicode #import strutils, xmltree, strformat, options, unicode
#import ../types, ../utils, ../formatters, ../prefs #import ../types, ../utils, ../formatters, ../prefs
## Snowflake ID cutoff for RSS GUID format transition
## Corresponds to approximately December 14, 2025 UTC
#const guidCutoff = 2000000000000000000'i64
# #
#proc getTitle(tweet: Tweet; retweet: string): string = #proc getTitle(tweet: Tweet; retweet: string): string =
#if tweet.pinned: result = "Pinned: " #if tweet.pinned: result = "Pinned: "
@@ -101,12 +104,17 @@ ${renderRssTweet(quoteTweet, cfg)}
# if link in links: continue # if link in links: continue
# end if # end if
# links.add link # links.add link
# let useGlobalGuid = tweet.id >= guidCutoff
<item> <item>
<title>${getTitle(tweet, retweet)}</title> <title>${getTitle(tweet, retweet)}</title>
<dc:creator>@${tweet.user.username}</dc:creator> <dc:creator>@${tweet.user.username}</dc:creator>
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description> <description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
<pubDate>${getRfc822Time(tweet)}</pubDate> <pubDate>${getRfc822Time(tweet)}</pubDate>
#if useGlobalGuid:
<guid isPermaLink="false">${tweet.id}</guid>
#else:
<guid>${urlPrefix & link}</guid> <guid>${urlPrefix & link}</guid>
#end if
<link>${urlPrefix & link}</link> <link>${urlPrefix & link}</link>
</item> </item>
# end for # end for

View File

@@ -28,14 +28,19 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore: if thread.hasMore:
renderMoreReplies(thread) renderMoreReplies(thread)
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
buildHtml(tdiv(class="replies", id="r")): buildHtml(tdiv(class="replies", id="r")):
var hasReplies = false
var replyCount = 0
for thread in replies.content: for thread in replies.content:
if thread.content.len == 0: continue if thread.content.len == 0: continue
hasReplies = true
replyCount += thread.content.len
renderReplyThread(thread, prefs, path) renderReplyThread(thread, prefs, path)
if replies.bottom.len > 0: if hasReplies and replies.bottom.len > 0:
renderMore(Query(), replies.bottom, focus="#r") if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
renderMore(Query(), replies.bottom, focus="#r")
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
let hasAfter = conv.after.content.len > 0 let hasAfter = conv.after.content.len > 0
@@ -70,6 +75,6 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if not conv.replies.beginning: if not conv.replies.beginning:
renderNewer(Query(), getLink(conv.tweet), focus="#r") renderNewer(Query(), getLink(conv.tweet), focus="#r")
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
renderReplies(conv.replies, prefs, path) renderReplies(conv.replies, prefs, path, conv.tweet)
renderToTop(focus="#m") renderToTop(focus="#m")

View File

@@ -66,6 +66,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
tdiv(class="tweet-name-row"): tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
linkUser(user, class="fullname") linkUser(user, class="fullname")
verifiedIcon(user)
linkUser(user, class="username") linkUser(user, class="username")
tdiv(class="tweet-content media-body", dir="auto"): tdiv(class="tweet-content media-body", dir="auto"):

View File

@@ -31,6 +31,7 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN
tdiv(class="tweet-name-row"): tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
linkUser(tweet.user, class="fullname") linkUser(tweet.user, class="fullname")
verifiedIcon(tweet.user)
linkUser(tweet.user, class="username") linkUser(tweet.user, class="username")
span(class="tweet-date"): span(class="tweet-date"):
@@ -235,6 +236,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
renderMiniAvatar(quote.user, prefs) renderMiniAvatar(quote.user, prefs)
linkUser(quote.user, class="fullname") linkUser(quote.user, class="fullname")
verifiedIcon(quote.user)
linkUser(quote.user, class="username") linkUser(quote.user, class="username")
span(class="tweet-date"): span(class="tweet-date"):

View File

@@ -15,7 +15,19 @@ protected = [
['Poop', 'Randy', 'Social media fanatic.'] ['Poop', 'Randy', 'Social media fanatic.']
] ]
invalid = [['thisprofiledoesntexist'], ['%']] invalid = [['thisprofiledoesntexist']]
malformed = [
['${userId}'],
['$%7BuserId%7D'], # URL encoded version
['%'], # Percent sign is invalid
['user@name'],
['user.name'],
['user-name'],
['user$name'],
['user{name}'],
['user name'], # space
]
banner_image = [ banner_image = [
['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500'] ['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500']
@@ -65,6 +77,13 @@ class ProfileTest(BaseTestCase):
self.open_nitter(username) self.open_nitter(username)
self.assert_text(f'User "{username}" not found') self.assert_text(f'User "{username}" not found')
@parameterized.expand(malformed)
def test_malformed_username(self, username):
"""Test that malformed usernames (with invalid characters) return 404"""
self.open_nitter(username)
# Malformed usernames should return 404 page not found, not try to fetch from Twitter
self.assert_text('Page not found')
def test_suspended(self): def test_suspended(self):
self.open_nitter('suspendme') self.open_nitter('suspendme')
self.assert_text('User "suspendme" has been suspended') self.assert_text('User "suspendme" has been suspended')

View File

@@ -21,7 +21,10 @@ def auth(username, password, otp_secret):
guest_token = requests.post( guest_token = requests.post(
"https://api.twitter.com/1.1/guest/activate.json", "https://api.twitter.com/1.1/guest/activate.json",
headers={'Authorization': bearer_token} headers={
'Authorization': bearer_token,
"User-Agent": "TwitterAndroid/10.21.0-release.0 (310210000-r-0) ONEPLUS+A3010/9"
}
).json().get('guest_token') ).json().get('guest_token')
if not guest_token: if not guest_token: