31 Commits

Author SHA1 Message Date
4eefa1bf07 Merge remote-tracking branch 'upstream/master' 2025-11-28 23:22:31 -03:00
Zed
31d210ca47 Add experimental x-client-transaction-id support (#1324)
* Add experimental x-client-transaction-id support

* Remove broken test
2025-11-29 01:13:08 +01:00
Zed
dae68b4f13 Ignore null errors, they're internal API errors 2025-11-29 01:05:57 +01:00
Zed
8516ebe2b7 Fix 'key not found in object: expanded_url' error
Fixes #1318
2025-11-29 00:37:45 +01:00
Zed
b83227aaf5 Implement temp fix for cookie sessions
Fixes #1319
2025-11-26 01:03:27 +01:00
0e707271a4 chore: added missing near param and removed unused import 2025-11-24 22:00:36 -03:00
f68e234ea6 Merge remote-tracking branch 'upstream/master' 2025-11-24 22:00:01 -03:00
Zed
404b06b5f3 Include "Video" and link for video tweets in RSS (#1315)
Fixes #836
2025-11-25 01:03:45 +01:00
Zed
2b922c049a Embed quote tweet in RSS (#1316)
Fixes #132
Closes #820
2025-11-25 01:02:45 +01:00
948a0fdc5c Merge remote-tracking branch 'upstream/master'
I really hope nothing breaks! :3
2025-11-24 20:25:40 -03:00
Zed
78101df2cc Style number input field 2025-11-24 23:04:25 +01:00
Zed
12bbddf204 Update search panel grid layout and animation 2025-11-24 23:04:25 +01:00
Zed
4979d07f2e Add spaces filter, remove broken filters 2025-11-24 23:04:25 +01:00
Zed
f038b53fa2 Fix body font size to match x.com
Fixes #711
2025-11-24 23:04:25 +01:00
Zed
4748311f8d Fix intent/follow URL redirect
Fixes #629
2025-11-24 23:04:25 +01:00
Zed
d47eb8f0eb Fix double slashes in url replacements
Fixes #520
2025-11-24 23:04:25 +01:00
Zed
1657eeb769 Fix canonical link causing redirects to Twitter
Fixes #526
2025-11-24 23:04:25 +01:00
Zed
25df682094 Expose username as HTML attribute
Fixes #551
2025-11-24 23:04:25 +01:00
Zed
53edbbc4e9 Fix broken tweet pagination ("Load more" button)
Fixes #1277
2025-11-23 20:00:10 +01:00
Zed
5b4a3fe691 Redirect /i/status/id/history to /i/status/id
Fixes #1231
2025-11-23 19:27:13 +01:00
d3d825dc50 Merge remote-tracking branch 'upstream/master' 2025-11-23 15:05:38 -03:00
Zed
f8a17fdaa5 Remove Nim 1.6.x support
Fixes #1311
2025-11-23 17:28:11 +01:00
b4d2f9aec9 chore: removed unused imports 2025-11-23 09:04:15 -03:00
2e122dbcb1 Merge remote-tracking branch 'upstream/master' 2025-11-23 08:59:35 -03:00
56762c9026 chore(css): bump style.css version to v20 2025-11-22 20:45:15 -03:00
3d9a7e3014 refactor: restructure homepage layout 2025-11-22 18:37:04 -03:00
Zed
b0d9c1d51a Update endpoints, fix parser, remove quotes stat 2025-11-22 21:29:36 +01:00
dba8410146 feat: default accounts appear on homepage if not following an account 2025-11-22 15:19:12 -03:00
704db60297 fix(css): added timeline-header a border in kuuro.css theme 2025-11-21 23:04:38 -03:00
73360e6972 feat: added homepage feed showing followed accounts 2025-11-21 23:03:58 -03:00
Zed
78d788b27f Fix verified parsing for oauth endpoints 2025-11-19 07:33:32 +01:00
39 changed files with 835 additions and 412 deletions

View File

@@ -27,6 +27,8 @@ enableDebug = false # enable request logs and debug endpoints (/.sessions)
logLevel = "info" # log level (debug, info, warn, error, fatal) logLevel = "info" # log level (debug, info, warn, error, fatal)
proxy = "" # http/https url, SOCKS proxies are not supported proxy = "" # http/https url, SOCKS proxies are not supported
proxyAuth = "" proxyAuth = ""
defaultFollowedAccounts = "eff,fsf" # default accounts to show when user follows none
disableTid = false # enable this if cookie-based auth is failing
# 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

@@ -10,7 +10,7 @@ bin = @["nitter"]
# Dependencies # Dependencies
requires "nim >= 1.6.10" requires "nim >= 2.0.0"
requires "jester#baca3f" requires "jester#baca3f"
requires "karax#5cf360c" requires "karax#5cf360c"
requires "sass#7dfdd03" requires "sass#7dfdd03"

View File

@@ -52,6 +52,7 @@ nav,
.unavailable-box, .unavailable-box,
.tweet-embed, .tweet-embed,
.overlay-panel, .overlay-panel,
.timeline-header,
.tab { .tab {
outline: 1px solid var(--border_grey) !important; outline: 1px solid var(--border_grey) !important;
outline-offset: -1px !important; outline-offset: -1px !important;

View File

@@ -37,6 +37,10 @@ function fetchAndParse(url) {
window.onload = function () { window.onload = function () {
const url = window.location.pathname; const url = window.location.pathname;
const isTweet = url.indexOf("/status/") !== -1; const isTweet = url.indexOf("/status/") !== -1;
const isHomepage = url === "/" || url === "";
if (isHomepage) return;
const isIncompleteThread = const isIncompleteThread =
isTweet && document.querySelector(".timeline-item.more-replies") != null; isTweet && document.querySelector(".timeline-item.more-replies") != null;

View File

@@ -1,96 +1,101 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar import asyncdispatch, httpclient, strutils, sequtils, sugar
import packedjson import packedjson
import types, query, formatters, consts, apiutils, parser import types, query, formatters, consts, apiutils, parser
import experimental/parser as newParser import experimental/parser as newParser
proc mediaUrl(id: string; cursor: string): SessionAwareUrl = # Helper to generate params object for GraphQL requests
let proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
cookieVariables = userMediaVariables % [id, cursor] result.add ("variables", variables)
oauthVariables = restIdVariables % [id, cursor] result.add ("features", gqlFeatures)
result = SessionAwareUrl( if fieldToggles.len > 0:
cookieUrl: graphUserMedia ? {"variables": cookieVariables, "features": gqlFeatures}, result.add ("fieldToggles", fieldToggles)
oauthUrl: graphUserMediaV2 ? {"variables": oauthVariables, "features": gqlFeatures}
proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl =
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
let url = apiUrl(endpoint, variables, fieldToggles)
return ApiReq(cookie: url, oauth: url)
proc mediaUrl(id: string; cursor: string): ApiReq =
result = ApiReq(
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]),
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
) )
proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = proc userTweetsUrl(id: string; cursor: string): ApiReq =
let result = ApiReq(
cookieVariables = userTweetsVariables % [id, cursor] # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
oauthVariables = restIdVariables % [id, cursor] oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
result = SessionAwareUrl( )
cookieUrl: graphUserTweets ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles}, # might change this in the future pending testing
oauthUrl: graphUserTweets ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles} result.cookie = result.oauth
proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
result = ApiReq(
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
) )
proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = proc tweetDetailUrl(id: string; cursor: string): ApiReq =
let let cookieVars = tweetDetailVars % [id, cursor]
cookieVariables = userTweetsAndRepliesVariables % [id, cursor] result = ApiReq(
oauthVariables = restIdVariables % [id, cursor] cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
result = SessionAwareUrl( oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
cookieUrl: graphUserTweetsAndReplies ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles},
oauthUrl: graphUserTweetsAndReplies ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles}
) )
proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = proc userUrl(username: string): ApiReq =
let let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
cookieVariables = tweetDetailVariables % [id, cursor] result = ApiReq(
oauthVariables = tweetVariables % [id, cursor] cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
result = SessionAwareUrl( oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
cookieUrl: graphTweetDetail ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": tweetDetailFieldToggles},
oauthUrl: graphTweetDetail ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": tweetDetailFieldToggles}
) )
proc getGraphUser*(username: string): Future[User] {.async.} = proc getGraphUser*(username: string): Future[User] {.async.} =
if username.len == 0: return if username.len == 0: return
let let js = await fetchRaw(userUrl(username))
variables = """{"screen_name": "$1"}""" % username
params = {"variables": variables, "features": gqlFeatures}
js = await fetchRaw(graphUser ? params, Api.userScreenName)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserById*(id: string): Future[User] {.async.} = proc getGraphUserById*(id: string): Future[User] {.async.} =
if id.len == 0 or id.any(c => not c.isDigit): return if id.len == 0 or id.any(c => not c.isDigit): return
let let
variables = """{"rest_id": "$1"}""" % id url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
params = {"variables": variables, "features": gqlFeatures} js = await fetchRaw(url)
js = await fetchRaw(graphUserById ? params, Api.userRestId)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
js = case kind url = case kind
of TimelineKind.tweets: of TimelineKind.tweets: userTweetsUrl(id, cursor)
await fetch(userTweetsUrl(id, cursor), Api.userTweets) of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
of TimelineKind.replies: of TimelineKind.media: mediaUrl(id, cursor)
await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies) js = await fetch(url)
of TimelineKind.media:
await fetch(mediaUrl(id, cursor), Api.userMedia)
result = parseGraphTimeline(js, after) result = parseGraphTimeline(js, after)
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = restIdVariables % [id, cursor] url = apiReq(graphListTweets, restIdVars % [id, cursor])
params = {"variables": variables, "features": gqlFeatures} js = await fetch(url)
js = await fetch(graphListTweets ? params, Api.listTweets)
result = parseGraphTimeline(js, after).tweets result = parseGraphTimeline(js, after).tweets
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
let let
variables = %*{"screenName": name, "listSlug": list} variables = %*{"screenName": name, "listSlug": list}
params = {"variables": $variables, "features": gqlFeatures} url = apiReq(graphListBySlug, $variables)
url = graphListBySlug ? params js = await fetch(url)
result = parseGraphList(await fetch(url, Api.listBySlug)) result = parseGraphList(js)
proc getGraphList*(id: string): Future[List] {.async.} = proc getGraphList*(id: string): Future[List] {.async.} =
let let
variables = """{"listId": "$1"}""" % id url = apiReq(graphListById, """{"listId": "$1"}""" % id)
params = {"variables": variables, "features": gqlFeatures} js = await fetch(url)
url = graphListById ? params result = parseGraphList(js)
result = parseGraphList(await fetch(url, Api.list))
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
if list.id.len == 0: return if list.id.len == 0: return
@@ -104,22 +109,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} let
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) url = apiReq(graphListMembers, $variables)
js = await fetchRaw(url)
result = parseGraphListMembers(js, after)
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
variables = """{"rest_id": "$1"}""" % id url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
params = {"variables": variables, "features": gqlFeatures} js = await fetch(url)
js = await fetch(graphTweetResult ? params, Api.tweetResult)
result = parseGraphTweetResult(js) result = parseGraphTweetResult(js)
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail) js = await fetch(tweetDetailUrl(id, cursor))
result = parseGraphConversation(js, id) result = parseGraphConversation(js, id)
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
@@ -139,6 +145,7 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
var var
variables = %*{ variables = %*{
"rawQuery": q, "rawQuery": q,
"query_source": "typedQuery",
"count": 20, "count": 20,
"product": "Latest", "product": "Latest",
"withDownvotePerspective": false, "withDownvotePerspective": false,
@@ -147,8 +154,10 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} let
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[Tweets](js, after)
result.query = query result.query = query
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
@@ -158,6 +167,7 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
var var
variables = %*{ variables = %*{
"rawQuery": query.text, "rawQuery": query.text,
"query_source": "typedQuery",
"count": 20, "count": 20,
"product": "People", "product": "People",
"withDownvotePerspective": false, "withDownvotePerspective": false,
@@ -168,13 +178,15 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
variables["cursor"] = % after variables["cursor"] = % after
result.beginning = false result.beginning = false
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} let
result = parseGraphSearch[User](await fetch(url, Api.search), after) url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[User](js, after)
result.query = query result.query = query
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return if id.len == 0: return
let js = await fetch(mediaUrl(id, ""), Api.userMedia) let js = await fetch(mediaUrl(id, ""))
result = parseGraphPhotoRail(js) result = parseGraphPhotoRail(js)
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =

View File

@@ -1,16 +1,30 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables, logging import httpclient, asyncdispatch, options, strutils, uri, times, math, tables, logging
import jsony, packedjson, zippy, oauth1 import jsony, packedjson, zippy, oauth1
import types, auth, consts, parserutils, http_pool import types, auth, consts, parserutils, http_pool, tid
import experimental/types/common import experimental/types/common
const const
rlRemaining = "x-rate-limit-remaining" rlRemaining = "x-rate-limit-remaining"
rlReset = "x-rate-limit-reset" rlReset = "x-rate-limit-reset"
rlLimit = "x-rate-limit-limit" rlLimit = "x-rate-limit-limit"
errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest} errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
var pool: HttpPool var
pool: HttpPool
disableTid: bool
proc setDisableTid*(disable: bool) =
disableTid = disable
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
case sessionKind
of oauth:
let o = req.oauth
parseUri("https://api.x.com/graphql") / o.endpoint ? o.params
of cookie:
let c = req.cookie
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
let let
@@ -32,15 +46,15 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
proc getCookieHeader(authToken, ct0: string): string = proc getCookieHeader(authToken, ct0: string): string =
"auth_token=" & authToken & "; ct0=" & ct0 "auth_token=" & authToken & "; ct0=" & ct0
proc genHeaders*(session: Session, url: string): HttpHeaders = proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
result = newHttpHeaders({ result = newHttpHeaders({
"connection": "keep-alive", "connection": "keep-alive",
"content-type": "application/json", "content-type": "application/json",
"x-twitter-active-user": "yes", "x-twitter-active-user": "yes",
"x-twitter-client-language": "en", "x-twitter-client-language": "en",
"authority": "api.x.com", "origin": "https://x.com",
"accept-encoding": "gzip", "accept-encoding": "gzip",
"accept-language": "en-US,en;q=0.9", "accept-language": "en-US,en;q=0.5",
"accept": "*/*", "accept": "*/*",
"DNT": "1", "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" "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"
@@ -48,15 +62,20 @@ proc genHeaders*(session: Session, url: string): HttpHeaders =
case session.kind case session.kind
of SessionKind.oauth: of SessionKind.oauth:
result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) result["authority"] = "api.x.com"
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
of SessionKind.cookie: of SessionKind.cookie:
result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
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)
if disableTid:
result["authorization"] = bearerToken2
else:
result["authorization"] = bearerToken
result["x-client-transaction-id"] = await genTid(url.path)
proc getAndValidateSession*(api: Api): Future[Session] {.async.} = proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
result = await getSession(api) result = await getSession(req)
case result.kind case result.kind
of SessionKind.oauth: of SessionKind.oauth:
if result.oauthToken.len == 0: if result.oauthToken.len == 0:
@@ -73,7 +92,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
try: try:
var resp: AsyncResponse var resp: AsyncResponse
pool.use(genHeaders(session, $url)): pool.use(await genHeaders(session, url)):
template getContent = template getContent =
resp = await c.get($url) resp = await c.get($url)
result = await resp.body result = await resp.body
@@ -89,7 +108,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
remaining = parseInt(resp.headers[rlRemaining]) remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset]) reset = parseInt(resp.headers[rlReset])
limit = parseInt(resp.headers[rlLimit]) limit = parseInt(resp.headers[rlLimit])
session.setRateLimit(api, remaining, reset, limit) session.setRateLimit(req, remaining, reset, limit)
if result.len > 0: if result.len > 0:
if resp.headers.getOrDefault("content-encoding") == "gzip": if resp.headers.getOrDefault("content-encoding") == "gzip":
@@ -98,24 +117,22 @@ template fetchImpl(result, fetchBody) {.dirty.} =
if result.startsWith("{\"errors"): if result.startsWith("{\"errors"):
let errors = result.fromJson(Errors) let errors = result.fromJson(Errors)
if errors notin errorsToSkip: if errors notin errorsToSkip:
error "Fetch error, API: ", api, ", errors: ", errors error "Fetch error, API: ", url.path, ", errors: ", errors
if errors in {expiredToken, badToken, locked}: if errors in {expiredToken, badToken, locked}:
invalidate(session) invalidate(session)
raise rateLimitError() raise rateLimitError()
elif errors in {rateLimited}: elif errors in {rateLimited}:
# rate limit hit, resets after 24 hours # rate limit hit, resets after 24 hours
setLimited(session, api) setLimited(session, req)
raise rateLimitError() raise rateLimitError()
elif result.startsWith("429 Too Many Requests"): elif result.startsWith("429 Too Many Requests"):
warn "[sessions] 429 error, API: ", api, ", session: ", session.pretty warn "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
session.apis[api].remaining = 0
# rate limit hit, resets after the 15 minute window
raise rateLimitError() raise rateLimitError()
fetchBody fetchBody
if resp.status == $Http400: if resp.status == $Http400:
error "ERROR 400, ", api, ": ", result error "ERROR 400, ", url.path, ": ", result
raise newException(InternalError, $url) raise newException(InternalError, $url)
except InternalError as e: except InternalError as e:
raise e raise e
@@ -137,19 +154,16 @@ template retry(bod) =
try: try:
bod bod
except RateLimitError: except RateLimitError:
info "[sessions] Rate limited, retrying ", api, " request..." info "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..."
bod bod
proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} = proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
retry: retry:
var var
body: string body: string
session = await getAndValidateSession(api) session = await getAndValidateSession(req)
when url is SessionAwareUrl: let url = req.toUrl(session.kind)
let url = case session.kind
of SessionKind.oauth: url.oauthUrl
of SessionKind.cookie: url.cookieUrl
fetchImpl body: fetchImpl body:
if body.startsWith('{') or body.startsWith('['): if body.startsWith('{') or body.startsWith('['):
@@ -158,21 +172,17 @@ proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} =
warn resp.status, ": ", body, " --- url: ", url warn resp.status, ": ", body, " --- url: ", url
result = newJNull() result = newJNull()
let apiErr = result.getError let error = result.getError
if apiErr != null and apiErr notin errorsToSkip: if error != null and error notin errorsToSkip:
error "Fetch error, API: ", api, ", error: ", apiErr error "Fetch error, API: ", url.path, ", error: ", error
if apiErr in {expiredToken, badToken, locked}: if error in {expiredToken, badToken, locked}:
invalidate(session) invalidate(session)
raise rateLimitError() raise rateLimitError()
proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} = proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
retry: retry:
var session = await getAndValidateSession(api) var session = await getAndValidateSession(req)
let url = req.toUrl(session.kind)
when url is SessionAwareUrl:
let url = case session.kind
of SessionKind.oauth: url.oauthUrl
of SessionKind.cookie: url.cookieUrl
fetchImpl result: fetchImpl result:
if not (result.startsWith('{') or result.startsWith('[')): if not (result.startsWith('{') or result.startsWith('[')):

