52 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
62a4347b96 fix(css): follow button correct border radius 2025-11-20 08:09:54 -03:00
443996df96 fix: don't show search panel when viewing quote tweets 2025-11-20 08:00:47 -03:00
441ea68fd7 chore+feat(css): added styling for follow button and new search in navbar 2025-11-19 21:06:36 -03:00
2fd13de8e1 feat: added following system using cookies 2025-11-19 18:09:37 -03:00
4e05923cd8 chore(conf): set default theme to Kuuro 2025-11-19 12:28:43 -03:00
5a79ec025a feat(pref): add ability to hide NSFW content 2025-11-19 12:28:26 -03:00
644cda63a0 chore(docs): added new screenshot showcasing forks new theme and layout 2025-11-19 10:08:17 -03:00
Zed
78d788b27f Fix verified parsing for oauth endpoints 2025-11-19 07:33:32 +01:00
49dc4b4f00 fix(css): timeline tweets now are separated by 5px 2025-11-19 01:06:17 -03:00
c654cdf57e feat(theme): added new kuuro theme 2025-11-19 01:05:50 -03:00
a7e8f3add0 feat(conf): ability to set log level in config 2025-11-18 22:14:52 -03:00
ce1139e335 fix(api): view counts now render in timelines and tweet details 2025-11-18 22:13:46 -03:00
3845fb1213 refactor: added standard nim logging library 2025-11-18 21:24:17 -03:00
4df434a7c6 fix(ui): added indication for dropdown in prefences 2025-11-18 19:24:23 -03:00
57a1fc6820 feat(pref): add ability to hide verfied badges 2025-11-18 19:23:45 -03:00
8d87371763 fix(formatters): replacement for on.soundcloud.com now points to soundcloak/on/ 2025-11-18 17:48:44 -03:00
2e2a96d7d8 chore(conf): added missing replacement configuration for new platforms 2025-11-18 15:55:30 -03:00
5e60c28ce5 feat: add SoundCloud -> SoundCloak replacement support 2025-11-18 15:51:32 -03:00
3256ba8b03 feat: add Fandom -> Phantom replacement support 2025-11-18 14:41:32 -03:00
b45ae21354 feat: add Imgur -> Rimgo replacement support 2025-11-18 14:22:15 -03:00
bb6728b6ec fix(black.css): timeline border_grey is darker 2025-11-17 16:03:14 -03:00
e430765516 fix: add viewstats to profile pages 2025-11-17 14:57:25 -03:00
53 changed files with 1323 additions and 574 deletions

View File

@@ -24,15 +24,22 @@ hmacKey = "secretkey" # random key for cryptographic signing of video urls
base64Media = false # use base64 encoding for proxied media urls base64Media = false # use base64 encoding for proxied media urls
enableRSS = true # set this to false to disable RSS feeds enableRSS = true # set this to false to disable RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.sessions) enableDebug = false # enable request logs and debug endpoints (/.sessions)
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]
theme = "Nitter" theme = "Kuuro"
replaceTwitter = "nitter.net" replaceTwitter = "nt.kuuro.net"
replaceYouTube = "piped.video" replaceYouTube = "inv.nadeko.net"
replaceReddit = "teddit.net" replaceReddit = "rd.kuuro.net"
replaceImgur = "rd.kuuro.net"
replaceFandom = "ph.kuuro.net"
replaceSoundCloud = "sc.kuuro.net"
proxyVideos = true proxyVideos = true
hlsPlayback = false hlsPlayback = false
infiniteScroll = false infiniteScroll = false
hideNsfw = true

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

