Compare commits
32 Commits
443996df96
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
4eefa1bf07
|
|||
|
|
31d210ca47 | ||
|
|
dae68b4f13 | ||
|
|
8516ebe2b7 | ||
|
|
b83227aaf5 | ||
|
0e707271a4
|
|||
|
f68e234ea6
|
|||
|
|
404b06b5f3 | ||
|
|
2b922c049a | ||
|
948a0fdc5c
|
|||
|
|
78101df2cc | ||
|
|
12bbddf204 | ||
|
|
4979d07f2e | ||
|
|
f038b53fa2 | ||
|
|
4748311f8d | ||
|
|
d47eb8f0eb | ||
|
|
1657eeb769 | ||
|
|
25df682094 | ||
|
|
53edbbc4e9 | ||
|
|
5b4a3fe691 | ||
|
d3d825dc50
|
|||
|
|
f8a17fdaa5 | ||
|
b4d2f9aec9
|
|||
|
2e122dbcb1
|
|||
|
56762c9026
|
|||
|
3d9a7e3014
|
|||
|
|
b0d9c1d51a | ||
|
dba8410146
|
|||
|
704db60297
|
|||
|
73360e6972
|
|||
|
62a4347b96
|
|||
|
|
78d788b27f |
@@ -27,6 +27,8 @@ enableDebug = false # enable request logs and debug endpoints (/.sessions)
|
|||||||
logLevel = "info" # log level (debug, info, warn, error, fatal)
|
logLevel = "info" # log level (debug, info, warn, error, fatal)
|
||||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||||
proxyAuth = ""
|
proxyAuth = ""
|
||||||
|
defaultFollowedAccounts = "eff,fsf" # default accounts to show when user follows none
|
||||||
|
disableTid = false # enable this if cookie-based auth is failing
|
||||||
|
|
||||||
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
||||||
[Preferences]
|
[Preferences]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ nav,
|
|||||||
.unavailable-box,
|
.unavailable-box,
|
||||||
.tweet-embed,
|
.tweet-embed,
|
||||||
.overlay-panel,
|
.overlay-panel,
|
||||||
|
.timeline-header,
|
||||||
.tab {
|
.tab {
|
||||||
outline: 1px solid var(--border_grey) !important;
|
outline: 1px solid var(--border_grey) !important;
|
||||||
outline-offset: -1px !important;
|
outline-offset: -1px !important;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
140
src/api.nim
140
src/api.nim
@@ -1,96 +1,101 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
|
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||||
import packedjson
|
import packedjson
|
||||||
import types, query, formatters, consts, apiutils, parser
|
import types, query, formatters, consts, apiutils, parser
|
||||||
import experimental/parser as newParser
|
import experimental/parser as newParser
|
||||||
|
|
||||||
proc mediaUrl(id: string; cursor: string): SessionAwareUrl =
|
# Helper to generate params object for GraphQL requests
|
||||||
let
|
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
|
||||||
cookieVariables = userMediaVariables % [id, cursor]
|
result.add ("variables", variables)
|
||||||
oauthVariables = restIdVariables % [id, cursor]
|
result.add ("features", gqlFeatures)
|
||||||
result = SessionAwareUrl(
|
if fieldToggles.len > 0:
|
||||||
cookieUrl: graphUserMedia ? {"variables": cookieVariables, "features": gqlFeatures},
|
result.add ("fieldToggles", fieldToggles)
|
||||||
oauthUrl: graphUserMediaV2 ? {"variables": oauthVariables, "features": gqlFeatures}
|
|
||||||
|
proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl =
|
||||||
|
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
|
||||||
|
|
||||||
|
proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
|
||||||
|
let url = apiUrl(endpoint, variables, fieldToggles)
|
||||||
|
return ApiReq(cookie: url, oauth: url)
|
||||||
|
|
||||||
|
proc mediaUrl(id: string; cursor: string): ApiReq =
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]),
|
||||||
|
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
|
||||||
)
|
)
|
||||||
|
|
||||||
proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl =
|
proc userTweetsUrl(id: string; cursor: string): ApiReq =
|
||||||
let
|
result = ApiReq(
|
||||||
cookieVariables = userTweetsVariables % [id, cursor]
|
# cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
|
||||||
oauthVariables = restIdVariables % [id, cursor]
|
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
|
||||||
result = SessionAwareUrl(
|
)
|
||||||
cookieUrl: graphUserTweets ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles},
|
# might change this in the future pending testing
|
||||||
oauthUrl: graphUserTweets ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles}
|
result.cookie = result.oauth
|
||||||
|
|
||||||
|
proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
|
||||||
|
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
|
||||||
|
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
|
||||||
)
|
)
|
||||||
|
|
||||||
proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl =
|
proc tweetDetailUrl(id: string; cursor: string): ApiReq =
|
||||||
let
|
let cookieVars = tweetDetailVars % [id, cursor]
|
||||||
cookieVariables = userTweetsAndRepliesVariables % [id, cursor]
|
result = ApiReq(
|
||||||
oauthVariables = restIdVariables % [id, cursor]
|
cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
|
||||||
result = SessionAwareUrl(
|
oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
|
||||||
cookieUrl: graphUserTweetsAndReplies ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles},
|
|
||||||
oauthUrl: graphUserTweetsAndReplies ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl =
|
proc userUrl(username: string): ApiReq =
|
||||||
let
|
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
|
||||||
cookieVariables = tweetDetailVariables % [id, cursor]
|
result = ApiReq(
|
||||||
oauthVariables = tweetVariables % [id, cursor]
|
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
|
||||||
result = SessionAwareUrl(
|
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
|
||||||
cookieUrl: graphTweetDetail ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": tweetDetailFieldToggles},
|
|
||||||
oauthUrl: graphTweetDetail ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": tweetDetailFieldToggles}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
proc getGraphUser*(username: string): Future[User] {.async.} =
|
proc getGraphUser*(username: string): Future[User] {.async.} =
|
||||||
if username.len == 0: return
|
if username.len == 0: return
|
||||||
let
|
let js = await fetchRaw(userUrl(username))
|
||||||
variables = """{"screen_name": "$1"}""" % username
|
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
|
||||||
js = await fetchRaw(graphUser ? params, Api.userScreenName)
|
|
||||||
result = parseGraphUser(js)
|
result = parseGraphUser(js)
|
||||||
|
|
||||||
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||||
if id.len == 0 or id.any(c => not c.isDigit): return
|
if id.len == 0 or id.any(c => not c.isDigit): return
|
||||||
let
|
let
|
||||||
variables = """{"rest_id": "$1"}""" % id
|
url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
js = await fetchRaw(url)
|
||||||
js = await fetchRaw(graphUserById ? params, Api.userRestId)
|
|
||||||
result = parseGraphUser(js)
|
result = parseGraphUser(js)
|
||||||
|
|
||||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
js = case kind
|
url = case kind
|
||||||
of TimelineKind.tweets:
|
of TimelineKind.tweets: userTweetsUrl(id, cursor)
|
||||||
await fetch(userTweetsUrl(id, cursor), Api.userTweets)
|
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
|
||||||
of TimelineKind.replies:
|
of TimelineKind.media: mediaUrl(id, cursor)
|
||||||
await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies)
|
js = await fetch(url)
|
||||||
of TimelineKind.media:
|
|
||||||
await fetch(mediaUrl(id, cursor), Api.userMedia)
|
|
||||||
result = parseGraphTimeline(js, after)
|
result = parseGraphTimeline(js, after)
|
||||||
|
|
||||||
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
variables = restIdVariables % [id, cursor]
|
url = apiReq(graphListTweets, restIdVars % [id, cursor])
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
js = await fetch(url)
|
||||||
js = await fetch(graphListTweets ? params, Api.listTweets)
|
|
||||||
result = parseGraphTimeline(js, after).tweets
|
result = parseGraphTimeline(js, after).tweets
|
||||||
|
|
||||||
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
||||||
let
|
let
|
||||||
variables = %*{"screenName": name, "listSlug": list}
|
variables = %*{"screenName": name, "listSlug": list}
|
||||||
params = {"variables": $variables, "features": gqlFeatures}
|
url = apiReq(graphListBySlug, $variables)
|
||||||
url = graphListBySlug ? params
|
js = await fetch(url)
|
||||||
result = parseGraphList(await fetch(url, Api.listBySlug))
|
result = parseGraphList(js)
|
||||||
|
|
||||||
proc getGraphList*(id: string): Future[List] {.async.} =
|
proc getGraphList*(id: string): Future[List] {.async.} =
|
||||||
let
|
let
|
||||||
variables = """{"listId": "$1"}""" % id
|
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
js = await fetch(url)
|
||||||
url = graphListById ? params
|
result = parseGraphList(js)
|
||||||
result = parseGraphList(await fetch(url, Api.list))
|
|
||||||
|
|
||||||
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
||||||
if list.id.len == 0: return
|
if list.id.len == 0: return
|
||||||
@@ -104,22 +109,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
|||||||
}
|
}
|
||||||
if after.len > 0:
|
if after.len > 0:
|
||||||
variables["cursor"] = % after
|
variables["cursor"] = % after
|
||||||
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
|
let
|
||||||
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
|
url = apiReq(graphListMembers, $variables)
|
||||||
|
js = await fetchRaw(url)
|
||||||
|
result = parseGraphListMembers(js, after)
|
||||||
|
|
||||||
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
variables = """{"rest_id": "$1"}""" % id
|
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
js = await fetch(url)
|
||||||
js = await fetch(graphTweetResult ? params, Api.tweetResult)
|
|
||||||
result = parseGraphTweetResult(js)
|
result = parseGraphTweetResult(js)
|
||||||
|
|
||||||
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail)
|
js = await fetch(tweetDetailUrl(id, cursor))
|
||||||
result = parseGraphConversation(js, id)
|
result = parseGraphConversation(js, id)
|
||||||
|
|
||||||
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
||||||
@@ -139,6 +145,7 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
|||||||
var
|
var
|
||||||
variables = %*{
|
variables = %*{
|
||||||
"rawQuery": q,
|
"rawQuery": q,
|
||||||
|
"query_source": "typedQuery",
|
||||||
"count": 20,
|
"count": 20,
|
||||||
"product": "Latest",
|
"product": "Latest",
|
||||||
"withDownvotePerspective": false,
|
"withDownvotePerspective": false,
|
||||||
@@ -147,8 +154,10 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
|||||||
}
|
}
|
||||||
if after.len > 0:
|
if after.len > 0:
|
||||||
variables["cursor"] = % after
|
variables["cursor"] = % after
|
||||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
let
|
||||||
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after)
|
url = apiReq(graphSearchTimeline, $variables)
|
||||||
|
js = await fetch(url)
|
||||||
|
result = parseGraphSearch[Tweets](js, after)
|
||||||
result.query = query
|
result.query = query
|
||||||
|
|
||||||
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
||||||
@@ -158,6 +167,7 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
|
|||||||
var
|
var
|
||||||
variables = %*{
|
variables = %*{
|
||||||
"rawQuery": query.text,
|
"rawQuery": query.text,
|
||||||
|
"query_source": "typedQuery",
|
||||||
"count": 20,
|
"count": 20,
|
||||||
"product": "People",
|
"product": "People",
|
||||||
"withDownvotePerspective": false,
|
"withDownvotePerspective": false,
|
||||||
@@ -168,13 +178,15 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
|
|||||||
variables["cursor"] = % after
|
variables["cursor"] = % after
|
||||||
result.beginning = false
|
result.beginning = false
|
||||||
|
|
||||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
let
|
||||||
result = parseGraphSearch[User](await fetch(url, Api.search), after)
|
url = apiReq(graphSearchTimeline, $variables)
|
||||||
|
js = await fetch(url)
|
||||||
|
result = parseGraphSearch[User](js, after)
|
||||||
result.query = query
|
result.query = query
|
||||||
|
|
||||||
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let js = await fetch(mediaUrl(id, ""), Api.userMedia)
|
let js = await fetch(mediaUrl(id, ""))
|
||||||
result = parseGraphPhotoRail(js)
|
result = parseGraphPhotoRail(js)
|
||||||
|
|
||||||
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables, logging
|
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables, logging
|
||||||
import jsony, packedjson, zippy, oauth1
|
import jsony, packedjson, zippy, oauth1
|
||||||
import types, auth, consts, parserutils, http_pool
|
import types, auth, consts, parserutils, http_pool, tid
|
||||||
import experimental/types/common
|
import experimental/types/common
|
||||||
|
|
||||||
const
|
const
|
||||||
rlRemaining = "x-rate-limit-remaining"
|
rlRemaining = "x-rate-limit-remaining"
|
||||||
rlReset = "x-rate-limit-reset"
|
rlReset = "x-rate-limit-reset"
|
||||||
rlLimit = "x-rate-limit-limit"
|
rlLimit = "x-rate-limit-limit"
|
||||||
errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
|
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
|
||||||
|
|
||||||
var pool: HttpPool
|
var
|
||||||
|
pool: HttpPool
|
||||||
|
disableTid: bool
|
||||||
|
|
||||||
|
proc setDisableTid*(disable: bool) =
|
||||||
|
disableTid = disable
|
||||||
|
|
||||||
|
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
|
||||||
|
case sessionKind
|
||||||
|
of oauth:
|
||||||
|
let o = req.oauth
|
||||||
|
parseUri("https://api.x.com/graphql") / o.endpoint ? o.params
|
||||||
|
of cookie:
|
||||||
|
let c = req.cookie
|
||||||
|
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
|
||||||
|
|
||||||
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
|
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
|
||||||
let
|
let
|
||||||
@@ -32,15 +46,15 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
|
|||||||
proc getCookieHeader(authToken, ct0: string): string =
|
proc getCookieHeader(authToken, ct0: string): string =
|
||||||
"auth_token=" & authToken & "; ct0=" & ct0
|
"auth_token=" & authToken & "; ct0=" & ct0
|
||||||
|
|
||||||
proc genHeaders*(session: Session, url: string): HttpHeaders =
|
proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
|
||||||
result = newHttpHeaders({
|
result = newHttpHeaders({
|
||||||
"connection": "keep-alive",
|
"connection": "keep-alive",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"x-twitter-active-user": "yes",
|
"x-twitter-active-user": "yes",
|
||||||
"x-twitter-client-language": "en",
|
"x-twitter-client-language": "en",
|
||||||
"authority": "api.x.com",
|
"origin": "https://x.com",
|
||||||
"accept-encoding": "gzip",
|
"accept-encoding": "gzip",
|
||||||
"accept-language": "en-US,en;q=0.9",
|
"accept-language": "en-US,en;q=0.5",
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"DNT": "1",
|
"DNT": "1",
|
||||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||||
@@ -48,15 +62,20 @@ proc genHeaders*(session: Session, url: string): HttpHeaders =
|
|||||||
|
|
||||||
case session.kind
|
case session.kind
|
||||||
of SessionKind.oauth:
|
of SessionKind.oauth:
|
||||||
result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret)
|
result["authority"] = "api.x.com"
|
||||||
|
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
|
||||||
of SessionKind.cookie:
|
of SessionKind.cookie:
|
||||||
result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
|
|
||||||
result["x-twitter-auth-type"] = "OAuth2Session"
|
result["x-twitter-auth-type"] = "OAuth2Session"
|
||||||
result["x-csrf-token"] = session.ct0
|
result["x-csrf-token"] = session.ct0
|
||||||
result["cookie"] = getCookieHeader(session.authToken, session.ct0)
|
result["cookie"] = getCookieHeader(session.authToken, session.ct0)
|
||||||
|
if disableTid:
|
||||||
|
result["authorization"] = bearerToken2
|
||||||
|
else:
|
||||||
|
result["authorization"] = bearerToken
|
||||||
|
result["x-client-transaction-id"] = await genTid(url.path)
|
||||||
|
|
||||||
proc getAndValidateSession*(api: Api): Future[Session] {.async.} =
|
proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
|
||||||
result = await getSession(api)
|
result = await getSession(req)
|
||||||
case result.kind
|
case result.kind
|
||||||
of SessionKind.oauth:
|
of SessionKind.oauth:
|
||||||
if result.oauthToken.len == 0:
|
if result.oauthToken.len == 0:
|
||||||
@@ -73,7 +92,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
var resp: AsyncResponse
|
var resp: AsyncResponse
|
||||||
pool.use(genHeaders(session, $url)):
|
pool.use(await genHeaders(session, url)):
|
||||||
template getContent =
|
template getContent =
|
||||||
resp = await c.get($url)
|
resp = await c.get($url)
|
||||||
result = await resp.body
|
result = await resp.body
|
||||||
@@ -89,7 +108,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
|||||||
remaining = parseInt(resp.headers[rlRemaining])
|
remaining = parseInt(resp.headers[rlRemaining])
|
||||||
reset = parseInt(resp.headers[rlReset])
|
reset = parseInt(resp.headers[rlReset])
|
||||||
limit = parseInt(resp.headers[rlLimit])
|
limit = parseInt(resp.headers[rlLimit])
|
||||||
session.setRateLimit(api, remaining, reset, limit)
|
session.setRateLimit(req, remaining, reset, limit)
|
||||||
|
|
||||||
if result.len > 0:
|
if result.len > 0:
|
||||||
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
||||||
@@ -98,24 +117,22 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
|||||||
if result.startsWith("{\"errors"):
|
if result.startsWith("{\"errors"):
|
||||||
let errors = result.fromJson(Errors)
|
let errors = result.fromJson(Errors)
|
||||||
if errors notin errorsToSkip:
|
if errors notin errorsToSkip:
|
||||||
error "Fetch error, API: ", api, ", errors: ", errors
|
error "Fetch error, API: ", url.path, ", errors: ", errors
|
||||||
if errors in {expiredToken, badToken, locked}:
|
if errors in {expiredToken, badToken, locked}:
|
||||||
invalidate(session)
|
invalidate(session)
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
elif errors in {rateLimited}:
|
elif errors in {rateLimited}:
|
||||||
# rate limit hit, resets after 24 hours
|
# rate limit hit, resets after 24 hours
|
||||||
setLimited(session, api)
|
setLimited(session, req)
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
elif result.startsWith("429 Too Many Requests"):
|
elif result.startsWith("429 Too Many Requests"):
|
||||||
warn "[sessions] 429 error, API: ", api, ", session: ", session.pretty
|
warn "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
|
||||||
session.apis[api].remaining = 0
|
|
||||||
# rate limit hit, resets after the 15 minute window
|
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
|
|
||||||
fetchBody
|
fetchBody
|
||||||
|
|
||||||
if resp.status == $Http400:
|
if resp.status == $Http400:
|
||||||
error "ERROR 400, ", api, ": ", result
|
error "ERROR 400, ", url.path, ": ", result
|
||||||
raise newException(InternalError, $url)
|
raise newException(InternalError, $url)
|
||||||
except InternalError as e:
|
except InternalError as e:
|
||||||
raise e
|
raise e
|
||||||
@@ -137,19 +154,16 @@ template retry(bod) =
|
|||||||
try:
|
try:
|
||||||
bod
|
bod
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
info "[sessions] Rate limited, retrying ", api, " request..."
|
info "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..."
|
||||||
bod
|
bod
|
||||||
|
|
||||||
proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} =
|
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
|
||||||
retry:
|
retry:
|
||||||
var
|
var
|
||||||
body: string
|
body: string
|
||||||
session = await getAndValidateSession(api)
|
session = await getAndValidateSession(req)
|
||||||
|
|
||||||
when url is SessionAwareUrl:
|
let url = req.toUrl(session.kind)
|
||||||
let url = case session.kind
|
|
||||||
of SessionKind.oauth: url.oauthUrl
|
|
||||||
of SessionKind.cookie: url.cookieUrl
|
|
||||||
|
|
||||||
fetchImpl body:
|
fetchImpl body:
|
||||||
if body.startsWith('{') or body.startsWith('['):
|
if body.startsWith('{') or body.startsWith('['):
|
||||||
@@ -158,21 +172,17 @@ proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} =
|
|||||||
warn resp.status, ": ", body, " --- url: ", url
|
warn resp.status, ": ", body, " --- url: ", url
|
||||||
result = newJNull()
|
result = newJNull()
|
||||||
|
|
||||||
let apiErr = result.getError
|
let error = result.getError
|
||||||
if apiErr != null and apiErr notin errorsToSkip:
|
if error != null and error notin errorsToSkip:
|
||||||
error "Fetch error, API: ", api, ", error: ", apiErr
|
error "Fetch error, API: ", url.path, ", error: ", error
|
||||||
if apiErr in {expiredToken, badToken, locked}:
|
if error in {expiredToken, badToken, locked}:
|
||||||
invalidate(session)
|
invalidate(session)
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
|
|
||||||
proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} =
|
proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
|
||||||
retry:
|
retry:
|
||||||
var session = await getAndValidateSession(api)
|
var session = await getAndValidateSession(req)
|
||||||
|
let url = req.toUrl(session.kind)
|
||||||
when url is SessionAwareUrl:
|
|
||||||
let url = case session.kind
|
|
||||||
of SessionKind.oauth: url.oauthUrl
|
|
||||||
of SessionKind.cookie: url.cookieUrl
|
|
||||||
|
|
||||||
fetchImpl result:
|
fetchImpl result:
|
||||||
if not (result.startsWith('{') or result.startsWith('[')):
|
if not (result.startsWith('{') or result.startsWith('[')):
|
||||||
|
|||||||
30
src/auth.nim
30
src/auth.nim
@@ -1,6 +1,6 @@
|
|||||||
#SPDX-License-Identifier: AGPL-3.0-only
|
#SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os, logging]
|
import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os, logging]
|
||||||
import types
|
import types, consts
|
||||||
import experimental/parser/session
|
import experimental/parser/session
|
||||||
|
|
||||||
# max requests at a time per session to avoid race conditions
|
# max requests at a time per session to avoid race conditions
|
||||||
@@ -18,6 +18,11 @@ proc logSession(args: varargs[string, `$`]) =
|
|||||||
s.add arg
|
s.add arg
|
||||||
info s
|
info s
|
||||||
|
|
||||||
|
proc endpoint(req: ApiReq; session: Session): string =
|
||||||
|
case session.kind
|
||||||
|
of oauth: req.oauth.endpoint
|
||||||
|
of cookie: req.cookie.endpoint
|
||||||
|
|
||||||
proc pretty*(session: Session): string =
|
proc pretty*(session: Session): string =
|
||||||
if session.isNil:
|
if session.isNil:
|
||||||
return "<null>"
|
return "<null>"
|
||||||
@@ -125,11 +130,12 @@ proc rateLimitError*(): ref RateLimitError =
|
|||||||
proc noSessionsError*(): ref NoSessionsError =
|
proc noSessionsError*(): ref NoSessionsError =
|
||||||
newException(NoSessionsError, "no sessions available")
|
newException(NoSessionsError, "no sessions available")
|
||||||
|
|
||||||
proc isLimited(session: Session; api: Api): bool =
|
proc isLimited(session: Session; req: ApiReq): bool =
|
||||||
if session.isNil:
|
if session.isNil:
|
||||||
return true
|
return true
|
||||||
|
|
||||||
if session.limited and api != Api.userTweets:
|
let api = req.endpoint(session)
|
||||||
|
if session.limited and api != graphUserTweetsV2:
|
||||||
if (epochTime().int - session.limitedAt) > hourInSeconds:
|
if (epochTime().int - session.limitedAt) > hourInSeconds:
|
||||||
session.limited = false
|
session.limited = false
|
||||||
logSession "resetting limit: ", session.pretty
|
logSession "resetting limit: ", session.pretty
|
||||||
@@ -143,8 +149,8 @@ proc isLimited(session: Session; api: Api): bool =
|
|||||||
else:
|
else:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
proc isReady(session: Session; api: Api): bool =
|
proc isReady(session: Session; req: ApiReq): bool =
|
||||||
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api))
|
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
|
||||||
|
|
||||||
proc invalidate*(session: var Session) =
|
proc invalidate*(session: var Session) =
|
||||||
if session.isNil: return
|
if session.isNil: return
|
||||||
@@ -159,24 +165,26 @@ proc release*(session: Session) =
|
|||||||
if session.isNil: return
|
if session.isNil: return
|
||||||
dec session.pending
|
dec session.pending
|
||||||
|
|
||||||
proc getSession*(api: Api): Future[Session] {.async.} =
|
proc getSession*(req: ApiReq): Future[Session] {.async.} =
|
||||||
for i in 0 ..< sessionPool.len:
|
for i in 0 ..< sessionPool.len:
|
||||||
if result.isReady(api): break
|
if result.isReady(req): break
|
||||||
result = sessionPool.sample()
|
result = sessionPool.sample()
|
||||||
|
|
||||||
if not result.isNil and result.isReady(api):
|
if not result.isNil and result.isReady(req):
|
||||||
inc result.pending
|
inc result.pending
|
||||||
else:
|
else:
|
||||||
logSession "no sessions available for API: ", api
|
logSession "no sessions available for API: ", req.cookie.endpoint
|
||||||
raise noSessionsError()
|
raise noSessionsError()
|
||||||
|
|
||||||
proc setLimited*(session: Session; api: Api) =
|
proc setLimited*(session: Session; req: ApiReq) =
|
||||||
|
let api = req.endpoint(session)
|
||||||
session.limited = true
|
session.limited = true
|
||||||
session.limitedAt = epochTime().int
|
session.limitedAt = epochTime().int
|
||||||
logSession "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty
|
logSession "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty
|
||||||
|
|
||||||
proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) =
|
proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) =
|
||||||
# avoid undefined behavior in race conditions
|
# avoid undefined behavior in race conditions
|
||||||
|
let api = req.endpoint(session)
|
||||||
if api in session.apis:
|
if api in session.apis:
|
||||||
let rateLimit = session.apis[api]
|
let rateLimit = session.apis[api]
|
||||||
if rateLimit.reset >= reset and rateLimit.remaining < remaining:
|
if rateLimit.reset >= reset and rateLimit.remaining < remaining:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import parsecfg except Config
|
import parsecfg except Config
|
||||||
import types, strutils
|
import types, strutils, sequtils
|
||||||
|
|
||||||
proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
|
proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
|
||||||
let val = config.getSectionValue(section, key)
|
let val = config.getSectionValue(section, key)
|
||||||
@@ -9,6 +9,7 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
|
|||||||
when T is int: parseInt(val)
|
when T is int: parseInt(val)
|
||||||
elif T is bool: parseBool(val)
|
elif T is bool: parseBool(val)
|
||||||
elif T is string: val
|
elif T is string: val
|
||||||
|
elif T is seq[string]: val.split(',').mapIt(it.strip())
|
||||||
|
|
||||||
proc getConfig*(path: string): (Config, parseCfg.Config) =
|
proc getConfig*(path: string): (Config, parseCfg.Config) =
|
||||||
var cfg = loadConfig(path)
|
var cfg = loadConfig(path)
|
||||||
@@ -41,7 +42,9 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
|||||||
enableDebug: cfg.get("Config", "enableDebug", false),
|
enableDebug: cfg.get("Config", "enableDebug", false),
|
||||||
logLevel: cfg.get("Config", "logLevel", ""),
|
logLevel: cfg.get("Config", "logLevel", ""),
|
||||||
proxy: cfg.get("Config", "proxy", ""),
|
proxy: cfg.get("Config", "proxy", ""),
|
||||||
proxyAuth: cfg.get("Config", "proxyAuth", "")
|
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||||
|
defaultFollowedAccounts: cfg.get("Config", "defaultFollowedAccounts", @["eff", "fsf"]),
|
||||||
|
disableTid: cfg.get("Config", "disableTid", false)
|
||||||
)
|
)
|
||||||
|
|
||||||
return (conf, cfg)
|
return (conf, cfg)
|
||||||
|
|||||||
@@ -1,32 +1,36 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import uri, strutils
|
import strutils
|
||||||
|
|
||||||
const
|
const
|
||||||
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
||||||
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
||||||
|
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||||
|
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
|
||||||
|
|
||||||
gql = parseUri("https://api.x.com") / "graphql"
|
graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
|
||||||
|
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
|
||||||
graphUser* = gql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
|
graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
|
||||||
graphUserById* = gql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
|
graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
|
||||||
graphUserTweetsV2* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2"
|
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
|
||||||
graphUserTweetsAndRepliesV2* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2"
|
graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
|
||||||
graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
|
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
|
||||||
graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
|
graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
|
||||||
graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
|
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
|
||||||
graphUserMediaV2* = gql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2"
|
graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
|
||||||
graphTweet* = gql / "Vorskcd2tZ-tc4Gx3zbk4Q/ConversationTimelineV2"
|
graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
|
||||||
graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
|
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
|
||||||
graphTweetResult* = gql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
|
graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
|
||||||
graphSearchTimeline* = gql / "7r8ibjHuK3MWUyzkzHNMYQ/SearchTimeline"
|
graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
|
||||||
graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
|
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
||||||
graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
||||||
graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
||||||
graphListTweets* = gql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
|
|
||||||
|
|
||||||
gqlFeatures* = """{
|
gqlFeatures* = """{
|
||||||
|
"android_ad_formats_media_component_render_overlay_enabled": false,
|
||||||
"android_graphql_skip_api_media_color_palette": false,
|
"android_graphql_skip_api_media_color_palette": false,
|
||||||
|
"android_professional_link_spotlight_display_enabled": false,
|
||||||
"blue_business_profile_image_shape_enabled": false,
|
"blue_business_profile_image_shape_enabled": false,
|
||||||
|
"commerce_android_shop_module_enabled": false,
|
||||||
"creator_subscriptions_subscription_count_enabled": false,
|
"creator_subscriptions_subscription_count_enabled": false,
|
||||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||||
"freedom_of_speech_not_reach_fetch_enabled": true,
|
"freedom_of_speech_not_reach_fetch_enabled": true,
|
||||||
@@ -36,8 +40,9 @@ const
|
|||||||
"interactive_text_enabled": false,
|
"interactive_text_enabled": false,
|
||||||
"longform_notetweets_consumption_enabled": true,
|
"longform_notetweets_consumption_enabled": true,
|
||||||
"longform_notetweets_inline_media_enabled": true,
|
"longform_notetweets_inline_media_enabled": true,
|
||||||
"longform_notetweets_richtext_consumption_enabled": true,
|
|
||||||
"longform_notetweets_rich_text_read_enabled": true,
|
"longform_notetweets_rich_text_read_enabled": true,
|
||||||
|
"longform_notetweets_richtext_consumption_enabled": true,
|
||||||
|
"mobile_app_spotlight_module_enabled": false,
|
||||||
"responsive_web_edit_tweet_api_enabled": true,
|
"responsive_web_edit_tweet_api_enabled": true,
|
||||||
"responsive_web_enhance_cards_enabled": false,
|
"responsive_web_enhance_cards_enabled": false,
|
||||||
"responsive_web_graphql_exclude_directive_enabled": true,
|
"responsive_web_graphql_exclude_directive_enabled": true,
|
||||||
@@ -46,6 +51,7 @@ const
|
|||||||
"responsive_web_media_download_video_enabled": false,
|
"responsive_web_media_download_video_enabled": false,
|
||||||
"responsive_web_text_conversations_enabled": false,
|
"responsive_web_text_conversations_enabled": false,
|
||||||
"responsive_web_twitter_article_tweet_consumption_enabled": true,
|
"responsive_web_twitter_article_tweet_consumption_enabled": true,
|
||||||
|
"unified_cards_destination_url_params_enabled": false,
|
||||||
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
||||||
"rweb_lists_timeline_redesign_enabled": true,
|
"rweb_lists_timeline_redesign_enabled": true,
|
||||||
"spaces_2022_h2_clipping": true,
|
"spaces_2022_h2_clipping": true,
|
||||||
@@ -86,11 +92,21 @@ const
|
|||||||
"payments_enabled": false,
|
"payments_enabled": false,
|
||||||
"responsive_web_profile_redirect_enabled": false,
|
"responsive_web_profile_redirect_enabled": false,
|
||||||
"responsive_web_grok_show_grok_translated_post": false,
|
"responsive_web_grok_show_grok_translated_post": false,
|
||||||
"responsive_web_grok_community_note_auto_translation_is_enabled": false
|
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
|
||||||
|
"profile_label_improvements_pcf_label_in_profile_enabled": false,
|
||||||
|
"grok_android_analyze_trend_fetch_enabled": false,
|
||||||
|
"grok_translations_community_note_auto_translation_is_enabled": false,
|
||||||
|
"grok_translations_post_auto_translation_is_enabled": false,
|
||||||
|
"grok_translations_community_note_translation_is_enabled": false,
|
||||||
|
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false,
|
||||||
|
"subscriptions_feature_can_gift_premium": false,
|
||||||
|
"responsive_web_twitter_article_notes_tab_enabled": false,
|
||||||
|
"subscriptions_verification_info_is_identity_verified_enabled": false,
|
||||||
|
"hidden_profile_subscriptions_enabled": false
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
tweetVariables* = """{
|
tweetVars* = """{
|
||||||
"focalTweetId": "$1",
|
"postId": "$1",
|
||||||
$2
|
$2
|
||||||
"includeHasBirdwatchNotes": false,
|
"includeHasBirdwatchNotes": false,
|
||||||
"includePromotedContent": false,
|
"includePromotedContent": false,
|
||||||
@@ -99,7 +115,7 @@ const
|
|||||||
"withV2Timeline": true
|
"withV2Timeline": true
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
tweetDetailVariables* = """{
|
tweetDetailVars* = """{
|
||||||
"focalTweetId": "$1",
|
"focalTweetId": "$1",
|
||||||
$2
|
$2
|
||||||
"referrer": "profile",
|
"referrer": "profile",
|
||||||
@@ -112,12 +128,12 @@ const
|
|||||||
"withVoice": true
|
"withVoice": true
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
restIdVariables* = """{
|
restIdVars* = """{
|
||||||
"rest_id": "$1", $2
|
"rest_id": "$1", $2
|
||||||
"count": 20
|
"count": 20
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
userMediaVariables* = """{
|
userMediaVars* = """{
|
||||||
"userId": "$1", $2
|
"userId": "$1", $2
|
||||||
"count": 20,
|
"count": 20,
|
||||||
"includePromotedContent": false,
|
"includePromotedContent": false,
|
||||||
@@ -126,7 +142,7 @@ const
|
|||||||
"withVoice": true
|
"withVoice": true
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
userTweetsVariables* = """{
|
userTweetsVars* = """{
|
||||||
"userId": "$1", $2
|
"userId": "$1", $2
|
||||||
"count": 20,
|
"count": 20,
|
||||||
"includePromotedContent": false,
|
"includePromotedContent": false,
|
||||||
@@ -134,7 +150,7 @@ const
|
|||||||
"withVoice": true
|
"withVoice": true
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
userTweetsAndRepliesVariables* = """{
|
userTweetsAndRepliesVars* = """{
|
||||||
"userId": "$1", $2
|
"userId": "$1", $2
|
||||||
"count": 20,
|
"count": 20,
|
||||||
"includePromotedContent": false,
|
"includePromotedContent": false,
|
||||||
@@ -143,4 +159,6 @@ const
|
|||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
fieldToggles* = """{"withArticlePlainText":false,"withViewCounts":true}"""
|
fieldToggles* = """{"withArticlePlainText":false,"withViewCounts":true}"""
|
||||||
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false,"withViewCounts":true}"""
|
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
|
||||||
|
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
|
||||||
|
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
8
src/experimental/parser/tid.nim
Normal file
8
src/experimental/parser/tid.nim
Normal 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)
|
||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
4
src/experimental/types/tid.nim
Normal file
4
src/experimental/types/tid.nim
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type
|
||||||
|
TidPair* = object
|
||||||
|
animationKey*: string
|
||||||
|
verification*: string
|
||||||
@@ -6,7 +6,7 @@ import types, utils, query
|
|||||||
const
|
const
|
||||||
cards = "cards.twitter.com/cards"
|
cards = "cards.twitter.com/cards"
|
||||||
tco = "https://t.co"
|
tco = "https://t.co"
|
||||||
twitter = parseUri("https://twitter.com")
|
twitter = parseUri("https://x.com")
|
||||||
|
|
||||||
let
|
let
|
||||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
||||||
@@ -64,18 +64,20 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
|||||||
result = body
|
result = body
|
||||||
|
|
||||||
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
||||||
result = result.replace(ytRegex, prefs.replaceYouTube)
|
let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
|
||||||
|
result = result.replace(ytRegex, youtubeHost)
|
||||||
|
|
||||||
if prefs.replaceTwitter.len > 0:
|
if prefs.replaceTwitter.len > 0:
|
||||||
|
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
|
||||||
if tco in result:
|
if tco in result:
|
||||||
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
|
result = result.replace(tco, https & twitterHost & "/t.co")
|
||||||
if "x.com" in result:
|
if "x.com" in result:
|
||||||
result = result.replace(xRegex, prefs.replaceTwitter)
|
result = result.replace(xRegex, twitterHost)
|
||||||
result = result.replacef(xLinkRegex, a(
|
result = result.replacef(xLinkRegex, a(
|
||||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
twitterHost & "$2", href = https & twitterHost & "$1"))
|
||||||
if "twitter.com" in result:
|
if "twitter.com" in result:
|
||||||
result = result.replace(cards, prefs.replaceTwitter & "/cards")
|
result = result.replace(cards, twitterHost & "/cards")
|
||||||
result = result.replace(twRegex, prefs.replaceTwitter)
|
result = result.replace(twRegex, twitterHost)
|
||||||
result = result.replacef(twLinkRegex, a(
|
result = result.replacef(twLinkRegex, a(
|
||||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
||||||
if "imgur.com" in result:
|
if "imgur.com" in result:
|
||||||
@@ -96,9 +98,10 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
|||||||
prefs.replaceSoundCloud & "$4", href = https & prefs.replaceSoundCloud & "$2"))
|
prefs.replaceSoundCloud & "$4", href = https & prefs.replaceSoundCloud & "$2"))
|
||||||
|
|
||||||
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
|
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
|
||||||
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
|
let redditHost = strip(prefs.replaceReddit, chars={'/'})
|
||||||
result = result.replace(rdRegex, prefs.replaceReddit)
|
result = result.replace(rdShortRegex, redditHost & "/comments/")
|
||||||
if prefs.replaceReddit in result and "/gallery/" in result:
|
result = result.replace(rdRegex, redditHost)
|
||||||
|
if redditHost in result and "/gallery/" in result:
|
||||||
result = result.replace("/gallery/", "/comments/")
|
result = result.replace("/gallery/", "/comments/")
|
||||||
|
|
||||||
if absolute.len > 0 and "href" in result:
|
if absolute.len > 0 and "href" in result:
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from os import getEnv
|
|||||||
|
|
||||||
import jester
|
import jester
|
||||||
|
|
||||||
import types, config, prefs, formatters, redis_cache, http_pool, auth
|
import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
|
||||||
import views/[general, about]
|
import views/[general, about, search, homepage]
|
||||||
|
import karax/[vdom]
|
||||||
import routes/[
|
import routes/[
|
||||||
preferences, timeline, status, media, search, rss, list, debug,
|
preferences, timeline, status, media, search, rss, list, debug,
|
||||||
unsupported, embed, resolver, router_utils, follow]
|
unsupported, embed, resolver, router_utils, follow]
|
||||||
@@ -72,6 +73,7 @@ setHmacKey(cfg.hmacKey)
|
|||||||
setProxyEncoding(cfg.base64Media)
|
setProxyEncoding(cfg.base64Media)
|
||||||
setMaxHttpConns(cfg.httpMaxConns)
|
setMaxHttpConns(cfg.httpMaxConns)
|
||||||
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||||
|
setDisableTid(cfg.disableTid)
|
||||||
initAboutPage(cfg.staticDir)
|
initAboutPage(cfg.staticDir)
|
||||||
|
|
||||||
waitFor initRedisPool(cfg)
|
waitFor initRedisPool(cfg)
|
||||||
@@ -98,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":
|
||||||
|
|||||||
312
src/parser.nim
312
src/parser.nim
@@ -1,10 +1,10 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, options, times, math
|
import strutils, options, times, math, tables
|
||||||
import packedjson, packedjson/deserialiser
|
import packedjson, packedjson/deserialiser
|
||||||
import types, parserutils, utils
|
import types, parserutils, utils
|
||||||
import experimental/parser/unifiedcard
|
import experimental/parser/unifiedcard
|
||||||
|
|
||||||
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
|
proc parseGraphTweet(js: JsonNode): Tweet
|
||||||
|
|
||||||
proc parseUser(js: JsonNode; id=""): User =
|
proc parseUser(js: JsonNode; id=""): User =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
@@ -21,12 +21,17 @@ proc parseUser(js: JsonNode; id=""): User =
|
|||||||
tweets: js{"statuses_count"}.getInt,
|
tweets: js{"statuses_count"}.getInt,
|
||||||
likes: js{"favourites_count"}.getInt,
|
likes: js{"favourites_count"}.getInt,
|
||||||
media: js{"media_count"}.getInt,
|
media: js{"media_count"}.getInt,
|
||||||
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
|
protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
|
||||||
protected: js{"protected"}.getBool,
|
|
||||||
sensitive: "sensitive" in js{"profile_interstitial_type"}.getStr("") or js{"possibly_sensitive"}.getBool,
|
sensitive: "sensitive" in js{"profile_interstitial_type"}.getStr("") or js{"possibly_sensitive"}.getBool,
|
||||||
joinDate: js{"created_at"}.getTime
|
joinDate: js{"created_at"}.getTime
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if js{"is_blue_verified"}.getBool(false):
|
||||||
|
result.verifiedType = blue
|
||||||
|
|
||||||
|
with verifiedType, js{"verified_type"}:
|
||||||
|
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||||
|
|
||||||
result.expandUserEntities(js)
|
result.expandUserEntities(js)
|
||||||
|
|
||||||
proc parseGraphUser(js: JsonNode): User =
|
proc parseGraphUser(js: JsonNode): User =
|
||||||
@@ -42,6 +47,9 @@ proc parseGraphUser(js: JsonNode): User =
|
|||||||
|
|
||||||
result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
|
result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
|
||||||
|
|
||||||
|
if result.verifiedType == none and user{"is_blue_verified"}.getBool(false):
|
||||||
|
result.verifiedType = blue
|
||||||
|
|
||||||
# fallback to support UserMedia/recent GraphQL updates
|
# fallback to support UserMedia/recent GraphQL updates
|
||||||
if result.username.len == 0:
|
if result.username.len == 0:
|
||||||
result.username = user{"core", "screen_name"}.getStr
|
result.username = user{"core", "screen_name"}.getStr
|
||||||
@@ -91,16 +99,24 @@ proc parsePoll(js: JsonNode): Poll =
|
|||||||
result.leader = result.values.find(max(result.values))
|
result.leader = result.values.find(max(result.values))
|
||||||
result.votes = result.values.sum
|
result.votes = result.values.sum
|
||||||
|
|
||||||
proc parseGif(js: JsonNode): Gif =
|
proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] =
|
||||||
result = Gif(
|
result = @[]
|
||||||
url: js{"video_info", "variants"}[0]{"url"}.getImageStr,
|
for v in variants:
|
||||||
thumb: js{"media_url_https"}.getImageStr
|
let
|
||||||
|
url = v{"url"}.getStr
|
||||||
|
contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4"))
|
||||||
|
bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0))
|
||||||
|
|
||||||
|
result.add VideoVariant(
|
||||||
|
contentType: contentType,
|
||||||
|
bitrate: bitrate,
|
||||||
|
url: url,
|
||||||
|
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
proc parseVideo(js: JsonNode): Video =
|
proc parseVideo(js: JsonNode): Video =
|
||||||
result = Video(
|
result = Video(
|
||||||
thumb: js{"media_url_https"}.getImageStr,
|
thumb: js{"media_url_https"}.getImageStr,
|
||||||
views: getVideoViewCount(js),
|
|
||||||
available: true,
|
available: true,
|
||||||
title: js{"ext_alt_text"}.getStr,
|
title: js{"ext_alt_text"}.getStr,
|
||||||
durationMs: js{"video_info", "duration_millis"}.getInt
|
durationMs: js{"video_info", "duration_millis"}.getInt
|
||||||
@@ -117,17 +133,62 @@ proc parseVideo(js: JsonNode): Video =
|
|||||||
with description, js{"additional_media_info", "description"}:
|
with description, js{"additional_media_info", "description"}:
|
||||||
result.description = description.getStr
|
result.description = description.getStr
|
||||||
|
|
||||||
for v in js{"video_info", "variants"}:
|
result.variants = parseVideoVariants(js{"video_info", "variants"})
|
||||||
let
|
|
||||||
contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary"))
|
|
||||||
url = v{"url"}.getStr
|
|
||||||
|
|
||||||
result.variants.add VideoVariant(
|
proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
|
||||||
contentType: contentType,
|
with jsMedia, js{"extended_entities", "media"}:
|
||||||
bitrate: v{"bitrate"}.getInt,
|
for m in jsMedia:
|
||||||
url: url,
|
case m.getTypeName:
|
||||||
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
of "photo":
|
||||||
|
result.photos.add m{"media_url_https"}.getImageStr
|
||||||
|
of "video":
|
||||||
|
result.video = some(parseVideo(m))
|
||||||
|
with user, m{"additional_media_info", "source_user"}:
|
||||||
|
if user{"id"}.getInt > 0:
|
||||||
|
result.attribution = some(parseUser(user))
|
||||||
|
else:
|
||||||
|
result.attribution = some(parseGraphUser(user))
|
||||||
|
of "animated_gif":
|
||||||
|
result.gif = some Gif(
|
||||||
|
url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
|
||||||
|
thumb: m{"media_url_https"}.getImageStr
|
||||||
)
|
)
|
||||||
|
else: discard
|
||||||
|
|
||||||
|
with url, m{"url"}:
|
||||||
|
if result.text.endsWith(url.getStr):
|
||||||
|
result.text.removeSuffix(url.getStr)
|
||||||
|
result.text = result.text.strip()
|
||||||
|
|
||||||
|
proc parseMediaEntities(js: JsonNode; result: var Tweet) =
|
||||||
|
with mediaEntities, js{"media_entities"}:
|
||||||
|
for mediaEntity in mediaEntities:
|
||||||
|
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
|
||||||
|
case mediaInfo.getTypeName
|
||||||
|
of "ApiImage":
|
||||||
|
result.photos.add mediaInfo{"original_img_url"}.getImageStr
|
||||||
|
of "ApiVideo":
|
||||||
|
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
|
||||||
|
result.video = some Video(
|
||||||
|
available: status.getStr == "Available",
|
||||||
|
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
|
||||||
|
durationMs: mediaInfo{"duration_millis"}.getInt,
|
||||||
|
variants: parseVideoVariants(mediaInfo{"variants"})
|
||||||
|
)
|
||||||
|
of "ApiGif":
|
||||||
|
result.gif = some Gif(
|
||||||
|
url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
|
||||||
|
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr
|
||||||
|
)
|
||||||
|
else: discard
|
||||||
|
|
||||||
|
# Remove media URLs from text
|
||||||
|
with mediaList, js{"legacy", "entities", "media"}:
|
||||||
|
for url in mediaList:
|
||||||
|
let expandedUrl = url.getExpandedUrl
|
||||||
|
if result.text.endsWith(expandedUrl):
|
||||||
|
result.text.removeSuffix(expandedUrl)
|
||||||
|
result.text = result.text.strip()
|
||||||
|
|
||||||
proc parsePromoVideo(js: JsonNode): Video =
|
proc parsePromoVideo(js: JsonNode): Video =
|
||||||
result = Video(
|
result = Video(
|
||||||
@@ -207,7 +268,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
|||||||
|
|
||||||
for u in ? urls:
|
for u in ? urls:
|
||||||
if u{"url"}.getStr == result.url:
|
if u{"url"}.getStr == result.url:
|
||||||
result.url = u{"expanded_url"}.getStr
|
result.url = u.getExpandedUrl(result.url)
|
||||||
break
|
break
|
||||||
|
|
||||||
if kind in {videoDirectMessage, imageDirectMessage}:
|
if kind in {videoDirectMessage, imageDirectMessage}:
|
||||||
@@ -219,12 +280,17 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
|||||||
|
|
||||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
|
|
||||||
|
let time =
|
||||||
|
if js{"created_at"}.notNull: js{"created_at"}.getTime
|
||||||
|
else: js{"created_at_ms"}.getTimeFromMs
|
||||||
|
|
||||||
result = Tweet(
|
result = Tweet(
|
||||||
id: js{"id_str"}.getId,
|
id: js{"id_str"}.getId,
|
||||||
threadId: js{"conversation_id_str"}.getId,
|
threadId: js{"conversation_id_str"}.getId,
|
||||||
replyId: js{"in_reply_to_status_id_str"}.getId,
|
replyId: js{"in_reply_to_status_id_str"}.getId,
|
||||||
text: js{"full_text"}.getStr,
|
text: js{"full_text"}.getStr,
|
||||||
time: js{"created_at"}.getTime,
|
time: time,
|
||||||
hasThread: js{"self_thread"}.notNull,
|
hasThread: js{"self_thread"}.notNull,
|
||||||
sensitive: js{"possibly_sensitive"}.getBool,
|
sensitive: js{"possibly_sensitive"}.getBool,
|
||||||
available: true,
|
available: true,
|
||||||
@@ -233,7 +299,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
replies: js{"reply_count"}.getInt,
|
replies: js{"reply_count"}.getInt,
|
||||||
retweets: js{"retweet_count"}.getInt,
|
retweets: js{"retweet_count"}.getInt,
|
||||||
likes: js{"favorite_count"}.getInt,
|
likes: js{"favorite_count"}.getInt,
|
||||||
quotes: js{"quote_count"}.getInt,
|
|
||||||
views: js{"views_count"}.getInt
|
views: js{"views_count"}.getInt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -259,6 +324,12 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
result.retweet = some parseGraphTweet(rt)
|
result.retweet = some parseGraphTweet(rt)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
with reposts, js{"repostedStatusResults"}:
|
||||||
|
with rt, reposts{"result"}:
|
||||||
|
if "legacy" in rt:
|
||||||
|
result.retweet = some parseGraphTweet(rt)
|
||||||
|
return
|
||||||
|
|
||||||
if jsCard.kind != JNull:
|
if jsCard.kind != JNull:
|
||||||
let name = jsCard{"name"}.getStr
|
let name = jsCard{"name"}.getStr
|
||||||
if "poll" in name:
|
if "poll" in name:
|
||||||
@@ -272,27 +343,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
||||||
|
|
||||||
result.expandTweetEntities(js)
|
result.expandTweetEntities(js)
|
||||||
|
parseLegacyMediaEntities(js, result)
|
||||||
with jsMedia, js{"extended_entities", "media"}:
|
|
||||||
for m in jsMedia:
|
|
||||||
case m{"type"}.getStr
|
|
||||||
of "photo":
|
|
||||||
result.photos.add m{"media_url_https"}.getImageStr
|
|
||||||
of "video":
|
|
||||||
result.video = some(parseVideo(m))
|
|
||||||
with user, m{"additional_media_info", "source_user"}:
|
|
||||||
if user{"id"}.getInt > 0:
|
|
||||||
result.attribution = some(parseUser(user))
|
|
||||||
else:
|
|
||||||
result.attribution = some(parseGraphUser(user))
|
|
||||||
of "animated_gif":
|
|
||||||
result.gif = some(parseGif(m))
|
|
||||||
else: discard
|
|
||||||
|
|
||||||
with url, m{"url"}:
|
|
||||||
if result.text.endsWith(url.getStr):
|
|
||||||
result.text.removeSuffix(url.getStr)
|
|
||||||
result.text = result.text.strip()
|
|
||||||
|
|
||||||
with jsWithheld, js{"withheld_in_countries"}:
|
with jsWithheld, js{"withheld_in_countries"}:
|
||||||
let withheldInCountries: seq[string] =
|
let withheldInCountries: seq[string] =
|
||||||
@@ -308,95 +359,108 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
result.text.removeSuffix(" Learn more.")
|
result.text.removeSuffix(" Learn more.")
|
||||||
result.available = false
|
result.available = false
|
||||||
|
|
||||||
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
proc parseGraphTweet(js: JsonNode): Tweet =
|
||||||
if js.kind == JNull:
|
if js.kind == JNull:
|
||||||
return Tweet()
|
return Tweet()
|
||||||
|
|
||||||
case js{"__typename"}.getStr
|
case js.getTypeName:
|
||||||
of "TweetUnavailable":
|
of "TweetUnavailable":
|
||||||
return Tweet()
|
return Tweet()
|
||||||
of "TweetTombstone":
|
of "TweetTombstone":
|
||||||
with text, js{"tombstone", "richText"}:
|
with text, select(js{"tombstone", "richText"}, js{"tombstone", "text"}):
|
||||||
return Tweet(text: text.getTombstone)
|
|
||||||
with text, js{"tombstone", "text"}:
|
|
||||||
return Tweet(text: text.getTombstone)
|
return Tweet(text: text.getTombstone)
|
||||||
return Tweet()
|
return Tweet()
|
||||||
of "TweetPreviewDisplay":
|
of "TweetPreviewDisplay":
|
||||||
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
||||||
of "TweetWithVisibilityResults":
|
of "TweetWithVisibilityResults":
|
||||||
return parseGraphTweet(js{"tweet"}, isLegacy)
|
return parseGraphTweet(js{"tweet"})
|
||||||
else:
|
else:
|
||||||
discard
|
discard
|
||||||
|
|
||||||
if not js.hasKey("legacy"):
|
if not js.hasKey("legacy"):
|
||||||
return Tweet()
|
return Tweet()
|
||||||
|
|
||||||
var jsCard = copy(js{if isLegacy: "card" else: "tweet_card", "legacy"})
|
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
|
||||||
if jsCard.kind != JNull:
|
if jsCard.kind != JNull:
|
||||||
var values = newJObject()
|
let legacyCard = jsCard{"legacy"}
|
||||||
for val in jsCard["binding_values"]:
|
if legacyCard.kind != JNull:
|
||||||
values[val["key"].getStr] = val["value"]
|
let bindingArray = legacyCard{"binding_values"}
|
||||||
jsCard["binding_values"] = values
|
if bindingArray.kind == JArray:
|
||||||
|
var bindingObj: seq[(string, JsonNode)]
|
||||||
|
for item in bindingArray:
|
||||||
|
bindingObj.add((item{"key"}.getStr, item{"value"}))
|
||||||
|
# Create a new card object with flattened structure
|
||||||
|
jsCard = %*{
|
||||||
|
"name": legacyCard{"name"},
|
||||||
|
"url": legacyCard{"url"},
|
||||||
|
"binding_values": %bindingObj
|
||||||
|
}
|
||||||
|
|
||||||
result = parseTweet(js{"legacy"}, jsCard)
|
result = parseTweet(js{"legacy"}, jsCard)
|
||||||
result.id = js{"rest_id"}.getId
|
result.id = js{"rest_id"}.getId
|
||||||
result.user = parseGraphUser(js{"core"})
|
result.user = parseGraphUser(js{"core"})
|
||||||
|
|
||||||
|
if result.replyId == 0:
|
||||||
|
result.replyId = js{"reply_to_results", "rest_id"}.getId
|
||||||
|
|
||||||
with count, js{"views", "count"}:
|
with count, js{"views", "count"}:
|
||||||
result.stats.views = count.getStr("0").parseInt
|
result.stats.views = count.getStr("0").parseInt
|
||||||
|
|
||||||
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
||||||
result.expandNoteTweetEntities(noteTweet)
|
result.expandNoteTweetEntities(noteTweet)
|
||||||
|
|
||||||
|
parseMediaEntities(js, result)
|
||||||
|
|
||||||
if result.quote.isSome:
|
if result.quote.isSome:
|
||||||
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy))
|
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
|
||||||
|
|
||||||
|
with quoted, js{"quotedPostResults", "result"}:
|
||||||
|
result.quote = some(parseGraphTweet(quoted))
|
||||||
|
|
||||||
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
||||||
for t in js{"content", "items"}:
|
for t in ? js{"content", "items"}:
|
||||||
let entryId = t{"entryId"}.getStr
|
let entryId = t.getEntryId
|
||||||
if "cursor-showmore" in entryId:
|
if "cursor-showmore" in entryId:
|
||||||
let cursor = t{"item", "content", "value"}
|
let cursor = t{"item", "content", "value"}
|
||||||
result.thread.cursor = cursor.getStr
|
result.thread.cursor = cursor.getStr
|
||||||
result.thread.hasMore = true
|
result.thread.hasMore = true
|
||||||
elif "tweet" in entryId and "promoted" notin entryId:
|
elif "tweet" in entryId and "promoted" notin entryId:
|
||||||
let
|
with tweet, t.getTweetResult("item"):
|
||||||
isLegacy = t{"item"}.hasKey("itemContent")
|
result.thread.content.add parseGraphTweet(tweet)
|
||||||
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
|
|
||||||
else: ("content", "tweetResult")
|
|
||||||
|
|
||||||
with content, t{"item", contentKey}:
|
let tweetDisplayType = select(
|
||||||
result.thread.content.add parseGraphTweet(content{resultKey, "result"}, isLegacy)
|
t{"item", "content", "tweet_display_type"},
|
||||||
|
t{"item", "itemContent", "tweetDisplayType"}
|
||||||
if content{"tweetDisplayType"}.getStr == "SelfThread":
|
)
|
||||||
|
if tweetDisplayType.getStr == "SelfThread":
|
||||||
result.self = true
|
result.self = true
|
||||||
|
|
||||||
proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
||||||
with tweet, js{"data", "tweet_result", "result"}:
|
with tweet, js{"data", "tweet_result", "result"}:
|
||||||
result = parseGraphTweet(tweet, false)
|
result = parseGraphTweet(tweet)
|
||||||
|
|
||||||
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
||||||
result = Conversation(replies: Result[Chain](beginning: true))
|
result = Conversation(replies: Result[Chain](beginning: true))
|
||||||
let
|
|
||||||
v2 = js{"data", "timeline_response"}.notNull
|
|
||||||
rootKey = if v2: "timeline_response" else: "threaded_conversation_with_injections_v2"
|
|
||||||
contentKey = if v2: "content" else: "itemContent"
|
|
||||||
resultKey = if v2: "tweetResult" else: "tweet_results"
|
|
||||||
|
|
||||||
let instructions = ? js{"data", rootKey, "instructions"}
|
let instructions = ? select(
|
||||||
|
js{"data", "timelineResponse", "instructions"},
|
||||||
|
js{"data", "timeline_response", "instructions"},
|
||||||
|
js{"data", "threaded_conversation_with_injections_v2", "instructions"}
|
||||||
|
)
|
||||||
if instructions.len == 0:
|
if instructions.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
for i in instructions:
|
for i in instructions:
|
||||||
let instrType = i{"type"}.getStr(i{"__typename"}.getStr)
|
if i.getTypeName == "TimelineAddEntries":
|
||||||
if instrType == "TimelineAddEntries":
|
|
||||||
for e in i{"entries"}:
|
for e in i{"entries"}:
|
||||||
let entryId = e{"entryId"}.getStr
|
let entryId = e.getEntryId
|
||||||
if entryId.startsWith("tweet"):
|
if entryId.startsWith("tweet"):
|
||||||
with tweetResult, e{"content", contentKey, resultKey, "result"}:
|
let tweetResult = getTweetResult(e)
|
||||||
let tweet = parseGraphTweet(tweetResult, not v2)
|
if tweetResult.notNull:
|
||||||
|
let tweet = parseGraphTweet(tweetResult)
|
||||||
|
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
tweet.id = parseBiggestInt(entryId.getId())
|
tweet.id = entryId.getId
|
||||||
|
|
||||||
if $tweet.id == tweetId:
|
if $tweet.id == tweetId:
|
||||||
result.tweet = tweet
|
result.tweet = tweet
|
||||||
@@ -409,67 +473,65 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
|||||||
elif thread.content.len > 0:
|
elif thread.content.len > 0:
|
||||||
result.replies.content.add thread
|
result.replies.content.add thread
|
||||||
elif entryId.startsWith("tombstone"):
|
elif entryId.startsWith("tombstone"):
|
||||||
let id = entryId.getId()
|
let
|
||||||
let tweet = Tweet(
|
content = select(e{"content", "content"}, e{"content", "itemContent"})
|
||||||
id: parseBiggestInt(id),
|
tweet = Tweet(
|
||||||
|
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
|
||||||
@@ -480,8 +542,7 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
|
|||||||
result.tweets.bottom = e{"content", "value"}.getStr
|
result.tweets.bottom = e{"content", "value"}.getStr
|
||||||
|
|
||||||
if after.len == 0:
|
if after.len == 0:
|
||||||
let instrType = i{"type"}.getStr(i{"__typename"}.getStr)
|
if i.getTypeName == "TimelinePinEntry":
|
||||||
if instrType == "TimelinePinEntry":
|
|
||||||
let tweets = extractTweetsFromEntry(i{"entry"})
|
let tweets = extractTweetsFromEntry(i{"entry"})
|
||||||
if tweets.len > 0:
|
if tweets.len > 0:
|
||||||
var tweet = tweets[0]
|
var tweet = tweets[0]
|
||||||
@@ -491,23 +552,20 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
|
|||||||
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
||||||
result = @[]
|
result = @[]
|
||||||
|
|
||||||
let instructions =
|
let instructions = select(
|
||||||
if js{"data", "user"}.notNull:
|
js{"data", "user", "result", "timeline", "timeline", "instructions"},
|
||||||
? js{"data", "user", "result", "timeline", "timeline", "instructions"}
|
js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||||
else:
|
)
|
||||||
? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
|
||||||
|
|
||||||
if instructions.len == 0:
|
if instructions.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
for i in instructions:
|
for i in instructions:
|
||||||
# TimelineAddToModule instruction is used by MediaTimelineV2
|
|
||||||
if i{"moduleItems"}.notNull:
|
if i{"moduleItems"}.notNull:
|
||||||
for item in i{"moduleItems"}:
|
for item in i{"moduleItems"}:
|
||||||
with tweetResult, item{"item", "itemContent", "tweet_results", "result"}:
|
with tweetResult, item.getTweetResult("item"):
|
||||||
let t = parseGraphTweet(tweetResult, false)
|
let t = parseGraphTweet(tweetResult)
|
||||||
if not t.available:
|
if not t.available:
|
||||||
t.id = parseBiggestInt(item{"entryId"}.getStr.getId())
|
t.id = item.getEntryId.getId
|
||||||
|
|
||||||
let photo = extractGalleryPhoto(t)
|
let photo = extractGalleryPhoto(t)
|
||||||
if photo.url.len > 0:
|
if photo.url.len > 0:
|
||||||
@@ -517,12 +575,11 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
|||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
|
|
||||||
let instrType = i{"type"}.getStr(i{"__typename"}.getStr)
|
if i.getTypeName != "TimelineAddEntries":
|
||||||
if instrType != "TimelineAddEntries":
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for e in i{"entries"}:
|
for e in i{"entries"}:
|
||||||
let entryId = e{"entryId"}.getStr
|
let entryId = e.getEntryId
|
||||||
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
|
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
|
||||||
for t in extractTweetsFromEntry(e):
|
for t in extractTweetsFromEntry(e):
|
||||||
let photo = extractGalleryPhoto(t)
|
let photo = extractGalleryPhoto(t)
|
||||||
@@ -535,21 +592,24 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
|||||||
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
||||||
result = Result[T](beginning: after.len == 0)
|
result = Result[T](beginning: after.len == 0)
|
||||||
|
|
||||||
let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
|
let instructions = select(
|
||||||
|
js{"data", "search", "timeline_response", "timeline", "instructions"},
|
||||||
|
js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
|
||||||
|
)
|
||||||
if instructions.len == 0:
|
if instructions.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
for instruction in instructions:
|
for instruction in instructions:
|
||||||
let typ = instruction{"type"}.getStr
|
let typ = getTypeName(instruction)
|
||||||
if typ == "TimelineAddEntries":
|
if typ == "TimelineAddEntries":
|
||||||
for e in instruction{"entries"}:
|
for e in instruction{"entries"}:
|
||||||
let entryId = e{"entryId"}.getStr
|
let entryId = e.getEntryId
|
||||||
when T is Tweets:
|
when T is Tweets:
|
||||||
if entryId.startsWith("tweet"):
|
if entryId.startsWith("tweet"):
|
||||||
with tweetRes, e{"content", "itemContent", "tweet_results", "result"}:
|
with tweetRes, getTweetResult(e):
|
||||||
let tweet = parseGraphTweet(tweetRes)
|
let tweet = parseGraphTweet(tweetRes)
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
tweet.id = parseBiggestInt(entryId.getId())
|
tweet.id = entryId.getId
|
||||||
result.content.add tweet
|
result.content.add tweet
|
||||||
elif T is User:
|
elif T is User:
|
||||||
if entryId.startsWith("user"):
|
if entryId.startsWith("user"):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import std/[times, macros, htmlgen, options, algorithm, re, logging]
|
import std/[times, macros, htmlgen, options, algorithm, re]
|
||||||
import std/strutils except escape
|
import std/strutils except escape
|
||||||
import std/unicode except strip
|
import std/unicode except strip
|
||||||
from xmltree import escape
|
from xmltree import escape
|
||||||
@@ -36,6 +36,12 @@ template `?`*(js: JsonNode): untyped =
|
|||||||
if j.isNull: return
|
if j.isNull: return
|
||||||
j
|
j
|
||||||
|
|
||||||
|
template select*(a, b: JsonNode): untyped =
|
||||||
|
if a.notNull: a else: b
|
||||||
|
|
||||||
|
template select*(a, b, c: JsonNode): untyped =
|
||||||
|
if a.notNull: a elif b.notNull: b else: c
|
||||||
|
|
||||||
template with*(ident, value, body): untyped =
|
template with*(ident, value, body): untyped =
|
||||||
if true:
|
if true:
|
||||||
let ident {.inject.} = value
|
let ident {.inject.} = value
|
||||||
@@ -44,8 +50,7 @@ template with*(ident, value, body): untyped =
|
|||||||
template with*(ident; value: JsonNode; body): untyped =
|
template with*(ident; value: JsonNode; body): untyped =
|
||||||
if true:
|
if true:
|
||||||
let ident {.inject.} = value
|
let ident {.inject.} = value
|
||||||
# value.notNull causes a compilation error for versions < 1.6.14
|
if value.notNull: body
|
||||||
if notNull(value): body
|
|
||||||
|
|
||||||
template getCursor*(js: JsonNode): string =
|
template getCursor*(js: JsonNode): string =
|
||||||
js{"content", "operation", "cursor", "value"}.getStr
|
js{"content", "operation", "cursor", "value"}.getStr
|
||||||
@@ -54,6 +59,20 @@ template getError*(js: JsonNode): Error =
|
|||||||
if js.kind != JArray or js.len == 0: null
|
if js.kind != JArray or js.len == 0: null
|
||||||
else: Error(js[0]{"code"}.getInt)
|
else: Error(js[0]{"code"}.getInt)
|
||||||
|
|
||||||
|
proc getTweetResult*(js: JsonNode; root="content"): JsonNode =
|
||||||
|
select(
|
||||||
|
js{root, "content", "tweet_results", "result"},
|
||||||
|
js{root, "itemContent", "tweet_results", "result"},
|
||||||
|
js{root, "content", "tweetResult", "result"}
|
||||||
|
)
|
||||||
|
|
||||||
|
template getTypeName*(js: JsonNode): string =
|
||||||
|
js{"__typename"}.getStr(js{"type"}.getStr)
|
||||||
|
|
||||||
|
template getEntryId*(e: JsonNode): string =
|
||||||
|
e{"entryId"}.getStr(e{"entry_id"}.getStr)
|
||||||
|
|
||||||
|
|
||||||
template parseTime(time: string; f: static string; flen: int): DateTime =
|
template parseTime(time: string; f: static string; flen: int): DateTime =
|
||||||
if time.len != flen: return
|
if time.len != flen: return
|
||||||
parse(time, f, utc())
|
parse(time, f, utc())
|
||||||
@@ -64,29 +83,24 @@ proc getDateTime*(js: JsonNode): DateTime =
|
|||||||
proc getTime*(js: JsonNode): DateTime =
|
proc getTime*(js: JsonNode): DateTime =
|
||||||
parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
|
parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
|
||||||
|
|
||||||
proc getId*(id: string): string {.inline.} =
|
proc getTimeFromMs*(js: JsonNode): DateTime =
|
||||||
|
let ms = js.getInt(0)
|
||||||
|
if ms == 0: return
|
||||||
|
let seconds = ms div 1000
|
||||||
|
return fromUnix(seconds).utc()
|
||||||
|
|
||||||
|
proc getId*(id: string): int64 {.inline.} =
|
||||||
let start = id.rfind("-")
|
let start = id.rfind("-")
|
||||||
if start < 0: return id
|
if start < 0:
|
||||||
id[start + 1 ..< id.len]
|
return parseBiggestInt(id)
|
||||||
|
return parseBiggestInt(id[start + 1 ..< id.len])
|
||||||
|
|
||||||
proc getId*(js: JsonNode): int64 {.inline.} =
|
proc getId*(js: JsonNode): int64 {.inline.} =
|
||||||
case js.kind
|
case js.kind
|
||||||
of JString: return parseBiggestInt(js.getStr("0"))
|
of JString: return js.getStr("0").getId
|
||||||
of JInt: return js.getBiggestInt()
|
of JInt: return js.getBiggestInt()
|
||||||
else: return 0
|
else: return 0
|
||||||
|
|
||||||
proc getEntryId*(js: JsonNode): string {.inline.} =
|
|
||||||
let entry = js{"entryId"}.getStr
|
|
||||||
if entry.len == 0: return
|
|
||||||
|
|
||||||
if "tweet" in entry or "sq-I-t" in entry:
|
|
||||||
return entry.getId
|
|
||||||
elif "tombstone" in entry:
|
|
||||||
return js{"content", "item", "content", "tombstone", "tweet", "id"}.getStr
|
|
||||||
else:
|
|
||||||
warn "unknown entry: ", entry
|
|
||||||
return
|
|
||||||
|
|
||||||
template getStrVal*(js: JsonNode; default=""): string =
|
template getStrVal*(js: JsonNode; default=""): string =
|
||||||
js{"string_value"}.getStr(default)
|
js{"string_value"}.getStr(default)
|
||||||
|
|
||||||
@@ -98,6 +112,9 @@ proc getImageStr*(js: JsonNode): string =
|
|||||||
template getImageVal*(js: JsonNode): string =
|
template getImageVal*(js: JsonNode): string =
|
||||||
js{"image_value", "url"}.getImageStr
|
js{"image_value", "url"}.getImageStr
|
||||||
|
|
||||||
|
template getExpandedUrl*(js: JsonNode; fallback=""): string =
|
||||||
|
js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
|
||||||
|
|
||||||
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
|
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
|
||||||
result = js{"website_url"}.getStrVal
|
result = js{"website_url"}.getStrVal
|
||||||
if kind == promoVideoConvo:
|
if kind == promoVideoConvo:
|
||||||
@@ -157,19 +174,13 @@ proc getMp4Resolution*(url: string): int =
|
|||||||
# cannot determine resolution (e.g. m3u8/non-mp4 video)
|
# cannot determine resolution (e.g. m3u8/non-mp4 video)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
proc getVideoViewCount*(js: JsonNode): string =
|
|
||||||
with stats, js{"ext_media_stats"}:
|
|
||||||
return stats{"view_count"}.getStr($stats{"viewCount"}.getInt)
|
|
||||||
|
|
||||||
return $js{"mediaStats", "viewCount"}.getInt(0)
|
|
||||||
|
|
||||||
proc extractSlice(js: JsonNode): Slice[int] =
|
proc extractSlice(js: JsonNode): Slice[int] =
|
||||||
result = js["indices"][0].getInt ..< js["indices"][1].getInt
|
result = js["indices"][0].getInt ..< js["indices"][1].getInt
|
||||||
|
|
||||||
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
||||||
textLen: int; hideTwitter = false) =
|
textLen: int; hideTwitter = false) =
|
||||||
let
|
let
|
||||||
url = js["expanded_url"].getStr
|
url = js.getExpandedUrl
|
||||||
slice = js.extractSlice
|
slice = js.extractSlice
|
||||||
|
|
||||||
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
||||||
@@ -230,7 +241,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
|||||||
ent = ? js{"entities"}
|
ent = ? js{"entities"}
|
||||||
|
|
||||||
with urls, ent{"url", "urls"}:
|
with urls, ent{"url", "urls"}:
|
||||||
user.website = urls[0]{"expanded_url"}.getStr
|
user.website = urls[0].getExpandedUrl
|
||||||
|
|
||||||
var replacements = newSeq[ReplaceSlice]()
|
var replacements = newSeq[ReplaceSlice]()
|
||||||
|
|
||||||
@@ -260,7 +271,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
|||||||
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
|
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
|
||||||
|
|
||||||
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
||||||
get(tweet.card).url = u{"expanded_url"}.getStr
|
get(tweet.card).url = u.getExpandedUrl
|
||||||
|
|
||||||
with media, entities{"media"}:
|
with media, entities{"media"}:
|
||||||
for m in media:
|
for m in media:
|
||||||
|
|||||||
@@ -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("&")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, sequtils, uri
|
import strutils, sequtils
|
||||||
import jester
|
import jester
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types]
|
import ".."/[types]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, strutils, sequtils, uri, options, sugar, logging
|
import asyncdispatch, strutils, sequtils, uri, options, sugar
|
||||||
|
|
||||||
import jester, karax/vdom
|
import jester, karax/vdom
|
||||||
|
|
||||||
@@ -31,8 +31,6 @@ proc createStatusRouter*(cfg: Config) =
|
|||||||
resp $renderReplies(replies, prefs, getPath())
|
resp $renderReplies(replies, prefs, getPath())
|
||||||
|
|
||||||
let conv = await getTweet(id, getCursor())
|
let conv = await getTweet(id, getCursor())
|
||||||
if conv == nil:
|
|
||||||
warn "nil conv"
|
|
||||||
|
|
||||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||||
var error = "Tweet not found"
|
var error = "Tweet not found"
|
||||||
@@ -68,7 +66,7 @@ proc createStatusRouter*(cfg: Config) =
|
|||||||
|
|
||||||
get "/@name/@s/@id/@m/?@i?":
|
get "/@name/@s/@id/@m/?@i?":
|
||||||
cond @"s" in ["status", "statuses"]
|
cond @"s" in ["status", "statuses"]
|
||||||
cond @"m" in ["video", "photo"]
|
cond @"m" in ["video", "photo", "history"]
|
||||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||||
|
|
||||||
get "/@name/statuses/@id/?":
|
get "/@name/statuses/@id/?":
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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?":
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
border-radius: 50%;
|
border-radius: 9999px;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
53
src/sass/homepage.scss
Normal file
53
src/sass/homepage.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ button {
|
|||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="date"],
|
input[type="date"],
|
||||||
|
input[type="number"],
|
||||||
select {
|
select {
|
||||||
@include input-colors;
|
@include input-colors;
|
||||||
background-color: var(--bg_elements);
|
background-color: var(--bg_elements);
|
||||||
@@ -24,7 +25,12 @@ select {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"] {
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +44,17 @@ input[type="date"]::-webkit-inner-spin-button {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
display: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="date"]::-webkit-clear-button {
|
input[type="date"]::-webkit-clear-button {
|
||||||
margin-left: 17px;
|
margin-left: 17px;
|
||||||
filter: grayscale(100%);
|
filter: grayscale(100%);
|
||||||
@@ -176,7 +193,8 @@ input::-webkit-datetime-edit-year-field:focus {
|
|||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
|
input[type="number"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
max-width: 140px;
|
max-width: 140px;
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
|
input[type="number"] {
|
||||||
height: calc(100% - 4px);
|
height: calc(100% - 4px);
|
||||||
width: calc(100% - 8px);
|
width: calc(100% - 8px);
|
||||||
}
|
}
|
||||||
@@ -155,15 +156,14 @@
|
|||||||
|
|
||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
@include search-resize(820px, 5);
|
@include search-resize(820px, 5);
|
||||||
@include search-resize(725px, 4);
|
@include search-resize(715px, 4);
|
||||||
@include search-resize(600px, 6);
|
@include search-resize(700px, 5);
|
||||||
@include search-resize(560px, 5);
|
@include search-resize(485px, 4);
|
||||||
@include search-resize(480px, 4);
|
|
||||||
@include search-resize(410px, 3);
|
@include search-resize(410px, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include search-resize(560px, 5);
|
@include search-resize(700px, 5);
|
||||||
@include search-resize(480px, 4);
|
@include search-resize(485px, 4);
|
||||||
@include search-resize(410px, 3);
|
@include search-resize(410px, 3);
|
||||||
|
|
||||||
@media(max-width: 600px) {
|
@media(max-width: 600px) {
|
||||||
|
|||||||
@@ -26,6 +26,25 @@
|
|||||||
button {
|
button {
|
||||||
float: unset;
|
float: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.timeline-default {
|
||||||
|
background-color: var(--bg_panel);
|
||||||
|
border-bottom: 1px solid var(--bg_elements);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
|
||||||
|
.timeline-default-message {
|
||||||
|
color: var(--fg_color);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-banner img {
|
.timeline-banner img {
|
||||||
|
|||||||
62
src/tid.nim
Normal file
62
src/tid.nim
Normal 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)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import times, sequtils, options, tables, uri
|
import times, sequtils, options, tables
|
||||||
import prefs_impl
|
import prefs_impl
|
||||||
|
|
||||||
genPrefsType()
|
genPrefsType()
|
||||||
@@ -13,19 +13,13 @@ type
|
|||||||
TimelineKind* {.pure.} = enum
|
TimelineKind* {.pure.} = enum
|
||||||
tweets, replies, media
|
tweets, replies, media
|
||||||
|
|
||||||
Api* {.pure.} = enum
|
ApiUrl* = object
|
||||||
tweetDetail
|
endpoint*: string
|
||||||
tweetResult
|
params*: seq[(string, string)]
|
||||||
search
|
|
||||||
list
|
ApiReq* = object
|
||||||
listBySlug
|
oauth*: ApiUrl
|
||||||
listMembers
|
cookie*: ApiUrl
|
||||||
listTweets
|
|
||||||
userRestId
|
|
||||||
userScreenName
|
|
||||||
userTweets
|
|
||||||
userTweetsAndReplies
|
|
||||||
userMedia
|
|
||||||
|
|
||||||
RateLimit* = object
|
RateLimit* = object
|
||||||
limit*: int
|
limit*: int
|
||||||
@@ -42,7 +36,7 @@ type
|
|||||||
pending*: int
|
pending*: int
|
||||||
limited*: bool
|
limited*: bool
|
||||||
limitedAt*: int
|
limitedAt*: int
|
||||||
apis*: Table[Api, RateLimit]
|
apis*: Table[string, RateLimit]
|
||||||
case kind*: SessionKind
|
case kind*: SessionKind
|
||||||
of oauth:
|
of oauth:
|
||||||
oauthToken*: string
|
oauthToken*: string
|
||||||
@@ -51,10 +45,6 @@ type
|
|||||||
authToken*: string
|
authToken*: string
|
||||||
ct0*: string
|
ct0*: string
|
||||||
|
|
||||||
SessionAwareUrl* = object
|
|
||||||
oauthUrl*: Uri
|
|
||||||
cookieUrl*: Uri
|
|
||||||
|
|
||||||
Error* = enum
|
Error* = enum
|
||||||
null = 0
|
null = 0
|
||||||
noUserMatches = 17
|
noUserMatches = 17
|
||||||
@@ -122,7 +112,6 @@ type
|
|||||||
durationMs*: int
|
durationMs*: int
|
||||||
url*: string
|
url*: string
|
||||||
thumb*: string
|
thumb*: string
|
||||||
views*: string
|
|
||||||
available*: bool
|
available*: bool
|
||||||
reason*: string
|
reason*: string
|
||||||
title*: string
|
title*: string
|
||||||
@@ -143,6 +132,7 @@ type
|
|||||||
since*: string
|
since*: string
|
||||||
until*: string
|
until*: string
|
||||||
near*: string
|
near*: string
|
||||||
|
minLikes*: string
|
||||||
sep*: string
|
sep*: string
|
||||||
|
|
||||||
Gif* = object
|
Gif* = object
|
||||||
@@ -203,7 +193,6 @@ type
|
|||||||
replies*: int
|
replies*: int
|
||||||
retweets*: int
|
retweets*: int
|
||||||
likes*: int
|
likes*: int
|
||||||
quotes*: int
|
|
||||||
views*: int
|
views*: int
|
||||||
|
|
||||||
Tweet* = ref object
|
Tweet* = ref object
|
||||||
@@ -290,6 +279,8 @@ type
|
|||||||
logLevel*: string
|
logLevel*: string
|
||||||
proxy*: string
|
proxy*: string
|
||||||
proxyAuth*: string
|
proxyAuth*: string
|
||||||
|
defaultFollowedAccounts*: seq[string]
|
||||||
|
disableTid*: bool
|
||||||
|
|
||||||
rssCacheTime*: int
|
rssCacheTime*: int
|
||||||
listCacheTime*: int
|
listCacheTime*: int
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
|||||||
tdiv(class="nav-item right"):
|
tdiv(class="nav-item right"):
|
||||||
if cfg.enableRss and rss.len > 0:
|
if cfg.enableRss and rss.len > 0:
|
||||||
icon "rss", title="RSS Feed", href=rss
|
icon "rss", title="RSS Feed", href=rss
|
||||||
icon "bird", title="Open in Twitter", href=canonical
|
icon "bird", title="Open in X", href=canonical
|
||||||
a(href="https://buymeacoffee.com/kuu7o"): verbatim lp
|
a(href="https://buymeacoffee.com/kuu7o"): verbatim lp
|
||||||
icon "info", title="About", href="/about"
|
icon "info", title="About", href="/about"
|
||||||
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
||||||
|
|
||||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
||||||
rss=""; canonical=""): VNode =
|
rss=""; alternate=""): VNode =
|
||||||
var theme = prefs.theme.toTheme
|
var theme = prefs.theme.toTheme
|
||||||
if "theme" in req.params:
|
if "theme" in req.params:
|
||||||
theme = req.params["theme"].toTheme
|
theme = req.params["theme"].toTheme
|
||||||
@@ -54,7 +54,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||||
|
|
||||||
buildHtml(head):
|
buildHtml(head):
|
||||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19")
|
link(rel="stylesheet", type="text/css", href="/css/style.css?v=21")
|
||||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
|
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
|
||||||
|
|
||||||
if theme.len > 0:
|
if theme.len > 0:
|
||||||
@@ -68,8 +68,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||||
href=opensearchUrl)
|
href=opensearchUrl)
|
||||||
|
|
||||||
if canonical.len > 0:
|
if alternate.len > 0:
|
||||||
link(rel="canonical", href=canonical)
|
link(rel="alternate", href=alternate, title="View on X")
|
||||||
|
|
||||||
if cfg.enableRss and rss.len > 0:
|
if cfg.enableRss and rss.len > 0:
|
||||||
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||||
@@ -131,16 +131,16 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
|||||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||||
images: seq[string] = @[]; banner=""): string =
|
images: seq[string] = @[]; banner=""): string =
|
||||||
|
|
||||||
let canonical = getTwitterLink(req.path, req.params)
|
let twitterLink = getTwitterLink(req.path, req.params)
|
||||||
|
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||||
rss, canonical)
|
rss, twitterLink)
|
||||||
|
|
||||||
body(class=if prefs.verifiedBadge == "Hide all": "hide-verified-all"
|
body(class=if prefs.verifiedBadge == "Hide all": "hide-verified-all"
|
||||||
elif prefs.verifiedBadge == "Show official only": "hide-verified-blue"
|
elif prefs.verifiedBadge == "Show official only": "hide-verified-blue"
|
||||||
else: ""):
|
else: ""):
|
||||||
renderNavbar(cfg, req, rss, canonical)
|
renderNavbar(cfg, req, rss, twitterLink)
|
||||||
|
|
||||||
tdiv(class="container"):
|
tdiv(class="container"):
|
||||||
body
|
body
|
||||||
|
|||||||
19
src/views/homepage.nim
Normal file
19
src/views/homepage.nim
Normal 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)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
|
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
|
||||||
#end if
|
#end if
|
||||||
#var text = stripHtml(tweet.text)
|
#var text = stripHtml(tweet.text)
|
||||||
##if unicode.runeLen(text) > 32:
|
#if unicode.runeLen(text) > 100:
|
||||||
## text = unicode.runeSubStr(text, 0, 32) & "..."
|
# text = unicode.runeSubStr(text, 0, 64) & "..."
|
||||||
##end if
|
#end if
|
||||||
#result &= xmltree.escape(text)
|
#result &= xmltree.escape(text)
|
||||||
#if result.len > 0: return
|
#if result.len > 0: return
|
||||||
#end if
|
#end if
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc getDescription(desc: string; cfg: Config): string =
|
#proc getDescription(desc: string; cfg: Config): string =
|
||||||
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
|
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
|
||||||
@@ -56,7 +56,10 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||||||
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
||||||
# end for
|
# end for
|
||||||
#elif tweet.video.isSome:
|
#elif tweet.video.isSome:
|
||||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
<a href="${urlPrefix}${tweet.getLink}">
|
||||||
|
<br>Video<br>
|
||||||
|
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
||||||
|
</a>
|
||||||
#elif tweet.gif.isSome:
|
#elif tweet.gif.isSome:
|
||||||
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
||||||
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
||||||
@@ -69,14 +72,17 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||||||
# end if
|
# end if
|
||||||
#end if
|
#end if
|
||||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||||
# let quoteLink = getLink(get(tweet.quote))
|
# let quoteTweet = get(tweet.quote)
|
||||||
|
# let quoteLink = urlPrefix & getLink(quoteTweet)
|
||||||
|
<hr/>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
<p>
|
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
|
||||||
${renderRssTweet(get(tweet.quote), cfg)}
|
<p>
|
||||||
</p>
|
${renderRssTweet(quoteTweet, cfg)}
|
||||||
<footer>
|
</p>
|
||||||
— <cite><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></cite>
|
<footer>
|
||||||
</footer>
|
— <cite><a href="${quoteLink}">${quoteLink}</a>
|
||||||
|
</footer>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
#end if
|
#end if
|
||||||
#end proc
|
#end proc
|
||||||
@@ -101,6 +107,18 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||||||
<title>${getTitle(tweet, retweet)}</title>
|
<title>${getTitle(tweet, retweet)}</title>
|
||||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
||||||
|
# let desc = renderRssTweet(tweet, cfg).strip(chars={'\n'})
|
||||||
|
# if retweet.len > 0:
|
||||||
|
<description><![CDATA[
|
||||||
|
<blockquote>
|
||||||
|
<b>${tweet.user.fullname} (@${tweet.user.username})</b>
|
||||||
|
<p>${desc}</p>
|
||||||
|
<footer>— <cite><a href="${urlPrefix & link}">${urlPrefix & link}</a></footer>
|
||||||
|
</blockquote>
|
||||||
|
]]></description>
|
||||||
|
# else:
|
||||||
|
<description><![CDATA[${desc}]]></description>
|
||||||
|
# end if
|
||||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||||
<guid>${urlPrefix & link}</guid>
|
<guid>${urlPrefix & link}</guid>
|
||||||
<link>${urlPrefix & link}</link>
|
<link>${urlPrefix & link}</link>
|
||||||
|
|||||||
@@ -50,6 +50,34 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
|||||||
|
|
||||||
renderTimelineTweets(results, prefs, path, pinned)
|
renderTimelineTweets(results, prefs, path, pinned)
|
||||||
|
|
||||||
|
proc renderHomepageTabs*(query: Query): VNode =
|
||||||
|
buildHtml(ul(class="tab")):
|
||||||
|
li(class=if query.kind == tweets: "tab-item active" else: "tab-item"):
|
||||||
|
a(href="/?f=tweets"): text "Tweets"
|
||||||
|
|
||||||
|
li(class=if query.kind == replies: "tab-item active" else: "tab-item"):
|
||||||
|
a(href="/?f=replies"): text "Tweets & Replies"
|
||||||
|
|
||||||
|
proc renderHomepageTimeline*(results: Timeline; prefs: Prefs; path: string): VNode =
|
||||||
|
let query = results.query
|
||||||
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
|
renderHomepageTabs(query)
|
||||||
|
|
||||||
|
renderTimelineTweets(results, prefs, path)
|
||||||
|
|
||||||
|
proc renderDefaultTimeline*(results: Timeline; prefs: Prefs; path: string): VNode =
|
||||||
|
let query = results.query
|
||||||
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
|
tdiv(class="timeline-header timeline-default"):
|
||||||
|
h2(class="timeline-default-message"):
|
||||||
|
text "Follow people to populate your "
|
||||||
|
strong: text "own"
|
||||||
|
text " feed"
|
||||||
|
|
||||||
|
renderHomepageTabs(query)
|
||||||
|
|
||||||
|
renderTimelineTweets(results, prefs, path)
|
||||||
|
|
||||||
proc renderUserSearch*(results: Result[User]; prefs: Prefs; path: string): VNode =
|
proc renderUserSearch*(results: Result[User]; prefs: Prefs; path: string): VNode =
|
||||||
buildHtml(tdiv(class="timeline-container")):
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
renderSearchTabs(results.query)
|
renderSearchTabs(results.query)
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
|
|||||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||||
index=i, last=(i == thread.high), showThread=show)
|
index=i, last=(i == thread.high), showThread=show)
|
||||||
|
|
||||||
proc renderUser(user: User; prefs: Prefs; path: string): VNode =
|
proc renderUser*(user: User; prefs: Prefs; path: string): VNode =
|
||||||
let class = if user.sensitive: "timeline-item nsfw" else: "timeline-item"
|
let class = if user.sensitive: "timeline-item nsfw" else: "timeline-item"
|
||||||
buildHtml(tdiv(class=class)):
|
buildHtml(tdiv(class=class, data-username=user.username)):
|
||||||
a(class="tweet-link", href=("/" & user.username))
|
a(class="tweet-link", href=("/" & user.username))
|
||||||
tdiv(class="tweet-body profile-result"):
|
tdiv(class="tweet-body profile-result"):
|
||||||
tdiv(class="tweet-header"):
|
tdiv(class="tweet-header"):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, sequtils, strformat, options, algorithm, uri
|
import strutils, sequtils, strformat, options, algorithm
|
||||||
import karax/[karaxdsl, vdom, vstyles]
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
from jester import Request
|
from jester import Request
|
||||||
|
|
||||||
@@ -178,18 +178,12 @@ func formatStat(stat: int): string =
|
|||||||
if stat > 0: insertSep($stat, ',')
|
if stat > 0: insertSep($stat, ',')
|
||||||
else: ""
|
else: ""
|
||||||
|
|
||||||
proc renderStats(tweet_id: int64; stats: TweetStats; views: string; published: string): VNode =
|
proc renderStats(stats: TweetStats): VNode =
|
||||||
buildHtml(tdiv(class="tweet-stats")):
|
buildHtml(tdiv(class="tweet-stats")):
|
||||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
||||||
a(class="tweet-stat", href=("/search?q=" & encodeUrl(&"-from:quotedreplies url:{tweet_id}") & "&e-nativeretweets=on")): icon "quote", formatStat(stats.quotes)
|
|
||||||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||||||
if stats.views > 0:
|
|
||||||
span(class="tweet-stat"): icon "views", formatStat(stats.views)
|
span(class="tweet-stat"): icon "views", formatStat(stats.views)
|
||||||
if views.len > 0:
|
|
||||||
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
|
||||||
if published.len > 0:
|
|
||||||
span(class="tweet-published"): text published
|
|
||||||
|
|
||||||
proc renderReply(tweet: Tweet): VNode =
|
proc renderReply(tweet: Tweet): VNode =
|
||||||
buildHtml(tdiv(class="replying-to")):
|
buildHtml(tdiv(class="replying-to")):
|
||||||
@@ -281,7 +275,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
divClass.add " nsfw"
|
divClass.add " nsfw"
|
||||||
|
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
|
return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
|
||||||
tdiv(class="unavailable-box"):
|
tdiv(class="unavailable-box"):
|
||||||
if tweet.tombstone.len > 0:
|
if tweet.tombstone.len > 0:
|
||||||
text tweet.tombstone
|
text tweet.tombstone
|
||||||
@@ -303,12 +297,11 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
tweet = tweet.retweet.get
|
tweet = tweet.retweet.get
|
||||||
retweet = fullTweet.user.fullname
|
retweet = fullTweet.user.fullname
|
||||||
|
|
||||||
buildHtml(tdiv(class=("timeline-item " & divClass))):
|
buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
|
||||||
if not mainTweet:
|
if not mainTweet:
|
||||||
a(class="tweet-link", href=getLink(tweet))
|
a(class="tweet-link", href=getLink(tweet))
|
||||||
|
|
||||||
tdiv(class="tweet-body"):
|
tdiv(class="tweet-body"):
|
||||||
var views = ""
|
|
||||||
renderHeader(tweet, retweet, pinned, prefs)
|
renderHeader(tweet, retweet, pinned, prefs)
|
||||||
|
|
||||||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||||||
@@ -332,10 +325,8 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
renderAlbum(tweet)
|
renderAlbum(tweet)
|
||||||
elif tweet.video.isSome:
|
elif tweet.video.isSome:
|
||||||
renderVideo(tweet.video.get(), prefs, path)
|
renderVideo(tweet.video.get(), prefs, path)
|
||||||
views = tweet.video.get().views
|
|
||||||
elif tweet.gif.isSome:
|
elif tweet.gif.isSome:
|
||||||
renderGif(tweet.gif.get(), prefs)
|
renderGif(tweet.gif.get(), prefs)
|
||||||
views = "GIF"
|
|
||||||
|
|
||||||
if tweet.poll.isSome:
|
if tweet.poll.isSome:
|
||||||
renderPoll(tweet.poll.get())
|
renderPoll(tweet.poll.get())
|
||||||
@@ -349,7 +340,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
renderMediaTags(tweet.mediaTags)
|
renderMediaTags(tweet.mediaTags)
|
||||||
|
|
||||||
if not prefs.hideTweetStats:
|
if not prefs.hideTweetStats:
|
||||||
renderStats(tweet.id, tweet.stats, views, published)
|
renderStats(tweet.stats)
|
||||||
|
|
||||||
if showThread:
|
if showThread:
|
||||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user