View File

@@ -1,6 +1,6 @@
#SPDX-License-Identifier: AGPL-3.0-only #SPDX-License-Identifier: AGPL-3.0-only
import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os, logging] import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os, logging]
import types import types, consts
import experimental/parser/session import experimental/parser/session
# max requests at a time per session to avoid race conditions # max requests at a time per session to avoid race conditions
@@ -18,6 +18,11 @@ proc logSession(args: varargs[string, `$`]) =
s.add arg s.add arg
info s info s
proc endpoint(req: ApiReq; session: Session): string =
case session.kind
of oauth: req.oauth.endpoint
of cookie: req.cookie.endpoint
proc pretty*(session: Session): string = proc pretty*(session: Session): string =
if session.isNil: if session.isNil:
return "<null>" return "<null>"
@@ -125,11 +130,12 @@ proc rateLimitError*(): ref RateLimitError =
proc noSessionsError*(): ref NoSessionsError = proc noSessionsError*(): ref NoSessionsError =
newException(NoSessionsError, "no sessions available") newException(NoSessionsError, "no sessions available")
proc isLimited(session: Session; api: Api): bool = proc isLimited(session: Session; req: ApiReq): bool =
if session.isNil: if session.isNil:
return true return true
if session.limited and api != Api.userTweets: let api = req.endpoint(session)
if session.limited and api != graphUserTweetsV2:
if (epochTime().int - session.limitedAt) > hourInSeconds: if (epochTime().int - session.limitedAt) > hourInSeconds:
session.limited = false session.limited = false
logSession "resetting limit: ", session.pretty logSession "resetting limit: ", session.pretty
@@ -143,8 +149,8 @@ proc isLimited(session: Session; api: Api): bool =
else: else:
return false return false
proc isReady(session: Session; api: Api): bool = proc isReady(session: Session; req: ApiReq): bool =
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api)) not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
proc invalidate*(session: var Session) = proc invalidate*(session: var Session) =
if session.isNil: return if session.isNil: return
@@ -159,24 +165,26 @@ proc release*(session: Session) =
if session.isNil: return if session.isNil: return
dec session.pending dec session.pending
proc getSession*(api: Api): Future[Session] {.async.} = proc getSession*(req: ApiReq): Future[Session] {.async.} =
for i in 0 ..< sessionPool.len: for i in 0 ..< sessionPool.len:
if result.isReady(api): break if result.isReady(req): break
result = sessionPool.sample() result = sessionPool.sample()
if not result.isNil and result.isReady(api): if not result.isNil and result.isReady(req):
inc result.pending inc result.pending
else: else:
logSession "no sessions available for API: ", api logSession "no sessions available for API: ", req.cookie.endpoint
raise noSessionsError() raise noSessionsError()
proc setLimited*(session: Session; api: Api) = proc setLimited*(session: Session; req: ApiReq) =
let api = req.endpoint(session)
session.limited = true session.limited = true
session.limitedAt = epochTime().int session.limitedAt = epochTime().int
logSession "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty logSession "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty
proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) = proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) =
# avoid undefined behavior in race conditions # avoid undefined behavior in race conditions
let api = req.endpoint(session)
if api in session.apis: if api in session.apis:
let rateLimit = session.apis[api] let rateLimit = session.apis[api]
if rateLimit.reset >= reset and rateLimit.remaining < remaining: if rateLimit.reset >= reset and rateLimit.remaining < remaining:

View File