@@ -14,7 +14,7 @@ body {
--dark_grey: #404040; --dark_grey: #404040;
--darker_grey: #1F1F1F; --darker_grey: #1F1F1F;
--darkest_grey: #151515; --darkest_grey: #151515;
--border_grey: #333333; --border_grey: #1c1c1c;
--accent: #FF6C60; --accent: #FF6C60;
--accent_light: #FFACA0; --accent_light: #FFACA0;

View File

@@ -0,0 +1,59 @@
body {
--bg_color: #050505;
--fg_color: #d2d2d2;
--fg_faded: #949494;
--fg_dark: var(--accent);
--fg_nav: var(--accent);
--bg_panel: #0a0a0a;
--bg_elements: #0f0f0f;
--bg_overlays: var(--bg_panel);
--bg_hover: #151515;
--grey: #949494;
--dark_grey: #444444;
--darker_grey: #333333;
--darkest_grey: #111111;
--border_grey: rgba(255, 255, 255, 0.06);
--accent: #f3c2e6;
--accent_light: #ffd6f2;
--accent_dark: #a6859d;
--accent_border: #f3c2e660;
--play_button: var(--accent);
--play_button_hover: var(--accent_light);
--more_replies_dots: #5f6364;
--error_red: #ec5f67;
--verified_blue: #6699cc;
--icon_text: var(--fg_color);
--tab: var(--fg_color);
--tab_selected: var(--accent);
--profile_stat: var(--fg_color);
}
*:not(.avatar) {
border-radius: 0 !important;
}
input, textarea, select, button,
.card-container,
.profile-card,
.photo-rail-card,
.timeline-item,
.search-field,
nav,
.timeline-footer,
.show-more,
.unavailable-box,
.tweet-embed,
.overlay-panel,
.timeline-header,
.tab {
outline: 1px solid var(--border_grey) !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;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 KiB

After

Width:  |  Height:  |  Size: 790 KiB

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: graphUserTweetsV2 ? {"variables": oauthVariables, "features": gqlFeatures} 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: graphUserTweetsAndRepliesV2 ? {"variables": oauthVariables, "features": gqlFeatures}
) )
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: graphTweet ? {"variables": oauthVariables, "features": gqlFeatures}
) )
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 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,23 +62,28 @@ 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:
echo "[sessions] Empty oauth token, session: ", result.pretty warn "[sessions] Empty oauth token, session: ", result.pretty
raise rateLimitError() raise rateLimitError()
of SessionKind.cookie: of SessionKind.cookie:
if result.authToken.len == 0 or result.ct0.len == 0: if result.authToken.len == 0 or result.ct0.len == 0:
echo "[sessions] Empty cookie credentials, session: ", result.pretty warn "[sessions] Empty cookie credentials, session: ", result.pretty
raise rateLimitError() raise rateLimitError()
template fetchImpl(result, fetchBody) {.dirty.} = template fetchImpl(result, fetchBody) {.dirty.} =
@@ -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:
echo "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"):
echo "[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:
echo "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
@@ -125,7 +142,10 @@ template fetchImpl(result, fetchBody) {.dirty.} =
raise e raise e
except Exception as e: except Exception as e:
let s = session.pretty let s = session.pretty
echo "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", url var safeUrl = $url
if safeUrl.len > 100:
safeUrl = safeUrl[0 .. 100] & "..."
error "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", safeUrl
raise rateLimitError() raise rateLimitError()
finally: finally:
release(session) release(session)
@@ -134,44 +154,37 @@ template retry(bod) =
try: try:
bod bod
except RateLimitError: except RateLimitError:
echo "[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('['):
result = parseJson(body) result = parseJson(body)
else: else:
echo resp.status, ": ", body, " --- url: ", url warn resp.status, ": ", body, " --- url: ", url
result = newJNull() result = newJNull()
let error = result.getError let error = result.getError
if error != null and error notin errorsToSkip: if error != null and error notin errorsToSkip:
echo "Fetch error, API: ", api, ", error: ", error error "Fetch error, API: ", url.path, ", error: ", error
if error 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('[')):
echo resp.status, ": ", result, " --- url: ", url warn resp.status, ": ", result, " --- url: ", url
result.setLen(0) result.setLen(0)

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, sequtils, strutils, tables, packedsets, os] 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
@@ -12,8 +12,16 @@ var
sessionPool: seq[Session] sessionPool: seq[Session]
enableLogging = false enableLogging = false
template log(str: varargs[string, `$`]) = proc logSession(args: varargs[string, `$`]) =
echo "[sessions] ", str.join("") var s = "[sessions] "
for arg in args:
s.add arg
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:
@@ -122,14 +130,15 @@ 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
log "resetting limit: ", session.pretty logSession "resetting limit: ", session.pretty
return false return false
else: else:
return true return true
@@ -140,12 +149,12 @@ 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
log "invalidating: ", session.pretty logSession "invalidating: ", session.pretty
# TODO: This isn't sufficient, but it works for now # TODO: This isn't sufficient, but it works for now
let idx = sessionPool.find(session) let idx = sessionPool.find(session)
@@ -156,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:
log "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
log "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:
@@ -188,15 +199,15 @@ proc initSessionPool*(cfg: Config; path: string) =
enableLogging = cfg.enableDebug enableLogging = cfg.enableDebug
if path.endsWith(".json"): if path.endsWith(".json"):
log "ERROR: .json is not supported, the file must be a valid JSONL file ending in .jsonl" fatal ".json is not supported, the file must be a valid JSONL file ending in .jsonl"
quit 1 quit 1
if not fileExists(path): if not fileExists(path):
log "ERROR: ", path, " not found. This file is required to authenticate API requests." fatal path, " not found. This file is required to authenticate API requests."
quit 1 quit 1
log "parsing JSONL account sessions file: ", path logSession "parsing JSONL account sessions file: ", path
for line in path.lines: for line in path.lines:
sessionPool.add parseSession(line) sessionPool.add parseSession(line)
log "successfully added ", sessionPool.len, " valid account sessions" logSession "successfully added ", sessionPool.len, " valid account sessions"

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)
@@ -39,8 +40,11 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
minTokens: cfg.get("Config", "tokenCount", 10), minTokens: cfg.get("Config", "tokenCount", 10),
enableRss: cfg.get("Config", "enableRSS", true), enableRss: cfg.get("Config", "enableRSS", true),
enableDebug: cfg.get("Config", "enableDebug", false), enableDebug: cfg.get("Config", "enableDebug", false),
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,
@@ -142,5 +158,7 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
fieldToggles* = """{"withArticlePlainText":false}""" fieldToggles* = """{"withArticlePlainText":false,"withViewCounts":true}"""
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":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

@@ -1,4 +1,6 @@
import std/[options, tables, strutils, strformat, sugar]
import std/[options, tables, strutils, strformat, sugar, logging]
import jsony import jsony
import user, ../types/unifiedcard import user, ../types/unifiedcard
import ../../formatters import ../../formatters
@@ -112,7 +114,7 @@ proc parseUnifiedCard*(json: string): Card =
of ComponentType.hidden: of ComponentType.hidden:
result.kind = CardKind.hidden result.kind = CardKind.hidden
of ComponentType.unknown: of ComponentType.unknown:
echo "ERROR: Unknown component type: ", json error "ERROR: Unknown component type: ", json
case component.kind case component.kind
of twitterListDetails: of twitterListDetails:

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