@@ -1,6 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import parsecfg except Config import parsecfg except Config
import types, strutils import types, strutils, sequtils
proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
let val = config.getSectionValue(section, key) let val = config.getSectionValue(section, key)
@@ -9,6 +9,7 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
when T is int: parseInt(val) when T is int: parseInt(val)
elif T is bool: parseBool(val) elif T is bool: parseBool(val)
elif T is string: val elif T is string: val
elif T is seq[string]: val.split(',').mapIt(it.strip())
proc getConfig*(path: string): (Config, parseCfg.Config) = proc getConfig*(path: string): (Config, parseCfg.Config) =
var cfg = loadConfig(path) var cfg = loadConfig(path)
@@ -41,7 +42,9 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
enableDebug: cfg.get("Config", "enableDebug", false), enableDebug: cfg.get("Config", "enableDebug", false),
logLevel: cfg.get("Config", "logLevel", ""), logLevel: cfg.get("Config", "logLevel", ""),
proxy: cfg.get("Config", "proxy", ""), proxy: cfg.get("Config", "proxy", ""),
proxyAuth: cfg.get("Config", "proxyAuth", "") proxyAuth: cfg.get("Config", "proxyAuth", ""),
defaultFollowedAccounts: cfg.get("Config", "defaultFollowedAccounts", @["eff", "fsf"]),
disableTid: cfg.get("Config", "disableTid", false)
) )
return (conf, cfg) return (conf, cfg)

View File