@@ -1,4 +1,6 @@
import std/[options, tables, times]
import std/[options, tables, times, logging]
import jsony import jsony
from ../../types import VideoType, VideoVariant, User from ../../types import VideoType, VideoVariant, User
@@ -103,21 +105,21 @@ proc enumHook*(s: string; v: var ComponentType) =
of "media_with_details_horizontal": mediaWithDetailsHorizontal of "media_with_details_horizontal": mediaWithDetailsHorizontal
of "commerce_drop_details": hidden of "commerce_drop_details": hidden
of "grok_share": grokShare of "grok_share": grokShare
else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown else: error "ERROR: Unknown enum value (ComponentType): ", s; unknown
proc enumHook*(s: string; v: var AppType) = proc enumHook*(s: string; v: var AppType) =
v = case s v = case s
of "android_app": androidApp of "android_app": androidApp
of "iphone_app": iPhoneApp of "iphone_app": iPhoneApp
of "ipad_app": iPadApp of "ipad_app": iPadApp
else: echo "ERROR: Unknown enum value (AppType): ", s; androidApp else: error "ERROR: Unknown enum value (AppType): ", s; androidApp
proc enumHook*(s: string; v: var MediaType) = proc enumHook*(s: string; v: var MediaType) =
v = case s v = case s
of "video": video of "video": video
of "photo": photo of "photo": photo
of "model3d": model3d of "model3d": model3d
else: echo "ERROR: Unknown enum value (MediaType): ", s; photo else: error "ERROR: Unknown enum value (MediaType): ", s; photo
proc parseHook*(s: string; i: var int; v: var DateTime) = proc parseHook*(s: string; i: var int; v: var DateTime) =
var str: string var str: string

View File

@@ -6,13 +6,18 @@ 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"
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>""" twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
xRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?x\.com" xRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?x\.com"
xLinkRegex = re"""<a href="https:\/\/x.com([^"]+)">x\.com(\S+)</a>""" xLinkRegex = re"""<a href="https:\/\/x.com([^"]+)">x\.com(\S+)</a>"""
imgurRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(i\.)?imgur\.com"
imgurLinkRegex = re"""<a href="https://(i\.)?imgur.com([^"]+)">(i\.)?imgur\.com(\S+)</a>"""
fandomRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))([a-z0-9-]+)\.fandom\.com"
fandomLinkRegex = re"""<a href="https://([a-z0-9-]+)\.fandom\.com([^"]+)">([a-z0-9-]+)\.fandom\.com(\S+)</a>"""
soundcloudRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(on\.|www\.)?soundcloud\.com"
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase}) ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
@@ -59,25 +64,44 @@ 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:
result = result.replace(imgurRegex, prefs.replaceImgur)
result = result.replacef(imgurLinkRegex, a(
prefs.replaceImgur & "$4", href = https & prefs.replaceImgur & "$2"))
if prefs.replaceFandom.len > 0 and "fandom.com" in result:
result = result.replace(fandomRegex, prefs.replaceFandom & "/$1")
result = result.replacef(fandomLinkRegex, a(
prefs.replaceFandom & "/$1$2", href = https & prefs.replaceFandom & "/$1$2"))
if prefs.replaceSoundCloud.len > 0 and "soundcloud.com" in result:
result = result.replace(soundcloudRegex, prefs.replaceSoundCloud)
result = result.replacef(re"""<a href="https://on\.soundcloud\.com([^"]+)">on\.soundcloud\.com(\S+)</a>""", a(
prefs.replaceSoundCloud & "/on$2", href = https & prefs.replaceSoundCloud & "/on$1"))
result = result.replacef(re"""<a href="https://(www\.)?soundcloud\.com([^"]+)">(www\.)?soundcloud\.com(\S+)</a>""", a(
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

@@ -1,20 +1,48 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strformat, logging import asyncdispatch, strformat, logging, terminal, times, strutils
from net import Port from net import Port
from htmlgen import a from htmlgen import a
from os import getEnv 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] unsupported, embed, resolver, router_utils, follow]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues" const issuesUrl = "https://github.com/zedeus/nitter/issues"
type ColoredLogger = ref object of Logger
method log(logger: ColoredLogger, level: Level, args: varargs[string, `$`]) =
if level < logger.levelThreshold: return
let color = case level
of lvlFatal, lvlError: fgRed
of lvlWarn: fgYellow
of lvlInfo: fgGreen
of lvlDebug: fgCyan
else: fgWhite
let levelStr = case level
of lvlFatal: "fatal"
of lvlError: "error"
of lvlWarn: "warn"
of lvlInfo: "info"
of lvlDebug: "debug"
else: "other"
let timeStr = format(now(), "HH:mm:ss")
stdout.styledWrite(fgWhite, "[", timeStr, "] ", color, levelStr, fgWhite, ": ")
for arg in args:
stdout.write(arg)
stdout.write("\n")
stdout.flushFile()
let let
configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
(cfg, fullCfg) = getConfig(configPath) (cfg, fullCfg) = getConfig(configPath)
@@ -23,13 +51,21 @@ let
initSessionPool(cfg, sessionsPath) initSessionPool(cfg, sessionsPath)
if not cfg.enableDebug: addHandler(new(ColoredLogger))
# Silence Jester's query warning
addHandler(newConsoleLogger())
setLogFilter(lvlError)
stdout.write &"Starting Nitter at {getUrlPrefix(cfg)}\n" let level = case cfg.logLevel.toLowerAscii
stdout.flushFile of "debug": lvlDebug
of "info": lvlInfo
of "warn": lvlWarn
of "error": lvlError
of "fatal": lvlFatal
of "none": lvlNone
else:
if cfg.enableDebug: lvlDebug else: lvlInfo
setLogFilter(level)
info &"Starting Nitter at {getUrlPrefix(cfg)}"
updateDefaultPrefs(fullCfg) updateDefaultPrefs(fullCfg)
setCacheTimes(cfg) setCacheTimes(cfg)
@@ -37,13 +73,14 @@ 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)
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" info &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}"
stdout.flushFile
createUnsupportedRouter(cfg) createUnsupportedRouter(cfg)
createFollowRouter(cfg)
createResolverRouter(cfg) createResolverRouter(cfg)
createPrefRouter(cfg) createPrefRouter(cfg)
createTimelineRouter(cfg) createTimelineRouter(cfg)
@@ -63,6 +100,40 @@ settings:
routes: routes:
get "/": get "/":
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()) resp renderMain(renderSearch(), request, cfg, themePrefs())
get "/about": get "/about":
@@ -83,13 +154,13 @@ routes:
resp Http404, showError("Page not found", cfg) resp Http404, showError("Page not found", cfg)
error InternalError: error InternalError:
echo error.exc.name, ": ", error.exc.msg error error.exc.name, ": ", error.exc.msg
const link = a("open a GitHub issue", href = issuesUrl) const link = a("open a GitHub issue", href = issuesUrl)
resp Http500, showError( resp Http500, showError(
&"An error occurred, please {link} with the URL you tried to visit.", cfg) &"An error occurred, please {link} with the URL you tried to visit.", cfg)
error BadClientError: error BadClientError:
echo error.exc.name, ": ", error.exc.msg error error.exc.name, ": ", error.exc.msg
resp Http500, showError("Network error occurred, please try again.", cfg) resp Http500, showError("Network error occurred, please try again.", cfg)
error RateLimitError: error RateLimitError:
@@ -111,5 +182,6 @@ routes:
extend preferences, "" extend preferences, ""
extend resolver, "" extend resolver, ""
extend embed, "" extend embed, ""
extend follow, ""
extend debug, "" extend debug, ""
extend unsupported, "" extend unsupported, ""

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,11 +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,
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 =
@@ -41,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
@@ -90,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
@@ -116,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(
@@ -206,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}:
@@ -218,20 +280,25 @@ 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,
available: true, available: true,
user: User(id: js{"user_id_str"}.getStr), user: User(id: js{"user_id_str"}.getStr),
stats: TweetStats( stats: TweetStats(
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
) )
) )
@@ -257,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:
@@ -270,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] =
@@ -306,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
@@ -407,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(
id: entryId.getId,
available: false, available: false,
text: e{"content", contentKey, "tombstoneInfo", "richText"}.getTombstone 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
@@ -478,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]
@@ -489,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:
@@ -515,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)
@@ -533,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

@@ -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:
echo "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

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import tables import tables, strutils, sequtils
import types, prefs_impl import types, prefs_impl
from config import get from config import get
from parsecfg import nil from parsecfg import nil
@@ -13,6 +13,8 @@ proc updateDefaultPrefs*(cfg: parsecfg.Config) =
proc getPrefs*(cookies: Table[string, string]): Prefs = proc getPrefs*(cookies: Table[string, string]): Prefs =
result = defaultPrefs result = defaultPrefs
if "nitter_following" in cookies:
result.following = cookies["nitter_following"].split(',').filterIt(it.len > 0)
genCookiePrefs(cookies) genCookiePrefs(cookies)
template getPref*(cookies: Table[string, string], pref): untyped = template getPref*(cookies: Table[string, string], pref): untyped =

View File

@@ -54,6 +54,10 @@ genPrefs:
theme(select, "Nitter"): theme(select, "Nitter"):
"Theme" "Theme"
verifiedBadge(select, "Show all"):
"Verified badges"
options: @["Show all", "Show official only", "Hide all"]
infiniteScroll(checkbox, false): infiniteScroll(checkbox, false):
"Infinite scrolling (experimental, requires JavaScript)" "Infinite scrolling (experimental, requires JavaScript)"
@@ -78,6 +82,9 @@ genPrefs:
squareAvatars(checkbox, false): squareAvatars(checkbox, false):
"Square profile pictures" "Square profile pictures"
hideNsfw(checkbox, true):
"Hide NSFW content"
Media: Media:
mp4Playback(checkbox, true): mp4Playback(checkbox, true):
"Enable mp4 video playback (only for gifs)" "Enable mp4 video playback (only for gifs)"
@@ -107,6 +114,18 @@ genPrefs:
"Reddit -> Teddit/Libreddit" "Reddit -> Teddit/Libreddit"
placeholder: "Teddit hostname" placeholder: "Teddit hostname"
replaceImgur(input, ""):
"Imgur -> Rimgo"
placeholder: "Rimgo hostname"
replaceFandom(input, ""):
"Fandom -> Phantom"
placeholder: "Phantom hostname"
replaceSoundCloud(input, ""):
"SoundCloud -> SoundCloak"
placeholder: "SoundCloak hostname"
iterator allPrefs*(): Pref = iterator allPrefs*(): Pref =
for k, v in prefList: for k, v in prefList:
for pref in v: for pref in v:
@@ -206,6 +225,7 @@ macro genPrefsType*(): untyped =
let name = nnkPostfix.newTree(ident("*"), ident("Prefs")) let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
result = quote do: result = quote do:
type `name` = object type `name` = object
following*: seq[string]
discard discard
for pref in allPrefs(): for pref in allPrefs():

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,7 +64,10 @@ 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:
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 " param &= "filter:self_threads OR -filter:replies "
if "nativeretweets" notin query.excludes: if "nativeretweets" notin query.excludes:
@@ -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 asyncdispatch, times, strformat, strutils, tables, hashes import asyncdispatch, times, strformat, strutils, tables, hashes, logging
import redis, redpool, flatty, supersnappy import redis, redpool, flatty, supersnappy
import types, api import types, api
@@ -59,8 +59,7 @@ proc initRedisPool*(cfg: Config) {.async.} =
await r.configSet("hash-max-ziplist-entries", "1000") await r.configSet("hash-max-ziplist-entries", "1000")
except OSError: except OSError:
stdout.write "Failed to connect to Redis.\n" fatal "Failed to connect to Redis."
stdout.flushFile
quit(1) quit(1)
template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000) template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000)
@@ -112,7 +111,7 @@ template deserialize(data, T) =
try: try:
result = fromFlatty(uncompress(data), T) result = fromFlatty(uncompress(data), T)
except: except:
echo "Decompression failed($#): '$#'" % [astToStr(T), data] error "Decompression failed($#): '$#'" % [astToStr(T), data]
proc getUserId*(username: string): Future[string] {.async.} = proc getUserId*(username: string): Future[string] {.async.} =
let name = toLower(username) let name = toLower(username)
@@ -189,6 +188,6 @@ proc getCachedRss*(key: string): Future[Rss] {.async.} =
let feed = await r.hGet(k, "rss") let feed = await r.hGet(k, "rss")
if feed.len > 0 and feed != redisNil: if feed.len > 0 and feed != redisNil:
try: result.feed = uncompress feed try: result.feed = uncompress feed
except: echo "Decompressing RSS failed: ", feed except: error "Decompressing RSS failed: ", feed
else: else:
result.cursor.setLen 0 result.cursor.setLen 0