@@ -1,32 +1,36 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils import strutils
const const
consumerKey* = "3nVuSoBZnx6U4vzUxf5w" consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
gql = parseUri("https://api.x.com") / "graphql" graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
graphUser* = gql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
graphUserById* = gql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
graphUserTweetsV2* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2" graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
graphUserTweetsAndRepliesV2* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2" graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
graphUserMediaV2* = gql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
graphTweet* = gql / "Vorskcd2tZ-tc4Gx3zbk4Q/ConversationTimelineV2" graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
graphTweetResult* = gql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
graphSearchTimeline* = gql / "7r8ibjHuK3MWUyzkzHNMYQ/SearchTimeline" graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
graphListTweets* = gql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
gqlFeatures* = """{ gqlFeatures* = """{
"android_ad_formats_media_component_render_overlay_enabled": false,
"android_graphql_skip_api_media_color_palette": false, "android_graphql_skip_api_media_color_palette": false,
"android_professional_link_spotlight_display_enabled": false,
"blue_business_profile_image_shape_enabled": false, "blue_business_profile_image_shape_enabled": false,
"commerce_android_shop_module_enabled": false,
"creator_subscriptions_subscription_count_enabled": false, "creator_subscriptions_subscription_count_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true, "creator_subscriptions_tweet_preview_api_enabled": true,
"freedom_of_speech_not_reach_fetch_enabled": true, "freedom_of_speech_not_reach_fetch_enabled": true,
@@ -36,8 +40,9 @@ const
"interactive_text_enabled": false, "interactive_text_enabled": false,
"longform_notetweets_consumption_enabled": true, "longform_notetweets_consumption_enabled": true,
"longform_notetweets_inline_media_enabled": true, "longform_notetweets_inline_media_enabled": true,
"longform_notetweets_richtext_consumption_enabled": true,
"longform_notetweets_rich_text_read_enabled": true, "longform_notetweets_rich_text_read_enabled": true,
"longform_notetweets_richtext_consumption_enabled": true,
"mobile_app_spotlight_module_enabled": false,
"responsive_web_edit_tweet_api_enabled": true, "responsive_web_edit_tweet_api_enabled": true,
"responsive_web_enhance_cards_enabled": false, "responsive_web_enhance_cards_enabled": false,
"responsive_web_graphql_exclude_directive_enabled": true, "responsive_web_graphql_exclude_directive_enabled": true,
@@ -46,6 +51,7 @@ const
"responsive_web_media_download_video_enabled": false, "responsive_web_media_download_video_enabled": false,
"responsive_web_text_conversations_enabled": false, "responsive_web_text_conversations_enabled": false,
"responsive_web_twitter_article_tweet_consumption_enabled": true, "responsive_web_twitter_article_tweet_consumption_enabled": true,
"unified_cards_destination_url_params_enabled": false,
"responsive_web_twitter_blue_verified_badge_is_enabled": true, "responsive_web_twitter_blue_verified_badge_is_enabled": true,
"rweb_lists_timeline_redesign_enabled": true, "rweb_lists_timeline_redesign_enabled": true,
"spaces_2022_h2_clipping": true, "spaces_2022_h2_clipping": true,
@@ -86,11 +92,21 @@ const
"payments_enabled": false, "payments_enabled": false,
"responsive_web_profile_redirect_enabled": false, "responsive_web_profile_redirect_enabled": false,
"responsive_web_grok_show_grok_translated_post": false, "responsive_web_grok_show_grok_translated_post": false,
"responsive_web_grok_community_note_auto_translation_is_enabled": false "responsive_web_grok_community_note_auto_translation_is_enabled": false,
"profile_label_improvements_pcf_label_in_profile_enabled": false,
"grok_android_analyze_trend_fetch_enabled": false,
"grok_translations_community_note_auto_translation_is_enabled": false,
"grok_translations_post_auto_translation_is_enabled": false,
"grok_translations_community_note_translation_is_enabled": false,
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false,
"subscriptions_feature_can_gift_premium": false,
"responsive_web_twitter_article_notes_tab_enabled": false,
"subscriptions_verification_info_is_identity_verified_enabled": false,
"hidden_profile_subscriptions_enabled": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetVariables* = """{ tweetVars* = """{
"focalTweetId": "$1", "postId": "$1",
$2 $2
"includeHasBirdwatchNotes": false, "includeHasBirdwatchNotes": false,
"includePromotedContent": false, "includePromotedContent": false,
@@ -99,7 +115,7 @@ const
"withV2Timeline": true "withV2Timeline": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetDetailVariables* = """{ tweetDetailVars* = """{
"focalTweetId": "$1", "focalTweetId": "$1",
$2 $2
"referrer": "profile", "referrer": "profile",
@@ -112,12 +128,12 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
restIdVariables* = """{ restIdVars* = """{
"rest_id": "$1", $2 "rest_id": "$1", $2
"count": 20 "count": 20
}""" }"""
userMediaVariables* = """{ userMediaVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@@ -126,7 +142,7 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userTweetsVariables* = """{ userTweetsVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@@ -134,7 +150,7 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userTweetsAndRepliesVariables* = """{ userTweetsAndRepliesVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@@ -143,4 +159,6 @@ const
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
fieldToggles* = """{"withArticlePlainText":false,"withViewCounts":true}""" fieldToggles* = """{"withArticlePlainText":false,"withViewCounts":true}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false,"withViewCounts":true}""" userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""

View File

@@ -1,6 +1,6 @@
import options, strutils import options, strutils
import jsony import jsony
import user, ../types/[graphuser, graphlistmembers] import user, utils, ../types/[graphuser, graphlistmembers]
from ../../types import User, VerifiedType, Result, Query, QueryKind from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseUserResult*(userResult: UserResult): User = proc parseUserResult*(userResult: UserResult): User =
@@ -15,22 +15,36 @@ proc parseUserResult*(userResult: UserResult): User =
result.fullname = userResult.core.name result.fullname = userResult.core.name
result.userPic = userResult.avatar.imageUrl.replace("_normal", "") result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
if userResult.privacy.isSome:
result.protected = userResult.privacy.get.protected
if userResult.location.isSome:
result.location = userResult.location.get.location
if userResult.core.createdAt.len > 0:
result.joinDate = parseTwitterDate(userResult.core.createdAt)
if userResult.verification.isSome: if userResult.verification.isSome:
let v = userResult.verification.get let v = userResult.verification.get
if v.verifiedType != VerifiedType.none: if v.verifiedType != VerifiedType.none:
result.verifiedType = v.verifiedType result.verifiedType = v.verifiedType
if userResult.profileBio.isSome: if userResult.profileBio.isSome and result.bio.len == 0:
result.bio = userResult.profileBio.get.description result.bio = userResult.profileBio.get.description
proc parseGraphUser*(json: string): User = proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{': if json.len == 0 or json[0] != '{':
return return
let raw = json.fromJson(GraphUser) let
let userResult = raw.data.userResult.result raw = json.fromJson(GraphUser)
userResult =
if raw.data.userResult.isSome: raw.data.userResult.get.result
elif raw.data.user.isSome: raw.data.user.get.result
else: UserResult()
if userResult.unavailableReason.get("") == "Suspended": if userResult.unavailableReason.get("") == "Suspended" or
userResult.reason.get("") == "Suspended":
return User(suspended: true) return User(suspended: true)
result = parseUserResult(userResult) result = parseUserResult(userResult)

View File

@@ -0,0 +1,8 @@
import jsony
import ../types/tid
export TidPair
proc parseTidPairs*(raw: string): seq[TidPair] =
result = raw.fromJson(seq[TidPair])
if result.len == 0:
raise newException(ValueError, "Parsing pairs failed: " & raw)

View File

@@ -58,11 +58,13 @@ proc toUser*(raw: RawUser): User =
media: raw.mediaCount, media: raw.mediaCount,
verifiedType: raw.verifiedType, verifiedType: raw.verifiedType,
protected: raw.protected, protected: raw.protected,
joinDate: parseTwitterDate(raw.createdAt),
banner: getBanner(raw), banner: getBanner(raw),
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "") userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
) )
if raw.createdAt.len > 0:
result.joinDate = parseTwitterDate(raw.createdAt)
if raw.pinnedTweetIdsStr.len > 0: if raw.pinnedTweetIdsStr.len > 0:
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])

View File

@@ -3,7 +3,7 @@ from ../../types import User, VerifiedType
type type
GraphUser* = object GraphUser* = object
data*: tuple[userResult: UserData] data*: tuple[userResult: Option[UserData], user: Option[UserData]]
UserData* = object UserData* = object
result*: UserResult result*: UserResult
@@ -22,15 +22,24 @@ type
Verification* = object Verification* = object
verifiedType*: VerifiedType verifiedType*: VerifiedType
Location* = object
location*: string
Privacy* = object
protected*: bool
UserResult* = object UserResult* = object
legacy*: User legacy*: User
restId*: string restId*: string
isBlueVerified*: bool isBlueVerified*: bool
unavailableReason*: Option[string]
core*: UserCore core*: UserCore
avatar*: UserAvatar avatar*: UserAvatar
unavailableReason*: Option[string]
reason*: Option[string]
privacy*: Option[Privacy]
profileBio*: Option[UserBio] profileBio*: Option[UserBio]
verification*: Option[Verification] verification*: Option[Verification]
location*: Option[Location]
proc enumHook*(s: string; v: var VerifiedType) = proc enumHook*(s: string; v: var VerifiedType) =
v = try: v = try:

View File

@@ -0,0 +1,4 @@
type
TidPair* = object
animationKey*: string
verification*: string

View File

@@ -6,7 +6,7 @@ import types, utils, query
const const
cards = "cards.twitter.com/cards" cards = "cards.twitter.com/cards"
tco = "https://t.co" tco = "https://t.co"
twitter = parseUri("https://twitter.com") twitter = parseUri("https://x.com")
let let
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com" twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
@@ -64,18 +64,20 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
result = body result = body
if prefs.replaceYouTube.len > 0 and "youtu" in result: if prefs.replaceYouTube.len > 0 and "youtu" in result:
result = result.replace(ytRegex, prefs.replaceYouTube) let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
result = result.replace(ytRegex, youtubeHost)
if prefs.replaceTwitter.len > 0: if prefs.replaceTwitter.len > 0:
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
if tco in result: if tco in result:
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") result = result.replace(tco, https & twitterHost & "/t.co")
if "x.com" in result: if "x.com" in result:
result = result.replace(xRegex, prefs.replaceTwitter) result = result.replace(xRegex, twitterHost)
result = result.replacef(xLinkRegex, a( result = result.replacef(xLinkRegex, a(
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) twitterHost & "$2", href = https & twitterHost & "$1"))
if "twitter.com" in result: if "twitter.com" in result:
result = result.replace(cards, prefs.replaceTwitter & "/cards") result = result.replace(cards, twitterHost & "/cards")
result = result.replace(twRegex, prefs.replaceTwitter) result = result.replace(twRegex, twitterHost)
result = result.replacef(twLinkRegex, a( result = result.replacef(twLinkRegex, a(
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
if "imgur.com" in result: if "imgur.com" in result:
@@ -96,9 +98,10 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
prefs.replaceSoundCloud & "$4", href = https & prefs.replaceSoundCloud & "$2")) prefs.replaceSoundCloud & "$4", href = https & prefs.replaceSoundCloud & "$2"))
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result): if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") let redditHost = strip(prefs.replaceReddit, chars={'/'})
result = result.replace(rdRegex, prefs.replaceReddit) result = result.replace(rdShortRegex, redditHost & "/comments/")
if prefs.replaceReddit in result and "/gallery/" in result: result = result.replace(rdRegex, redditHost)
if redditHost in result and "/gallery/" in result:
result = result.replace("/gallery/", "/comments/") result = result.replace("/gallery/", "/comments/")
if absolute.len > 0 and "href" in result: if absolute.len > 0 and "href" in result:

View File

@@ -6,8 +6,9 @@ from os import getEnv
import jester import jester
import types, config, prefs, formatters, redis_cache, http_pool, auth import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
import views/[general, about] import views/[general, about, search, homepage]
import karax/[vdom]
import routes/[ import routes/[
preferences, timeline, status, media, search, rss, list, debug, preferences, timeline, status, media, search, rss, list, debug,
unsupported, embed, resolver, router_utils, follow] unsupported, embed, resolver, router_utils, follow]
@@ -72,6 +73,7 @@ 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)
setDisableTid(cfg.disableTid)
initAboutPage(cfg.staticDir) initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg) waitFor initRedisPool(cfg)
@@ -98,7 +100,41 @@ settings:
routes: routes:
get "/": get "/":
resp renderMain(renderSearch(), request, cfg, themePrefs()) let prefs = cookiePrefs()
if prefs.following.len > 0 or cfg.defaultFollowedAccounts.len > 0:
let
cursor = getCursor()
accounts = if prefs.following.len > 0: prefs.following else: cfg.defaultFollowedAccounts
isDefault = prefs.following.len == 0
currentPath = if @"f".len > 0: "/?f=" & @"f" else: "/"
var homepageQuery = initQuery(params(request))
if @"f".len == 0:
homepageQuery.kind = tweets
homepageQuery.fromUser = accounts
let
timeline = await getGraphTweetSearch(homepageQuery, cursor)
html = if isDefault:
renderDefaultTimeline(timeline, prefs, getPath())
else:
renderHomepageTimeline(timeline, prefs, getPath())
var users: seq[User]
for username in accounts:
try:
let user = await getCachedUser(username)
users.add(user)
except:
continue
let homepageHtml = renderHomepage(users, html, prefs, currentPath)
resp renderMain(homepageHtml, request, cfg, prefs, "Following")
else:
resp renderMain(renderSearch(), request, cfg, themePrefs())
get "/about": get "/about":
resp renderMain(renderAbout(), request, cfg, themePrefs()) resp renderMain(renderAbout(), request, cfg, themePrefs())

View File

@@ -1,10 +1,10 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, options, times, math import strutils, options, times, math, tables
import packedjson, packedjson/deserialiser import packedjson, packedjson/deserialiser
import types, parserutils, utils import types, parserutils, utils
import experimental/parser/unifiedcard import experimental/parser/unifiedcard
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet proc parseGraphTweet(js: JsonNode): Tweet
proc parseUser(js: JsonNode; id=""): User = proc parseUser(js: JsonNode; id=""): User =
if js.isNull: return if js.isNull: return
@@ -21,12 +21,17 @@ proc parseUser(js: JsonNode; id=""): User =
tweets: js{"statuses_count"}.getInt, tweets: js{"statuses_count"}.getInt,
likes: js{"favourites_count"}.getInt, likes: js{"favourites_count"}.getInt,
media: js{"media_count"}.getInt, media: js{"media_count"}.getInt,
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")), protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
protected: js{"protected"}.getBool,
sensitive: "sensitive" in js{"profile_interstitial_type"}.getStr("") or js{"possibly_sensitive"}.getBool, sensitive: "sensitive" in js{"profile_interstitial_type"}.getStr("") or js{"possibly_sensitive"}.getBool,
joinDate: js{"created_at"}.getTime joinDate: js{"created_at"}.getTime
) )
if js{"is_blue_verified"}.getBool(false):
result.verifiedType = blue
with verifiedType, js{"verified_type"}:
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
result.expandUserEntities(js) result.expandUserEntities(js)
proc parseGraphUser(js: JsonNode): User = proc parseGraphUser(js: JsonNode): User =
@@ -42,6 +47,9 @@ proc parseGraphUser(js: JsonNode): User =
result = parseUser(user{"legacy"}, user{"rest_id"}.getStr) result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
if result.verifiedType == none and user{"is_blue_verified"}.getBool(false):
result.verifiedType = blue
# fallback to support UserMedia/recent GraphQL updates # fallback to support UserMedia/recent GraphQL updates
if result.username.len == 0: if result.username.len == 0:
result.username = user{"core", "screen_name"}.getStr result.username = user{"core", "screen_name"}.getStr
@@ -91,16 +99,24 @@ proc parsePoll(js: JsonNode): Poll =
result.leader = result.values.find(max(result.values)) result.leader = result.values.find(max(result.values))
result.votes = result.values.sum result.votes = result.values.sum
proc parseGif(js: JsonNode): Gif = proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] =
result = Gif( result = @[]
url: js{"video_info", "variants"}[0]{"url"}.getImageStr, for v in variants:
thumb: js{"media_url_https"}.getImageStr let
) url = v{"url"}.getStr
contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4"))
bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0))
result.add VideoVariant(
contentType: contentType,
bitrate: bitrate,
url: url,
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
)
proc parseVideo(js: JsonNode): Video = proc parseVideo(js: JsonNode): Video =
result = Video( result = Video(
thumb: js{"media_url_https"}.getImageStr, thumb: js{"media_url_https"}.getImageStr,
views: getVideoViewCount(js),
available: true, available: true,
title: js{"ext_alt_text"}.getStr, title: js{"ext_alt_text"}.getStr,
durationMs: js{"video_info", "duration_millis"}.getInt durationMs: js{"video_info", "duration_millis"}.getInt
@@ -117,17 +133,62 @@ proc parseVideo(js: JsonNode): Video =
with description, js{"additional_media_info", "description"}: with description, js{"additional_media_info", "description"}:
result.description = description.getStr result.description = description.getStr
for v in js{"video_info", "variants"}: result.variants = parseVideoVariants(js{"video_info", "variants"})
let
contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary"))
url = v{"url"}.getStr
result.variants.add VideoVariant( proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
contentType: contentType, with jsMedia, js{"extended_entities", "media"}:
bitrate: v{"bitrate"}.getInt, for m in jsMedia:
url: url, case m.getTypeName:
resolution: if contentType == mp4: getMp4Resolution(url) else: 0 of "photo":
) result.photos.add m{"media_url_https"}.getImageStr
of "video":
result.video = some(parseVideo(m))
with user, m{"additional_media_info", "source_user"}:
if user{"id"}.getInt > 0:
result.attribution = some(parseUser(user))
else:
result.attribution = some(parseGraphUser(user))
of "animated_gif":
result.gif = some Gif(
url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
thumb: m{"media_url_https"}.getImageStr
)
else: discard
with url, m{"url"}:
if result.text.endsWith(url.getStr):
result.text.removeSuffix(url.getStr)
result.text = result.text.strip()
proc parseMediaEntities(js: JsonNode; result: var Tweet) =
with mediaEntities, js{"media_entities"}:
for mediaEntity in mediaEntities:
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
case mediaInfo.getTypeName
of "ApiImage":
result.photos.add mediaInfo{"original_img_url"}.getImageStr
of "ApiVideo":
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
result.video = some Video(
available: status.getStr == "Available",
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
durationMs: mediaInfo{"duration_millis"}.getInt,
variants: parseVideoVariants(mediaInfo{"variants"})
)
of "ApiGif":
result.gif = some Gif(
url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr
)
else: discard
# Remove media URLs from text
with mediaList, js{"legacy", "entities", "media"}:
for url in mediaList:
let expandedUrl = url.getExpandedUrl
if result.text.endsWith(expandedUrl):
result.text.removeSuffix(expandedUrl)
result.text = result.text.strip()
proc parsePromoVideo(js: JsonNode): Video = proc parsePromoVideo(js: JsonNode): Video =
result = Video( result = Video(
@@ -207,7 +268,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
for u in ? urls: for u in ? urls:
if u{"url"}.getStr == result.url: if u{"url"}.getStr == result.url:
result.url = u{"expanded_url"}.getStr result.url = u.getExpandedUrl(result.url)
break break
if kind in {videoDirectMessage, imageDirectMessage}: if kind in {videoDirectMessage, imageDirectMessage}:
@@ -219,12 +280,17 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
if js.isNull: return if js.isNull: return
let time =
if js{"created_at"}.notNull: js{"created_at"}.getTime
else: js{"created_at_ms"}.getTimeFromMs
result = Tweet( result = Tweet(
id: js{"id_str"}.getId, id: js{"id_str"}.getId,
threadId: js{"conversation_id_str"}.getId, threadId: js{"conversation_id_str"}.getId,
replyId: js{"in_reply_to_status_id_str"}.getId, replyId: js{"in_reply_to_status_id_str"}.getId,
text: js{"full_text"}.getStr, text: js{"full_text"}.getStr,
time: js{"created_at"}.getTime, time: time,
hasThread: js{"self_thread"}.notNull, hasThread: js{"self_thread"}.notNull,
sensitive: js{"possibly_sensitive"}.getBool, sensitive: js{"possibly_sensitive"}.getBool,
available: true, available: true,
@@ -233,7 +299,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
replies: js{"reply_count"}.getInt, replies: js{"reply_count"}.getInt,
retweets: js{"retweet_count"}.getInt, retweets: js{"retweet_count"}.getInt,
likes: js{"favorite_count"}.getInt, likes: js{"favorite_count"}.getInt,
quotes: js{"quote_count"}.getInt,
views: js{"views_count"}.getInt views: js{"views_count"}.getInt
) )
) )
@@ -259,6 +324,12 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.retweet = some parseGraphTweet(rt) result.retweet = some parseGraphTweet(rt)
return return
with reposts, js{"repostedStatusResults"}:
with rt, reposts{"result"}:
if "legacy" in rt:
result.retweet = some parseGraphTweet(rt)
return
if jsCard.kind != JNull: if jsCard.kind != JNull:
let name = jsCard{"name"}.getStr let name = jsCard{"name"}.getStr
if "poll" in name: if "poll" in name:
@@ -272,27 +343,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.card = some parseCard(jsCard, js{"entities", "urls"}) result.card = some parseCard(jsCard, js{"entities", "urls"})
result.expandTweetEntities(js) result.expandTweetEntities(js)
parseLegacyMediaEntities(js, result)
with jsMedia, js{"extended_entities", "media"}:
for m in jsMedia:
case m{"type"}.getStr
of "photo":
result.photos.add m{"media_url_https"}.getImageStr
of "video":
result.video = some(parseVideo(m))
with user, m{"additional_media_info", "source_user"}:
if user{"id"}.getInt > 0:
result.attribution = some(parseUser(user))
else:
result.attribution = some(parseGraphUser(user))
of "animated_gif":
result.gif = some(parseGif(m))
else: discard
with url, m{"url"}:
if result.text.endsWith(url.getStr):
result.text.removeSuffix(url.getStr)
result.text = result.text.strip()
with jsWithheld, js{"withheld_in_countries"}: with jsWithheld, js{"withheld_in_countries"}:
let withheldInCountries: seq[string] = let withheldInCountries: seq[string] =
@@ -308,95 +359,108 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.text.removeSuffix(" Learn more.") result.text.removeSuffix(" Learn more.")
result.available = false result.available = false
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet = proc parseGraphTweet(js: JsonNode): Tweet =
if js.kind == JNull: if js.kind == JNull:
return Tweet() return Tweet()
case js{"__typename"}.getStr case js.getTypeName:
of "TweetUnavailable": of "TweetUnavailable":
return Tweet() return Tweet()
of "TweetTombstone": of "TweetTombstone":
with text, js{"tombstone", "richText"}: with text, select(js{"tombstone", "richText"}, js{"tombstone", "text"}):
return Tweet(text: text.getTombstone)
with text, js{"tombstone", "text"}:
return Tweet(text: text.getTombstone) return Tweet(text: text.getTombstone)
return Tweet() return Tweet()
of "TweetPreviewDisplay": of "TweetPreviewDisplay":
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.") return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
of "TweetWithVisibilityResults": of "TweetWithVisibilityResults":
return parseGraphTweet(js{"tweet"}, isLegacy) return parseGraphTweet(js{"tweet"})
else: else:
discard discard
if not js.hasKey("legacy"): if not js.hasKey("legacy"):
return Tweet() return Tweet()
var jsCard = copy(js{if isLegacy: "card" else: "tweet_card", "legacy"}) var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
if jsCard.kind != JNull: if jsCard.kind != JNull:
var values = newJObject() let legacyCard = jsCard{"legacy"}
for val in jsCard["binding_values"]: if legacyCard.kind != JNull:
values[val["key"].getStr] = val["value"] let bindingArray = legacyCard{"binding_values"}
jsCard["binding_values"] = values if bindingArray.kind == JArray:
var bindingObj: seq[(string, JsonNode)]
for item in bindingArray:
bindingObj.add((item{"key"}.getStr, item{"value"}))
# Create a new card object with flattened structure
jsCard = %*{
"name": legacyCard{"name"},
"url": legacyCard{"url"},
"binding_values": %bindingObj
}
result = parseTweet(js{"legacy"}, jsCard) result = parseTweet(js{"legacy"}, jsCard)
result.id = js{"rest_id"}.getId result.id = js{"rest_id"}.getId
result.user = parseGraphUser(js{"core"}) result.user = parseGraphUser(js{"core"})
if result.replyId == 0:
result.replyId = js{"reply_to_results", "rest_id"}.getId
with count, js{"views", "count"}: with count, js{"views", "count"}:
result.stats.views = count.getStr("0").parseInt result.stats.views = count.getStr("0").parseInt
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}: with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
result.expandNoteTweetEntities(noteTweet) result.expandNoteTweetEntities(noteTweet)
parseMediaEntities(js, result)
if result.quote.isSome: if result.quote.isSome:
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy)) result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
with quoted, js{"quotedPostResults", "result"}:
result.quote = some(parseGraphTweet(quoted))
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
for t in js{"content", "items"}: for t in ? js{"content", "items"}:
let entryId = t{"entryId"}.getStr let entryId = t.getEntryId
if "cursor-showmore" in entryId: if "cursor-showmore" in entryId:
let cursor = t{"item", "content", "value"} let cursor = t{"item", "content", "value"}
result.thread.cursor = cursor.getStr result.thread.cursor = cursor.getStr
result.thread.hasMore = true result.thread.hasMore = true
elif "tweet" in entryId and "promoted" notin entryId: elif "tweet" in entryId and "promoted" notin entryId:
let with tweet, t.getTweetResult("item"):
isLegacy = t{"item"}.hasKey("itemContent") result.thread.content.add parseGraphTweet(tweet)
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
else: ("content", "tweetResult")
with content, t{"item", contentKey}: let tweetDisplayType = select(
result.thread.content.add parseGraphTweet(content{resultKey, "result"}, isLegacy) t{"item", "content", "tweet_display_type"},
t{"item", "itemContent", "tweetDisplayType"}
if content{"tweetDisplayType"}.getStr == "SelfThread": )
if tweetDisplayType.getStr == "SelfThread":
result.self = true result.self = true
proc parseGraphTweetResult*(js: JsonNode): Tweet = proc parseGraphTweetResult*(js: JsonNode): Tweet =
with tweet, js{"data", "tweet_result", "result"}: with tweet, js{"data", "tweet_result", "result"}:
result = parseGraphTweet(tweet, false) result = parseGraphTweet(tweet)
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result = Conversation(replies: Result[Chain](beginning: true)) result = Conversation(replies: Result[Chain](beginning: true))
let
v2 = js{"data", "timeline_response"}.notNull
rootKey = if v2: "timeline_response" else: "threaded_conversation_with_injections_v2"
contentKey = if v2: "content" else: "itemContent"
resultKey = if v2: "tweetResult" else: "tweet_results"
let instructions = ? js{"data", rootKey, "instructions"} let instructions = ? select(
js{"data", "timelineResponse", "instructions"},
js{"data", "timeline_response", "instructions"},
js{"data", "threaded_conversation_with_injections_v2", "instructions"}
)
if instructions.len == 0: if instructions.len == 0:
return return
for i in instructions: for i in instructions:
let instrType = i{"type"}.getStr(i{"__typename"}.getStr) if i.getTypeName == "TimelineAddEntries":
if instrType == "TimelineAddEntries":
for e in i{"entries"}: for e in i{"entries"}:
let entryId = e{"entryId"}.getStr let entryId = e.getEntryId
if entryId.startsWith("tweet"): if entryId.startsWith("tweet"):
with tweetResult, e{"content", contentKey, resultKey, "result"}: let tweetResult = getTweetResult(e)
let tweet = parseGraphTweet(tweetResult, not v2) if tweetResult.notNull:
let tweet = parseGraphTweet(tweetResult)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId()) tweet.id = entryId.getId
if $tweet.id == tweetId: if $tweet.id == tweetId:
result.tweet = tweet result.tweet = tweet
@@ -409,67 +473,65 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
elif thread.content.len > 0: elif thread.content.len > 0:
result.replies.content.add thread result.replies.content.add thread
elif entryId.startsWith("tombstone"): elif entryId.startsWith("tombstone"):
let id = entryId.getId() let
let tweet = Tweet( content = select(e{"content", "content"}, e{"content", "itemContent"})
id: parseBiggestInt(id), tweet = Tweet(
available: false, id: entryId.getId,
text: e{"content", contentKey, "tombstoneInfo", "richText"}.getTombstone available: false,
) text: content{"tombstoneInfo", "richText"}.getTombstone
)
if id == tweetId: if $tweet.id == tweetId:
result.tweet = tweet result.tweet = tweet
else: else:
result.before.content.add tweet result.before.content.add tweet
elif entryId.startsWith("cursor-bottom"): elif entryId.startsWith("cursor-bottom"):
result.replies.bottom = e{"content", contentKey, "value"}.getStr var cursorValue = select(
e{"content", "value"},
e{"content", "content", "value"},
e{"content", "itemContent", "value"}
)
result.replies.bottom = cursorValue.getStr
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
var tweetResult = e{"content", "itemContent", "tweet_results", "result"} with tweetResult, getTweetResult(e):
if tweetResult.isNull: var tweet = parseGraphTweet(tweetResult)
tweetResult = e{"content", "content", "tweetResult", "result"}
if tweetResult.notNull:
var tweet = parseGraphTweet(tweetResult, false)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(e.getEntryId()) tweet.id = e.getEntryId.getId
result.add tweet result.add tweet
return return
for item in e{"content", "items"}: for item in e{"content", "items"}:
with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: with tweetResult, item.getTweetResult("item"):
var tweet = parseGraphTweet(tweetResult, false) var tweet = parseGraphTweet(tweetResult)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) tweet.id = item.getEntryId.getId
result.add tweet result.add tweet
proc parseGraphTimeline*(js: JsonNode; after=""): Profile = proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
result = Profile(tweets: Timeline(beginning: after.len == 0)) result = Profile(tweets: Timeline(beginning: after.len == 0))
let instructions = let instructions = ? select(
if js{"data", "list"}.notNull: js{"data", "list", "timeline_response", "timeline", "instructions"},
? js{"data", "list", "timeline_response", "timeline", "instructions"} js{"data", "user", "result", "timeline", "timeline", "instructions"},
elif js{"data", "user"}.notNull: js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
? js{"data", "user", "result", "timeline", "timeline", "instructions"} )
else:
? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
if instructions.len == 0: if instructions.len == 0:
return return
for i in instructions: for i in instructions:
# TimelineAddToModule instruction is used by UserMedia
if i{"moduleItems"}.notNull: if i{"moduleItems"}.notNull:
for item in i{"moduleItems"}: for item in i{"moduleItems"}:
with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: with tweetResult, item.getTweetResult("item"):
let tweet = parseGraphTweet(tweetResult, false) let tweet = parseGraphTweet(tweetResult)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) tweet.id = item.getEntryId.getId
result.tweets.content.add tweet result.tweets.content.add tweet
continue continue
if i{"entries"}.notNull: if i{"entries"}.notNull:
for e in i{"entries"}: for e in i{"entries"}:
let entryId = e{"entryId"}.getStr let entryId = e.getEntryId
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
for tweet in extractTweetsFromEntry(e): for tweet in extractTweetsFromEntry(e):
result.tweets.content.add tweet result.tweets.content.add tweet
@@ -480,8 +542,7 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
result.tweets.bottom = e{"content", "value"}.getStr result.tweets.bottom = e{"content", "value"}.getStr
if after.len == 0: if after.len == 0:
let instrType = i{"type"}.getStr(i{"__typename"}.getStr) if i.getTypeName == "TimelinePinEntry":
if instrType == "TimelinePinEntry":
let tweets = extractTweetsFromEntry(i{"entry"}) let tweets = extractTweetsFromEntry(i{"entry"})
if tweets.len > 0: if tweets.len > 0:
var tweet = tweets[0] var tweet = tweets[0]
@@ -491,23 +552,20 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
result = @[] result = @[]
let instructions = let instructions = select(
if js{"data", "user"}.notNull: js{"data", "user", "result", "timeline", "timeline", "instructions"},
? js{"data", "user", "result", "timeline", "timeline", "instructions"} js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
else: )
? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
if instructions.len == 0: if instructions.len == 0:
return return
for i in instructions: for i in instructions:
# TimelineAddToModule instruction is used by MediaTimelineV2
if i{"moduleItems"}.notNull: if i{"moduleItems"}.notNull:
for item in i{"moduleItems"}: for item in i{"moduleItems"}:
with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: with tweetResult, item.getTweetResult("item"):
let t = parseGraphTweet(tweetResult, false) let t = parseGraphTweet(tweetResult)
if not t.available: if not t.available:
t.id = parseBiggestInt(item{"entryId"}.getStr.getId()) t.id = item.getEntryId.getId
let photo = extractGalleryPhoto(t) let photo = extractGalleryPhoto(t)
if photo.url.len > 0: if photo.url.len > 0:
@@ -517,12 +575,11 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
return return
continue continue
let instrType = i{"type"}.getStr(i{"__typename"}.getStr) if i.getTypeName != "TimelineAddEntries":
if instrType != "TimelineAddEntries":
continue continue
for e in i{"entries"}: for e in i{"entries"}:
let entryId = e{"entryId"}.getStr let entryId = e.getEntryId
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
for t in extractTweetsFromEntry(e): for t in extractTweetsFromEntry(e):
let photo = extractGalleryPhoto(t) let photo = extractGalleryPhoto(t)
@@ -535,21 +592,24 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
result = Result[T](beginning: after.len == 0) result = Result[T](beginning: after.len == 0)
let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"} let instructions = select(
js{"data", "search", "timeline_response", "timeline", "instructions"},
js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
)
if instructions.len == 0: if instructions.len == 0:
return return
for instruction in instructions: for instruction in instructions:
let typ = instruction{"type"}.getStr let typ = getTypeName(instruction)
if typ == "TimelineAddEntries": if typ == "TimelineAddEntries":
for e in instruction{"entries"}: for e in instruction{"entries"}:
let entryId = e{"entryId"}.getStr let entryId = e.getEntryId
when T is Tweets: when T is Tweets:
if entryId.startsWith("tweet"): if entryId.startsWith("tweet"):
with tweetRes, e{"content", "itemContent", "tweet_results", "result"}: with tweetRes, getTweetResult(e):
let tweet = parseGraphTweet(tweetRes) let tweet = parseGraphTweet(tweetRes)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId()) tweet.id = entryId.getId
result.content.add tweet result.content.add tweet
elif T is User: elif T is User:
if entryId.startsWith("user"): if entryId.startsWith("user"):

View File

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import std/[times, macros, htmlgen, options, algorithm, re, logging] import std/[times, macros, htmlgen, options, algorithm, re]
import std/strutils except escape import std/strutils except escape
import std/unicode except strip import std/unicode except strip
from xmltree import escape from xmltree import escape
@@ -36,6 +36,12 @@ template `?`*(js: JsonNode): untyped =
if j.isNull: return if j.isNull: return
j j
template select*(a, b: JsonNode): untyped =
if a.notNull: a else: b
template select*(a, b, c: JsonNode): untyped =
if a.notNull: a elif b.notNull: b else: c
template with*(ident, value, body): untyped = template with*(ident, value, body): untyped =
if true: if true:
let ident {.inject.} = value let ident {.inject.} = value
@@ -44,8 +50,7 @@ template with*(ident, value, body): untyped =
template with*(ident; value: JsonNode; body): untyped = template with*(ident; value: JsonNode; body): untyped =
if true: if true:
let ident {.inject.} = value let ident {.inject.} = value
# value.notNull causes a compilation error for versions < 1.6.14 if value.notNull: body
if notNull(value): body
template getCursor*(js: JsonNode): string = template getCursor*(js: JsonNode): string =
js{"content", "operation", "cursor", "value"}.getStr js{"content", "operation", "cursor", "value"}.getStr
@@ -54,6 +59,20 @@ template getError*(js: JsonNode): Error =
if js.kind != JArray or js.len == 0: null if js.kind != JArray or js.len == 0: null
else: Error(js[0]{"code"}.getInt) else: Error(js[0]{"code"}.getInt)
proc getTweetResult*(js: JsonNode; root="content"): JsonNode =
select(
js{root, "content", "tweet_results", "result"},
js{root, "itemContent", "tweet_results", "result"},
js{root, "content", "tweetResult", "result"}
)
template getTypeName*(js: JsonNode): string =
js{"__typename"}.getStr(js{"type"}.getStr)
template getEntryId*(e: JsonNode): string =
e{"entryId"}.getStr(e{"entry_id"}.getStr)
template parseTime(time: string; f: static string; flen: int): DateTime = template parseTime(time: string; f: static string; flen: int): DateTime =
if time.len != flen: return if time.len != flen: return
parse(time, f, utc()) parse(time, f, utc())
@@ -64,29 +83,24 @@ proc getDateTime*(js: JsonNode): DateTime =
proc getTime*(js: JsonNode): DateTime = proc getTime*(js: JsonNode): DateTime =
parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30) parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
proc getId*(id: string): string {.inline.} = proc getTimeFromMs*(js: JsonNode): DateTime =
let ms = js.getInt(0)
if ms == 0: return
let seconds = ms div 1000
return fromUnix(seconds).utc()
proc getId*(id: string): int64 {.inline.} =
let start = id.rfind("-") let start = id.rfind("-")
if start < 0: return id if start < 0:
id[start + 1 ..< id.len] return parseBiggestInt(id)
return parseBiggestInt(id[start + 1 ..< id.len])
proc getId*(js: JsonNode): int64 {.inline.} = proc getId*(js: JsonNode): int64 {.inline.} =
case js.kind case js.kind
of JString: return parseBiggestInt(js.getStr("0")) of JString: return js.getStr("0").getId
of JInt: return js.getBiggestInt() of JInt: return js.getBiggestInt()
else: return 0 else: return 0
proc getEntryId*(js: JsonNode): string {.inline.} =
let entry = js{"entryId"}.getStr
if entry.len == 0: return
if "tweet" in entry or "sq-I-t" in entry:
return entry.getId
elif "tombstone" in entry:
return js{"content", "item", "content", "tombstone", "tweet", "id"}.getStr
else:
warn "unknown entry: ", entry
return
template getStrVal*(js: JsonNode; default=""): string = template getStrVal*(js: JsonNode; default=""): string =
js{"string_value"}.getStr(default) js{"string_value"}.getStr(default)
@@ -98,6 +112,9 @@ proc getImageStr*(js: JsonNode): string =
template getImageVal*(js: JsonNode): string = template getImageVal*(js: JsonNode): string =
js{"image_value", "url"}.getImageStr js{"image_value", "url"}.getImageStr
template getExpandedUrl*(js: JsonNode; fallback=""): string =
js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
proc getCardUrl*(js: JsonNode; kind: CardKind): string = proc getCardUrl*(js: JsonNode; kind: CardKind): string =
result = js{"website_url"}.getStrVal result = js{"website_url"}.getStrVal
if kind == promoVideoConvo: if kind == promoVideoConvo:
@@ -157,19 +174,13 @@ proc getMp4Resolution*(url: string): int =
# cannot determine resolution (e.g. m3u8/non-mp4 video) # cannot determine resolution (e.g. m3u8/non-mp4 video)
return 0 return 0
proc getVideoViewCount*(js: JsonNode): string =
with stats, js{"ext_media_stats"}:
return stats{"view_count"}.getStr($stats{"viewCount"}.getInt)
return $js{"mediaStats", "viewCount"}.getInt(0)
proc extractSlice(js: JsonNode): Slice[int] = proc extractSlice(js: JsonNode): Slice[int] =
result = js["indices"][0].getInt ..< js["indices"][1].getInt result = js["indices"][0].getInt ..< js["indices"][1].getInt
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode; proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
textLen: int; hideTwitter = false) = textLen: int; hideTwitter = false) =
let let
url = js["expanded_url"].getStr url = js.getExpandedUrl
slice = js.extractSlice slice = js.extractSlice
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
@@ -230,7 +241,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
ent = ? js{"entities"} ent = ? js{"entities"}
with urls, ent{"url", "urls"}: with urls, ent{"url", "urls"}:
user.website = urls[0]{"expanded_url"}.getStr user.website = urls[0].getExpandedUrl
var replacements = newSeq[ReplaceSlice]() var replacements = newSeq[ReplaceSlice]()
@@ -260,7 +271,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
if hasCard and u{"url"}.getStr == get(tweet.card).url: if hasCard and u{"url"}.getStr == get(tweet.card).url:
get(tweet.card).url = u{"expanded_url"}.getStr get(tweet.card).url = u.getExpandedUrl
with media, entities{"media"}: with media, entities{"media"}:
for m in media: for m in media:

View File

@@ -6,10 +6,9 @@ import types
const const
validFilters* = @[ validFilters* = @[
"media", "images", "twimg", "videos", "media", "images", "twimg", "videos",
"native_video", "consumer_video", "pro_video", "native_video", "consumer_video", "spaces",
"links", "news", "quote", "mentions", "links", "news", "quote", "mentions",
"replies", "retweets", "nativeretweets", "replies", "retweets", "nativeretweets"
"verified", "safe"
] ]
emptyQuery* = "include:nativeretweets" emptyQuery* = "include:nativeretweets"
@@ -18,6 +17,11 @@ template `@`(param: string): untyped =
if param in pms: pms[param] if param in pms: pms[param]
else: "" else: ""
proc validateNumber(value: string): string =
if value.anyIt(not it.isDigit):
return ""
return value
proc initQuery*(pms: Table[string, string]; name=""): Query = proc initQuery*(pms: Table[string, string]; name=""): Query =
result = Query( result = Query(
kind: parseEnum[QueryKind](@"f", tweets), kind: parseEnum[QueryKind](@"f", tweets),
@@ -26,7 +30,8 @@ proc initQuery*(pms: Table[string, string]; name=""): Query =
excludes: validFilters.filterIt("e-" & it in pms), excludes: validFilters.filterIt("e-" & it in pms),
since: @"since", since: @"since",
until: @"until", until: @"until",
near: @"near" near: @"near",
minLikes: validateNumber(@"min_faves")
) )
if name.len > 0: if name.len > 0:
@@ -59,8 +64,11 @@ proc genQueryParam*(query: Query): string =
if i < query.fromUser.high: if i < query.fromUser.high:
param &= "OR " param &= "OR "
if query.fromUser.len > 0 and query.kind in {posts, media}: if query.fromUser.len > 0:
param &= "filter:self_threads OR -filter:replies " if query.kind in {posts, media}:
param &= "filter:self_threads OR -filter:replies "
elif query.kind == tweets and query.fromUser.len > 1:
param &= "filter:self_threads OR -filter:replies "
if "nativeretweets" notin query.excludes: if "nativeretweets" notin query.excludes:
param &= "include:nativeretweets " param &= "include:nativeretweets "
@@ -79,7 +87,9 @@ proc genQueryParam*(query: Query): string =
if query.until.len > 0: if query.until.len > 0:
result &= " until:" & query.until result &= " until:" & query.until
if query.near.len > 0: if query.near.len > 0:
result &= &" near:\"{query.near}\" within:15mi" result &= " near:\"" & query.near & "\""
if query.minLikes.len > 0:
result &= " min_faves:" & query.minLikes
if query.text.len > 0: if query.text.len > 0:
if result.len > 0: if result.len > 0:
result &= " " & query.text result &= " " & query.text
@@ -105,6 +115,8 @@ proc genQueryUrl*(query: Query): string =
params.add "until=" & query.until params.add "until=" & query.until
if query.near.len > 0: if query.near.len > 0:
params.add "near=" & query.near params.add "near=" & query.near
if query.minLikes.len > 0:
params.add "min_faves=" & query.minLikes
if params.len > 0: if params.len > 0:
result &= params.join("&") result &= params.join("&")

View File

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, sequtils, uri import strutils, sequtils
import jester import jester
import router_utils import router_utils
import ".."/[types] import ".."/[types]

View File

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils, sequtils, uri, options, sugar, logging import asyncdispatch, strutils, sequtils, uri, options, sugar
import jester, karax/vdom import jester, karax/vdom
@@ -31,8 +31,6 @@ proc createStatusRouter*(cfg: Config) =
resp $renderReplies(replies, prefs, getPath()) resp $renderReplies(replies, prefs, getPath())
let conv = await getTweet(id, getCursor()) let conv = await getTweet(id, getCursor())
if conv == nil:
warn "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id == 0: if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Tweet not found" var error = "Tweet not found"
@@ -68,7 +66,7 @@ proc createStatusRouter*(cfg: Config) =
get "/@name/@s/@id/@m/?@i?": get "/@name/@s/@id/@m/?@i?":
cond @"s" in ["status", "statuses"] cond @"s" in ["status", "statuses"]
cond @"m" in ["video", "photo"] cond @"m" in ["video", "photo", "history"]
redirect("/$1/status/$2" % [@"name", @"id"]) redirect("/$1/status/$2" % [@"name", @"id"])
get "/@name/statuses/@id/?": get "/@name/statuses/@id/?":
@@ -76,6 +74,6 @@ proc createStatusRouter*(cfg: Config) =
get "/i/web/status/@id": get "/i/web/status/@id":
redirect("/i/status/" & @"id") redirect("/i/status/" & @"id")
get "/@name/thread/@id/?": get "/@name/thread/@id/?":
redirect("/$1/status/$2" % [@"name", @"id"]) redirect("/$1/status/$2" % [@"name", @"id"])

View File

@@ -105,6 +105,12 @@ proc createTimelineRouter*(cfg: Config) =
get "/intent/user": get "/intent/user":
respUserId() respUserId()
get "/intent/follow/?":
let username = request.params.getOrDefault("screen_name")
if username.len == 0:
resp Http400, showError("Missing screen_name parameter", cfg)
redirect("/" & username)
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"]

View File

@@ -17,7 +17,7 @@ proc createUnsupportedRouter*(cfg: Config) =
get "/@name/lists/?": feature() get "/@name/lists/?": feature()
get "/intent/?@i?": get "/intent/?@i?":
cond @"i" notin ["user"] cond @"i" notin ["user", "follow"]
feature() feature()
get "/i/@i?/?@j?": get "/i/@i?/?@j?":

53
src/sass/homepage.scss Normal file
View File

@@ -0,0 +1,53 @@
@import '_variables';
@import '_mixins';
.homepage-container {
display: flex;
gap: 5px;
max-width: 1200px;
margin: 0 auto;
justify-content: center;
}
.following-column,
.timeline-users-column {
width: 280px;
flex-shrink: 0;
}
.timeline-users-column {
.timeline-item {
flex-direction: column;
}
}
.profile-cards {
display: flex;
flex-direction: column;
gap: 5px;
}
.timeline-users-column > :first-child,
.homepage-container .timeline-container > .timeline > :first-child {
margin-top: 0;
}
@media (max-width: 1200px) {
.homepage-container {
max-width: 100%;
padding: 0 10px;
}
}
@media (max-width: 1100px) {
.following-column {
display: none;
}
}
@media (max-width: 800px) {
.timeline-users-column {
display: none;
}
}

View File

@@ -7,6 +7,7 @@
@import 'inputs'; @import 'inputs';
@import 'timeline'; @import 'timeline';
@import 'search'; @import 'search';
@import 'homepage';
body { body {
// colors // colors
@@ -51,7 +52,7 @@ body {
background-color: var(--bg_color); background-color: var(--bg_color);
color: var(--fg_color); color: var(--fg_color);
font-family: $font_stack; font-family: $font_stack;
font-size: 14px; font-size: 15px;
line-height: 1.3; line-height: 1.3;
margin: 0; margin: 0;
} }

View File

@@ -14,6 +14,7 @@ button {
input[type="text"], input[type="text"],
input[type="date"], input[type="date"],
input[type="number"],
select { select {
@include input-colors; @include input-colors;
background-color: var(--bg_elements); background-color: var(--bg_elements);
@@ -24,7 +25,12 @@ select {
font-size: 14px; font-size: 14px;
} }
input[type="text"] { input[type="number"] {
-moz-appearance: textfield;
}
input[type="text"],
input[type="number"] {
height: 16px; height: 16px;
} }
@@ -38,6 +44,17 @@ input[type="date"]::-webkit-inner-spin-button {
display: none; display: none;
} }
input[type="number"] {
-moz-appearance: textfield;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
display: none;
-webkit-appearance: none;
margin: 0;
}
input[type="date"]::-webkit-clear-button { input[type="date"]::-webkit-clear-button {
margin-left: 17px; margin-left: 17px;
filter: grayscale(100%); filter: grayscale(100%);
@@ -176,7 +193,8 @@ input::-webkit-datetime-edit-year-field:focus {
color: var(--fg_color); color: var(--fg_color);
} }
input[type="text"] { input[type="text"],
input[type="number"] {
position: absolute; position: absolute;
right: 0; right: 0;
max-width: 140px; max-width: 140px;

View File

@@ -27,7 +27,8 @@
min-width: 0; min-width: 0;
} }
input[type="text"] { input[type="text"],
input[type="number"] {
height: calc(100% - 4px); height: calc(100% - 4px);
width: calc(100% - 8px); width: calc(100% - 8px);
} }
@@ -155,15 +156,14 @@
.profile-tabs { .profile-tabs {
@include search-resize(820px, 5); @include search-resize(820px, 5);
@include search-resize(725px, 4); @include search-resize(715px, 4);
@include search-resize(600px, 6); @include search-resize(700px, 5);
@include search-resize(560px, 5); @include search-resize(485px, 4);
@include search-resize(480px, 4);
@include search-resize(410px, 3); @include search-resize(410px, 3);
} }
@include search-resize(560px, 5); @include search-resize(700px, 5);
@include search-resize(480px, 4); @include search-resize(485px, 4);
@include search-resize(410px, 3); @include search-resize(410px, 3);
@media(max-width: 600px) { @media(max-width: 600px) {

View File

@@ -26,6 +26,25 @@
button { button {
float: unset; float: unset;
} }
&.timeline-default {
background-color: var(--bg_panel);
border-bottom: 1px solid var(--bg_elements);
margin-bottom: 5px;
.timeline-default-message {
color: var(--fg_color);
font-size: 16px;
font-weight: normal;
margin: 0;
padding: 5px;
strong {
font-weight: bold;
color: var(--accent);
}
}
}
} }
.timeline-banner img { .timeline-banner img {

62
src/tid.nim Normal file
View File

@@ -0,0 +1,62 @@
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
import nimcrypto
import experimental/parser/tid
randomize()
const defaultKeyword = "obfiowerehiring";
const pairsUrl =
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
var
cachedPairs: seq[TidPair] = @[]
lastCached = 0
# refresh every hour
ttlSec = 60 * 60
proc getPair(): Future[TidPair] {.async.} =
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
lastCached = int(epochTime())
let client = newAsyncHttpClient()
defer: client.close()
let resp = await client.get(pairsUrl)
if resp.status == $Http200:
cachedPairs = parseTidPairs(await resp.body)
return sample(cachedPairs)
proc encodeSha256(text: string): array[32, byte] =
let
data = cast[ptr byte](addr text[0])
dataLen = uint(len(text))
digest = sha256.digest(data, dataLen)
return digest.data
proc encodeBase64[T](data: T): string =
return encode(data).replace("=", "")
proc decodeBase64(data: string): seq[byte] =
return cast[seq[byte]](decode(data))
proc genTid*(path: string): Future[string] {.async.} =
let
pair = await getPair()
timeNow = int(epochTime() - 1682924400)
timeNowBytes = @[
byte(timeNow and 0xff),
byte((timeNow shr 8) and 0xff),
byte((timeNow shr 16) and 0xff),
byte((timeNow shr 24) and 0xff)
]
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
hashBytes = encodeSha256(data)
keyBytes = decodeBase64(pair.verification)
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
randomNum = byte(rand(256))
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
return encodeBase64(tid)

View File

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import times, sequtils, options, tables, uri import times, sequtils, options, tables
import prefs_impl import prefs_impl
genPrefsType() genPrefsType()
@@ -13,19 +13,13 @@ type
TimelineKind* {.pure.} = enum TimelineKind* {.pure.} = enum
tweets, replies, media tweets, replies, media
Api* {.pure.} = enum ApiUrl* = object
tweetDetail endpoint*: string
tweetResult params*: seq[(string, string)]
search
list ApiReq* = object
listBySlug oauth*: ApiUrl
listMembers cookie*: ApiUrl
listTweets
userRestId
userScreenName
userTweets
userTweetsAndReplies
userMedia
RateLimit* = object RateLimit* = object
limit*: int limit*: int
@@ -42,7 +36,7 @@ type
pending*: int pending*: int
limited*: bool limited*: bool
limitedAt*: int limitedAt*: int
apis*: Table[Api, RateLimit] apis*: Table[string, RateLimit]
case kind*: SessionKind case kind*: SessionKind
of oauth: of oauth:
oauthToken*: string oauthToken*: string
@@ -51,10 +45,6 @@ type
authToken*: string authToken*: string
ct0*: string ct0*: string
SessionAwareUrl* = object
oauthUrl*: Uri
cookieUrl*: Uri
Error* = enum Error* = enum
null = 0 null = 0
noUserMatches = 17 noUserMatches = 17
@@ -122,7 +112,6 @@ type
durationMs*: int durationMs*: int
url*: string url*: string
thumb*: string thumb*: string
views*: string
available*: bool available*: bool
reason*: string reason*: string
title*: string title*: string
@@ -143,6 +132,7 @@ type
since*: string since*: string
until*: string until*: string
near*: string near*: string
minLikes*: string
sep*: string sep*: string
Gif* = object Gif* = object
@@ -203,7 +193,6 @@ type
replies*: int replies*: int
retweets*: int retweets*: int
likes*: int likes*: int
quotes*: int
views*: int views*: int
Tweet* = ref object Tweet* = ref object
@@ -290,6 +279,8 @@ type
logLevel*: string logLevel*: string
proxy*: string proxy*: string
proxyAuth*: string proxyAuth*: string
defaultFollowedAccounts*: seq[string]
disableTid*: bool
rssCacheTime*: int rssCacheTime*: int
listCacheTime*: int listCacheTime*: int

View File

@@ -33,14 +33,14 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
tdiv(class="nav-item right"): tdiv(class="nav-item right"):
if cfg.enableRss and rss.len > 0: if cfg.enableRss and rss.len > 0:
icon "rss", title="RSS Feed", href=rss icon "rss", title="RSS Feed", href=rss
icon "bird", title="Open in Twitter", href=canonical icon "bird", title="Open in X", href=canonical
a(href="https://buymeacoffee.com/kuu7o"): verbatim lp a(href="https://buymeacoffee.com/kuu7o"): verbatim lp
icon "info", title="About", href="/about" icon "info", title="About", href="/about"
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle=""; video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; canonical=""): VNode = rss=""; alternate=""): VNode =
var theme = prefs.theme.toTheme var theme = prefs.theme.toTheme
if "theme" in req.params: if "theme" in req.params:
theme = req.params["theme"].toTheme theme = req.params["theme"].toTheme
@@ -54,7 +54,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): buildHtml(head):
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") link(rel="stylesheet", type="text/css", href="/css/style.css?v=21")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
if theme.len > 0: if theme.len > 0:
@@ -68,8 +68,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
href=opensearchUrl) href=opensearchUrl)
if canonical.len > 0: if alternate.len > 0:
link(rel="canonical", href=canonical) link(rel="alternate", href=alternate, title="View on X")
if cfg.enableRss and rss.len > 0: if cfg.enableRss and rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
@@ -131,16 +131,16 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video=""; titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""): string = images: seq[string] = @[]; banner=""): string =
let canonical = getTwitterLink(req.path, req.params) let twitterLink = getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, canonical) rss, twitterLink)
body(class=if prefs.verifiedBadge == "Hide all": "hide-verified-all" body(class=if prefs.verifiedBadge == "Hide all": "hide-verified-all"
elif prefs.verifiedBadge == "Show official only": "hide-verified-blue" elif prefs.verifiedBadge == "Show official only": "hide-verified-blue"
else: ""): else: ""):
renderNavbar(cfg, req, rss, canonical) renderNavbar(cfg, req, rss, twitterLink)
tdiv(class="container"): tdiv(class="container"):
body body

19
src/views/homepage.nim Normal file
View File

@@ -0,0 +1,19 @@
# SPDX-License-Identifier: AGPL-3.0-only
import karax/[karaxdsl, vdom]
import timeline, profile
import ".."/[types]
proc renderHomepage*(users: seq[User]; timeline: VNode; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="homepage-container")):
tdiv(class="timeline-users-column"):
for user in users:
renderUser(user, prefs, path)
tdiv(class="timeline"):
timeline
tdiv(class="following-column"):
tdiv(class="profile-cards"):
for user in users:
renderUserCard(user, prefs, path)

View File

@@ -89,6 +89,13 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state) input(name=pref, `type`="date", value=state)
icon "calendar" icon "calendar"
proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
let p = placeholder
buildHtml(tdiv(class=("pref-group pref-input " & class))):
if label.len > 0:
label(`for`=pref): text label
input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
proc genImg*(url: string; class=""): VNode = proc genImg*(url: string; class=""): VNode =
buildHtml(): buildHtml():
img(src=getPicUrl(url), class=class, alt="", loading="lazy") img(src=getPicUrl(url), class=class, alt="", loading="lazy")

View File

@@ -9,9 +9,9 @@
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: " #elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
#end if #end if
#var text = stripHtml(tweet.text) #var text = stripHtml(tweet.text)
##if unicode.runeLen(text) > 32: #if unicode.runeLen(text) > 100:
## text = unicode.runeSubStr(text, 0, 32) & "..." # text = unicode.runeSubStr(text, 0, 64) & "..."
##end if #end if
#result &= xmltree.escape(text) #result &= xmltree.escape(text)
#if result.len > 0: return #if result.len > 0: return
#end if #end if
@@ -25,7 +25,7 @@
#end proc #end proc
# #
#proc getDescription(desc: string; cfg: Config): string = #proc getDescription(desc: string; cfg: Config): string =
Twitter feed for: ${desc}. Generated by ${cfg.hostname} Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#end proc #end proc
# #
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] = #proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
@@ -56,7 +56,10 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" /> <img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
# end for # end for
#elif tweet.video.isSome: #elif tweet.video.isSome:
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" /> <a href="${urlPrefix}${tweet.getLink}">
<br>Video<br>
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
</a>
#elif tweet.gif.isSome: #elif tweet.gif.isSome:
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" # let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" # let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
@@ -69,14 +72,17 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
# end if # end if
#end if #end if
#if tweet.quote.isSome and get(tweet.quote).available: #if tweet.quote.isSome and get(tweet.quote).available:
# let quoteLink = getLink(get(tweet.quote)) # let quoteTweet = get(tweet.quote)
# let quoteLink = urlPrefix & getLink(quoteTweet)
<hr/>
<blockquote> <blockquote>
<p> <b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
${renderRssTweet(get(tweet.quote), cfg)} <p>
</p> ${renderRssTweet(quoteTweet, cfg)}
<footer> </p>
— <cite><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></cite> <footer>
</footer> — <cite><a href="${quoteLink}">${quoteLink}</a>
</footer>
</blockquote> </blockquote>
#end if #end if
#end proc #end proc
@@ -101,6 +107,18 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
<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>
# let desc = renderRssTweet(tweet, cfg).strip(chars={'\n'})
# if retweet.len > 0:
<description><![CDATA[
<blockquote>
<b>${tweet.user.fullname} (@${tweet.user.username})</b>
<p>${desc}</p>
<footer>— <cite><a href="${urlPrefix & link}">${urlPrefix & link}</a></footer>
</blockquote>
]]></description>
# else:
<description><![CDATA[${desc}]]></description>
# end if
<pubDate>${getRfc822Time(tweet)}</pubDate> <pubDate>${getRfc822Time(tweet)}</pubDate>
<guid>${urlPrefix & link}</guid> <guid>${urlPrefix & link}</guid>
<link>${urlPrefix & link}</link> <link>${urlPrefix & link}</link>

View File

@@ -50,6 +50,34 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
renderTimelineTweets(results, prefs, path, pinned) renderTimelineTweets(results, prefs, path, pinned)
proc renderHomepageTabs*(query: Query): VNode =
buildHtml(ul(class="tab")):
li(class=if query.kind == tweets: "tab-item active" else: "tab-item"):
a(href="/?f=tweets"): text "Tweets"
li(class=if query.kind == replies: "tab-item active" else: "tab-item"):
a(href="/?f=replies"): text "Tweets & Replies"
proc renderHomepageTimeline*(results: Timeline; prefs: Prefs; path: string): VNode =
let query = results.query
buildHtml(tdiv(class="timeline-container")):
renderHomepageTabs(query)
renderTimelineTweets(results, prefs, path)
proc renderDefaultTimeline*(results: Timeline; prefs: Prefs; path: string): VNode =
let query = results.query
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header timeline-default"):
h2(class="timeline-default-message"):
text "Follow people to populate your "
strong: text "own"
text " feed"
renderHomepageTabs(query)
renderTimelineTweets(results, prefs, path)
proc renderUserSearch*(results: Result[User]; prefs: Prefs; path: string): VNode = proc renderUserSearch*(results: Result[User]; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="timeline-container")): buildHtml(tdiv(class="timeline-container")):
renderSearchTabs(results.query) renderSearchTabs(results.query)

View File

@@ -55,9 +55,9 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
renderTweet(tweet, prefs, path, class=(header & "thread"), renderTweet(tweet, prefs, path, class=(header & "thread"),
index=i, last=(i == thread.high), showThread=show) index=i, last=(i == thread.high), showThread=show)
proc renderUser(user: User; prefs: Prefs; path: string): VNode = proc renderUser*(user: User; prefs: Prefs; path: string): VNode =
let class = if user.sensitive: "timeline-item nsfw" else: "timeline-item" let class = if user.sensitive: "timeline-item nsfw" else: "timeline-item"
buildHtml(tdiv(class=class)): buildHtml(tdiv(class=class, data-username=user.username)):
a(class="tweet-link", href=("/" & user.username)) a(class="tweet-link", href=("/" & user.username))
tdiv(class="tweet-body profile-result"): tdiv(class="tweet-body profile-result"):
tdiv(class="tweet-header"): tdiv(class="tweet-header"):

View File

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, sequtils, strformat, options, algorithm, uri import strutils, sequtils, strformat, options, algorithm
import karax/[karaxdsl, vdom, vstyles] import karax/[karaxdsl, vdom, vstyles]
from jester import Request from jester import Request
@@ -178,18 +178,12 @@ func formatStat(stat: int): string =
if stat > 0: insertSep($stat, ',') if stat > 0: insertSep($stat, ',')
else: "" else: ""
proc renderStats(tweet_id: int64; stats: TweetStats; views: string; published: string): VNode = proc renderStats(stats: TweetStats): VNode =
buildHtml(tdiv(class="tweet-stats")): buildHtml(tdiv(class="tweet-stats")):
span(class="tweet-stat"): icon "comment", formatStat(stats.replies) span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
a(class="tweet-stat", href=("/search?q=" & encodeUrl(&"-from:quotedreplies url:{tweet_id}") & "&e-nativeretweets=on")): icon "quote", formatStat(stats.quotes)
span(class="tweet-stat"): icon "heart", formatStat(stats.likes) span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
if stats.views > 0: span(class="tweet-stat"): icon "views", formatStat(stats.views)
span(class="tweet-stat"): icon "views", formatStat(stats.views)
if views.len > 0:
span(class="tweet-stat"): icon "play", insertSep(views, ',')
if published.len > 0:
span(class="tweet-published"): text published
proc renderReply(tweet: Tweet): VNode = proc renderReply(tweet: Tweet): VNode =
buildHtml(tdiv(class="replying-to")): buildHtml(tdiv(class="replying-to")):
@@ -281,7 +275,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
divClass.add " nsfw" divClass.add " nsfw"
if not tweet.available: if not tweet.available:
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")): return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
tdiv(class="unavailable-box"): tdiv(class="unavailable-box"):
if tweet.tombstone.len > 0: if tweet.tombstone.len > 0:
text tweet.tombstone text tweet.tombstone
@@ -303,12 +297,11 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
tweet = tweet.retweet.get tweet = tweet.retweet.get
retweet = fullTweet.user.fullname retweet = fullTweet.user.fullname
buildHtml(tdiv(class=("timeline-item " & divClass))): buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
if not mainTweet: if not mainTweet:
a(class="tweet-link", href=getLink(tweet)) a(class="tweet-link", href=getLink(tweet))
tdiv(class="tweet-body"): tdiv(class="tweet-body"):
var views = ""
renderHeader(tweet, retweet, pinned, prefs) renderHeader(tweet, retweet, pinned, prefs)
if not afterTweet and index == 0 and tweet.reply.len > 0 and if not afterTweet and index == 0 and tweet.reply.len > 0 and
@@ -332,10 +325,8 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderAlbum(tweet) renderAlbum(tweet)
elif tweet.video.isSome: elif tweet.video.isSome:
renderVideo(tweet.video.get(), prefs, path) renderVideo(tweet.video.get(), prefs, path)
views = tweet.video.get().views
elif tweet.gif.isSome: elif tweet.gif.isSome:
renderGif(tweet.gif.get(), prefs) renderGif(tweet.gif.get(), prefs)
views = "GIF"
if tweet.poll.isSome: if tweet.poll.isSome:
renderPoll(tweet.poll.get()) renderPoll(tweet.poll.get())
@@ -349,7 +340,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderMediaTags(tweet.mediaTags) renderMediaTags(tweet.mediaTags)
if not prefs.hideTweetStats: if not prefs.hideTweetStats:
renderStats(tweet.id, tweet.stats, views, published) renderStats(tweet.stats)
if showThread: if showThread:
a(class="show-thread", href=("/i/status/" & $tweet.threadId)): a(class="show-thread", href=("/i/status/" & $tweet.threadId)):

View File

@@ -11,12 +11,7 @@ card = [
['voidtarget/status/1094632512926605312', ['voidtarget/status/1094632512926605312',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
'gist.github.com', True], 'gist.github.com', True]
['nim_lang/status/1082989146040340480',
'Nim in 2018: A short recap',
'There were several big news in the Nim world in 2018 two new major releases, partnership with Status, and much more. But let us go chronologically.',
'nim-lang.org', True]
] ]
no_thumb = [ no_thumb = [

View File

@@ -94,7 +94,7 @@ async def login_and_get_cookies(username, password, totp_seed=None, headless=Fal
async def main(): async def main():
if len(sys.argv) < 3: if len(sys.argv) < 3:
print('Usage: python3 twitter-auth.py username password [totp_seed] [--append sessions.jsonl] [--headless]') print('Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]')
sys.exit(1) sys.exit(1)
username = sys.argv[1] username = sys.argv[1]