23
src/routes/follow.nim Normal file
View File

@@ -0,0 +1,23 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, sequtils
import jester
import router_utils
import ".."/[types]
proc createFollowRouter*(cfg: Config) =
router follow:
post "/follow":
let user = @"user"
var prefs = cookiePrefs()
if user.len > 0 and user notin prefs.following:
prefs.following.add(user)
setCookie("nitter_following", prefs.following.join(","), daysForward(360), path="/", httpOnly=true, secure=cfg.useHttps, sameSite=None)
redirect(refPath())
post "/unfollow":
let user = @"user"
var prefs = cookiePrefs()
if user.len > 0 and user in prefs.following:
prefs.following.keepItIf(it != user)
setCookie("nitter_following", prefs.following.join(","), daysForward(360), path="/", httpOnly=true, secure=cfg.useHttps, sameSite=None)
redirect(refPath())

View File

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils, httpclient, os, hashes, base64, re import uri, strutils, httpclient, os, hashes, base64, re, logging
import asynchttpserver, asyncstreams, asyncfile, asyncnet import asynchttpserver, asyncstreams, asyncfile, asyncnet
import jester import jester
@@ -38,7 +38,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
let res = await client.get(url) let res = await client.get(url)
if res.status != "200 OK": if res.status != "200 OK":
if res.status != "404 Not Found": if res.status != "404 Not Found":
echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] warn "[media] Proxying failed, status: $1, url: $2" % [res.status, url]
return Http404 return Http404
let hashed = $hash(url) let hashed = $hash(url)
@@ -67,7 +67,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
await request.client.send(data) await request.client.send(data)
data.setLen 0 data.setLen 0
except HttpRequestError, ProtocolError, OSError: except HttpRequestError, ProtocolError, OSError:
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] error "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url]
result = Http404 result = Http404
finally: finally:
client.close() client.close()

View File

@@ -32,7 +32,7 @@ proc createSearchRouter*(cfg: Config) =
users = await getGraphUserSearch(query, getCursor()) users = await getGraphUserSearch(query, getCursor())
except InternalError: except InternalError:
users = Result[User](beginning: true, query: query) users = Result[User](beginning: true, query: query)
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) resp renderMain(renderUserSearch(users, prefs, getPath()), request, cfg, prefs, title)
of tweets: of tweets:
let let
tweets = await getGraphTweetSearch(query, getCursor()) tweets = await getGraphTweetSearch(query, getCursor())

View File

@@ -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:
echo "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/?":

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?":

View File

@@ -37,3 +37,52 @@
height: unset; height: unset;
} }
} }
.follow-form {
display: inline-block;
pointer-events: all;
}
.follow-button {
background-color: transparent;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 9999px;
padding: 4px 12px;
font-weight: 700;
font-size: 14px;
cursor: pointer;
line-height: 1.2;
transition: background-color 0.2s;
outline: none;
&:hover {
background-color: var(--bg_hover);
}
&.unfollow {
color: #e0245e;
border-color: #e0245e;
&:hover {
background-color: rgba(224, 36, 94, 0.1);
}
}
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.tweet-name-row .follow-form {
margin-left: 10px;
}
.tweet-name-row .follow-button {
padding: 2px 10px;
font-size: 13px;
}

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

@@ -61,23 +61,8 @@
@mixin search-resize($width, $rows) { @mixin search-resize($width, $rows) {
@media(max-width: $width) { @media(max-width: $width) {
.search-toggles {
grid-template-columns: repeat($rows, auto);
}
#search-panel-toggle:checked ~ .search-panel { #search-panel-toggle:checked ~ .search-panel {
@if $rows == 6 { max-height: 80vh !important;
max-height: 200px !important;
}
@if $rows == 5 {
max-height: 300px !important;
}
@if $rows == 4 {
max-height: 300px !important;
}
@if $rows == 3 {
max-height: 365px !important;
}
} }
} }
} }

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;
} }
@@ -127,7 +128,6 @@ ul {
max-width: 600px; max-width: 600px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
margin-top: 10px;
background-color: var(--bg_overlays); background-color: var(--bg_overlays);
padding: 10px 15px; padding: 10px 15px;
align-self: start; align-self: start;
@@ -169,6 +169,14 @@ ul {
} }
} }
body.hide-verified-all .verified-icon {
display: none !important;
}
body.hide-verified-blue .verified-icon.blue {
display: none !important;
}
@media(max-width: 600px) { @media(max-width: 600px) {
.preferences-container { .preferences-container {
max-width: 95vw; max-width: 95vw;

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%);
@@ -162,9 +179,22 @@ input::-webkit-datetime-edit-year-field:focus {
-moz-appearance: none; -moz-appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
padding-right: 18px;
} }
input[type="text"] { .pref-select:after {
content: '\e80b'; /* icon-down */
font-family: $font_icon;
font-size: 10px;
pointer-events: none;
position: absolute;
right: 4px;
top: 5px;
color: var(--fg_color);
}
input[type="text"],
input[type="number"] {
position: absolute; position: absolute;
right: 0; right: 0;
max-width: 140px; max-width: 140px;

View File

@@ -25,6 +25,14 @@ nav {
align-items: center; align-items: center;
flex-basis: 920px; flex-basis: 920px;
height: 50px; height: 50px;
gap: 15px;
}
.inner-nav .search-field {
flex: 1;
margin: 0 auto;
max-width: 350px;
min-width: 0;
} }
.site-name { .site-name {
@@ -46,11 +54,11 @@ nav {
.nav-item { .nav-item {
display: flex; display: flex;
flex: 1; flex: 0 0 auto;
line-height: 50px; line-height: 50px;
height: 50px; height: 50px;
overflow: hidden; overflow: hidden;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: center; align-items: center;
&.right { &.right {

View File

@@ -9,70 +9,111 @@
.search-field { .search-field {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
position: relative;
button { button {
margin: 0 2px 0 0; margin: 0 2px 0 0;
height: 23px; height: 23px;
display: flex; display: flex;
align-items: center; align-items: center;
flex: 0 0 auto;
} }
.pref-input { .pref-input {
margin: 0 4px 0 0; margin: 0 4px 0 0;
flex-grow: 1; flex-grow: 1;
height: 23px; height: 23px;
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);
} }
> label { > label {
display: inline; display: flex;
align-items: center;
height: 23px;
background-color: var(--bg_elements); background-color: var(--bg_elements);
color: var(--fg_color); color: var(--fg_color);
border: 1px solid var(--accent_border); border: 1px solid var(--accent_border);
padding: 1px 6px 2px 6px; padding: 0 6px;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
margin-bottom: 2px; margin-bottom: 0;
box-sizing: border-box;
@include input-colors; @include input-colors;
} }
@include create-toggle(search-panel, 200px); @include create-toggle(search-panel, 600px);
} }
.search-panel { .search-panel {
width: 100%; width: 100%;
margin-top: 5px;
border: 1px solid var(--accent_border);
max-height: 0; max-height: 0;
overflow: hidden; overflow-y: auto;
overflow-x: hidden;
transition: max-height 0.4s; transition: max-height 0.4s;
flex-grow: 1; position: absolute;
top: 100%;
left: 0;
background-color: var(--bg_overlays);
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
border-radius: 0 0 4px 4px;
z-index: 2000;
padding: 5px 10px;
box-sizing: border-box;
opacity: 0;
visibility: hidden;
transition: max-height 0.4s, opacity 0.4s, visibility 0.4s;
font-weight: initial; font-weight: initial;
text-align: left; text-align: left;
font-size: 13px;
> div { > div {
line-height: 1.7em; line-height: 1.4em;
margin-bottom: 4px;
} }
.checkbox-container { .checkbox-container {
display: inline; display: inline-flex;
align-items: center;
padding-right: unset; padding-right: unset;
margin-bottom: unset; margin-bottom: 2px;
margin-left: 23px; margin-left: 18px;
margin-right: 0;
white-space: nowrap;
} }
.checkbox { .checkbox {
right: unset; right: unset;
left: -22px; left: -18px;
width: 15px;
height: 15px;
} }
.checkbox-container .checkbox:after { .checkbox-container .checkbox:after {
top: -4px; top: 0;
left: 1px;
bottom: unset;
font-size: 13px;
width: auto;
height: auto;
}
.search-title {
font-size: 13px;
margin-top: 2px;
margin-bottom: 2px;
} }
} }
@@ -92,11 +133,11 @@
.pref-input { .pref-input {
display: block; display: block;
padding-bottom: 5px; padding-bottom: 2px;
input { input {
height: 21px; height: 21px;
margin-top: 1px; margin-top: 0;
} }
} }
} }
@@ -104,19 +145,36 @@
.search-toggles { .search-toggles {
flex-grow: 1; flex-grow: 1;
display: grid; display: grid;
grid-template-columns: repeat(6, auto); grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
grid-column-gap: 10px; gap: 5px 10px;
}
#search-panel-toggle:checked ~ .search-panel {
opacity: 1;
visibility: visible;
} }
.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) {
.search-panel {
position: fixed;
top: 50px;
left: 0;
right: 0;
width: 100%;
border-radius: 0;
border-top: 1px solid var(--accent_border);
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
}

View File

@@ -2,13 +2,14 @@
.timeline-container { .timeline-container {
@include panel(100%, 600px); @include panel(100%, 600px);
background-color: transparent;
} }
.timeline { .timeline {
background-color: var(--bg_panel); background-color: transparent;
> div:not(:first-child) { > div:not(:first-child) {
border-top: 1px solid var(--border_grey); border-top: none;
} }
} }
@@ -25,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 {
@@ -159,4 +179,6 @@
padding: .75em; padding: .75em;
display: flex; display: flex;
position: relative; position: relative;
margin-top: 5px;
background-color: var(--bg_panel);
} }

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
@@ -103,6 +93,7 @@ type
media*: int media*: int
verifiedType*: VerifiedType verifiedType*: VerifiedType
protected*: bool protected*: bool
sensitive*: bool
suspended*: bool suspended*: bool
joinDate*: DateTime joinDate*: DateTime
@@ -121,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
@@ -142,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
@@ -202,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
@@ -214,6 +204,7 @@ type
time*: DateTime time*: DateTime
reply*: seq[string] reply*: seq[string]
pinned*: bool pinned*: bool
sensitive*: bool
hasThread*: bool hasThread*: bool
available*: bool available*: bool
tombstone*: string tombstone*: string
@@ -285,8 +276,11 @@ type
minTokens*: int minTokens*: int
enableRss*: bool enableRss*: bool
enableDebug*: bool enableDebug*: bool
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

@@ -2,8 +2,8 @@
import uri, strutils, strformat import uri, strutils, strformat
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import renderutils import renderutils, search_panel
import ../utils, ../types, ../prefs, ../formatters import ../utils, ../types, ../prefs, ../formatters, ../query, tables
import jester import jester
@@ -20,24 +20,27 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
path = $(parseUri(req.path) ? filterParams(req.params)) path = $(parseUri(req.path) ? filterParams(req.params))
if "/status/" in path: path.add "#m" if "/status/" in path: path.add "#m"
let query = initQuery(req.params)
buildHtml(nav): buildHtml(nav):
tdiv(class="inner-nav"): tdiv(class="inner-nav"):
tdiv(class="nav-item"): tdiv(class="nav-item"):
a(class="site-name", href="/"): text cfg.title a(class="site-name", href="/"): text cfg.title
a(href="/about"): text "(donate)" a(href="/about"): text "(donate)"
renderSearchPanel(query)
tdiv(class="nav-item right"): tdiv(class="nav-item right"):
icon "search", title="Search", href="/search"
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
@@ -51,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:
@@ -65,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")
@@ -120,18 +123,24 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="preload", type="font/woff2", `as`="font", link(rel="preload", type="font/woff2", `as`="font",
href="/fonts/fontello.woff2?61663884", crossorigin="anonymous") href="/fonts/fontello.woff2?61663884", crossorigin="anonymous")
if prefs.hideNsfw:
style:
verbatim ".nsfw { display: none !important; }"
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; 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: body(class=if prefs.verifiedBadge == "Hide all": "hide-verified-all"
renderNavbar(cfg, req, rss, canonical) elif prefs.verifiedBadge == "Show official only": "hide-verified-blue"
else: ""):
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

@@ -12,8 +12,9 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"): span(class="profile-stat-num"):
text insertSep($num, ',') text insertSep($num, ',')
proc renderUserCard*(user: User; prefs: Prefs): VNode = proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="profile-card")): let class = if user.sensitive: "profile-card nsfw" else: "profile-card"
buildHtml(tdiv(class=class)):
tdiv(class="profile-card-info"): tdiv(class="profile-card-info"):
let let
url = getPicUrl(user.getUserPic()) url = getPicUrl(user.getUserPic())
@@ -24,10 +25,13 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
a(class="profile-card-avatar", href=url, target="_blank"): a(class="profile-card-avatar", href=url, target="_blank"):
genImg(user.getUserPic(size)) genImg(user.getUserPic(size))
tdiv(class="profile-header"):
tdiv(class="profile-card-tabs-name"): tdiv(class="profile-card-tabs-name"):
linkUser(user, class="profile-card-fullname") linkUser(user, class="profile-card-fullname")
linkUser(user, class="profile-card-username") linkUser(user, class="profile-card-username")
renderFollowButton(user, prefs, path)
tdiv(class="profile-card-extra"): tdiv(class="profile-card-extra"):
if user.bio.len > 0: if user.bio.len > 0:
tdiv(class="profile-bio"): tdiv(class="profile-bio"):
@@ -109,7 +113,7 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
let sticky = if prefs.stickyProfile: " sticky" else: "" let sticky = if prefs.stickyProfile: " sticky" else: ""
tdiv(class=("profile-tab" & sticky)): tdiv(class=("profile-tab" & sticky)):
renderUserCard(profile.user, prefs) renderUserCard(profile.user, prefs, path)
if profile.photoRail.len > 0: if profile.photoRail.len > 0:
renderPhotoRail(profile) renderPhotoRail(profile)

View File

@@ -77,7 +77,7 @@ proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0)) input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
proc genSelect*(pref, label, state: string; options: seq[string]): VNode = proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
buildHtml(tdiv(class="pref-group pref-input")): buildHtml(tdiv(class="pref-group pref-input pref-select")):
label(`for`=pref): text label label(`for`=pref): text label
select(name=pref): select(name=pref):
for opt in options: for opt in options:
@@ -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")
@@ -100,3 +107,15 @@ proc getTabClass*(query: Query; tab: QueryKind): string =
proc getAvatarClass*(prefs: Prefs): string = proc getAvatarClass*(prefs: Prefs): string =
if prefs.squareAvatars: "avatar" if prefs.squareAvatars: "avatar"
else: "avatar round" else: "avatar round"
proc renderFollowButton*(user: User; prefs: Prefs; path: string): VNode =
let
isFollowing = user.username in prefs.following
action = if isFollowing: "/unfollow" else: "/follow"
text = if isFollowing: "Unfollow" else: "Follow"
class = if isFollowing: "follow-button unfollow" else: "follow-button"
buildHtml(form(action=action, `method`="post", class="follow-form")):
refererField path
hiddenField("user", user.username)
button(class=class, `type`="submit"): text text

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:
<a href="${urlPrefix}${tweet.getLink}">
<br>Video<br>
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" /> <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,13 +72,16 @@ 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>
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
<p> <p>
${renderRssTweet(get(tweet.quote), cfg)} ${renderRssTweet(quoteTweet, cfg)}
</p> </p>
<footer> <footer>
— <cite><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></cite> — <cite><a href="${quoteLink}">${quoteLink}</a>
</footer> </footer>
</blockquote> </blockquote>
#end if #end if
@@ -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

@@ -1,33 +1,13 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, unicode, tables, options import strutils, options
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import renderutils, timeline import renderutils, timeline
import ".."/[types, query] import ".."/[types, query]
const toggles = {
"nativeretweets": "Retweets",
"media": "Media",
"videos": "Videos",
"news": "News",
"verified": "Verified",
"native_video": "Native videos",
"replies": "Replies",
"links": "Links",
"images": "Images",
"safe": "Safe",
"quote": "Quotes",
"pro_video": "Pro videos"
}.toOrderedTable
proc renderSearch*(): VNode = proc renderSearch*(): VNode =
buildHtml(tdiv(class="panel-container")): buildHtml(tdiv(class="panel-container")):
tdiv(class="search-bar"): discard
form(`method`="get", action="/search", autocomplete="off"):
hiddenField("f", "tweets")
input(`type`="text", name="q", autofocus="",
placeholder="Search...", dir="auto")
button(`type`="submit"): icon "search"
proc renderProfileTabs*(query: Query; username: string): VNode = proc renderProfileTabs*(query: Query; username: string): VNode =
let link = "/" & username let link = "/" & username
@@ -51,43 +31,6 @@ proc renderSearchTabs*(query: Query): VNode =
q.kind = users q.kind = users
a(href=("?" & genQueryUrl(q))): text "Users" a(href=("?" & genQueryUrl(q))): text "Users"
proc isPanelOpen(q: Query): bool =
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
@[q.near, q.until, q.since].anyIt(it.len > 0))
proc renderSearchPanel*(query: Query): VNode =
let user = query.fromUser.join(",")
let action = if user.len > 0: &"/{user}/search" else: "/search"
buildHtml(form(`method`="get", action=action,
class="search-field", autocomplete="off")):
hiddenField("f", "tweets")
genInput("q", "", query.text, "Enter search...", class="pref-inline")
button(`type`="submit"): icon "search"
input(id="search-panel-toggle", `type`="checkbox", checked=isPanelOpen(query))
label(`for`="search-panel-toggle"): icon "down"
tdiv(class="search-panel"):
for f in @["filter", "exclude"]:
span(class="search-title"): text capitalize(f)
tdiv(class="search-toggles"):
for k, v in toggles:
let state =
if f == "filter": k in query.filters
else: k in query.excludes
genCheckbox(&"{f[0]}-{k}", v, state)
tdiv(class="search-row"):
tdiv:
span(class="search-title"): text "Time range"
tdiv(class="date-range"):
genDate("since", query.since)
span(class="search-title"): text "-"
genDate("until", query.until)
tdiv:
span(class="search-title"): text "Near"
genInput("near", "", query.near, "Location...", autofocus=false)
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =
let query = results.query let query = results.query
@@ -100,21 +43,42 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
renderProfileTabs(query, query.fromUser.join(",")) renderProfileTabs(query, query.fromUser.join(","))
if query.fromUser.len == 0 or query.kind == tweets: if query.fromUser.len == 0 or query.kind == tweets:
tdiv(class="timeline-header"): discard
renderSearchPanel(query)
if query.fromUser.len == 0: if query.fromUser.len == 0:
renderSearchTabs(query) renderSearchTabs(query)
renderTimelineTweets(results, prefs, path, pinned) renderTimelineTweets(results, prefs, path, pinned)
proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode = proc renderHomepageTabs*(query: Query): VNode =
buildHtml(tdiv(class="timeline-container")): buildHtml(ul(class="tab")):
tdiv(class="timeline-header"): li(class=if query.kind == tweets: "tab-item active" else: "tab-item"):
form(`method`="get", action="/search", class="search-field", autocomplete="off"): a(href="/?f=tweets"): text "Tweets"
hiddenField("f", "users")
genInput("q", "", results.query.text, "Enter username...", class="pref-inline")
button(`type`="submit"): icon "search"
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 =
buildHtml(tdiv(class="timeline-container")):
renderSearchTabs(results.query) renderSearchTabs(results.query)
renderTimelineUsers(results, prefs) renderTimelineUsers(results, prefs, path)

View File

@@ -0,0 +1,58 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, unicode, tables
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types]
const toggles = {
"nativeretweets": "Retweets",
"media": "Media",
"videos": "Videos",
"news": "News",
"verified": "Verified",
"native_video": "Native videos",
"replies": "Replies",
"links": "Links",
"images": "Images",
"safe": "Safe",
"quote": "Quotes",
"pro_video": "Pro videos"
}.toOrderedTable
proc isPanelOpen(q: Query): bool =
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.anyIt(it != "nativeretweets") or
@[q.near, q.until, q.since].anyIt(it.len > 0))
proc renderSearchPanel*(query: Query): VNode =
let user = query.fromUser.join(",")
let action = if user.len > 0: &"/{user}/search" else: "/search"
buildHtml(form(`method`="get", action=action,
class="search-field", autocomplete="off")):
hiddenField("f", "tweets")
genInput("q", "", query.text, "Enter search...", class="pref-inline")
button(`type`="submit"): icon "search"
input(id="search-panel-toggle", `type`="checkbox", checked=isPanelOpen(query))
label(`for`="search-panel-toggle"): icon "down"
tdiv(class="search-panel"):
for f in @["filter", "exclude"]:
span(class="search-title"): text capitalize(f)
tdiv(class="search-toggles"):
for k, v in toggles:
let state =
if f == "filter": k in query.filters
else: k in query.excludes
genCheckbox(&"{f[0]}-{k}", v, state)
tdiv(class="search-row"):
tdiv:
span(class="search-title"): text "Time range"
tdiv(class="date-range"):
genDate("since", query.since)
span(class="search-title"): text "-"
genDate("until", query.until)
tdiv:
span(class="search-title"): text "Near"
genInput("near", "", query.near, "Location...", autofocus=false)

View File

@@ -55,8 +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): VNode = proc renderUser*(user: User; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="timeline-item")): let class = if user.sensitive: "timeline-item nsfw" else: "timeline-item"
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"):
@@ -66,6 +67,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
tdiv(class="tweet-name-row"): tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
linkUser(user, class="fullname") linkUser(user, class="fullname")
renderFollowButton(user, prefs, path)
linkUser(user, class="username") linkUser(user, class="username")
tdiv(class="tweet-content media-body", dir="auto"): tdiv(class="tweet-content media-body", dir="auto"):
@@ -78,7 +80,7 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
if results.content.len > 0: if results.content.len > 0:
for user in results.content: for user in results.content:
renderUser(user, prefs) renderUser(user, prefs, path)
if results.bottom.len > 0: if results.bottom.len > 0:
renderMore(results.query, results.bottom) renderMore(results.query, results.bottom)
renderToTop() renderToTop()

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")):
@@ -277,8 +271,11 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if index == -1 or last: if index == -1 or last:
divClass = "thread-last " & class divClass = "thread-last " & class
if tweet.sensitive:
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
@@ -300,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
@@ -329,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())
@@ -346,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]