mirror of
https://github.com/zedeus/nitter.git
synced 2024-06-10 09:09:21 +00:00
Merge branch 'zedeus:master' into master
This commit is contained in:
commit
7fc439531f
|
@ -5,7 +5,7 @@ function insertBeforeLast(node, elem) {
|
|||
}
|
||||
|
||||
function getLoadMore(doc) {
|
||||
return doc.querySelector('.show-more:not(.timeline-item)');
|
||||
return doc.querySelector(".show-more:not(.timeline-item)");
|
||||
}
|
||||
|
||||
function isDuplicate(item, itemClass) {
|
||||
|
@ -15,18 +15,19 @@ function isDuplicate(item, itemClass) {
|
|||
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
|
||||
}
|
||||
|
||||
window.onload = function() {
|
||||
window.onload = function () {
|
||||
const url = window.location.pathname;
|
||||
const isTweet = url.indexOf("/status/") !== -1;
|
||||
const containerClass = isTweet ? ".replies" : ".timeline";
|
||||
const itemClass = containerClass + ' > div:not(.top-ref)';
|
||||
const itemClass = containerClass + " > div:not(.top-ref)";
|
||||
|
||||
var html = document.querySelector("html");
|
||||
var container = document.querySelector(containerClass);
|
||||
var loading = false;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
function handleScroll(failed) {
|
||||
if (loading) return;
|
||||
|
||||
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
||||
loading = true;
|
||||
var loadMore = getLoadMore(document);
|
||||
|
@ -35,13 +36,15 @@ window.onload = function() {
|
|||
loadMore.children[0].text = "Loading...";
|
||||
|
||||
var url = new URL(loadMore.children[0].href);
|
||||
url.searchParams.append('scroll', 'true');
|
||||
url.searchParams.append("scroll", "true");
|
||||
|
||||
fetch(url.toString()).then(function (response) {
|
||||
if (response.status === 404) throw "error";
|
||||
|
||||
return response.text();
|
||||
}).then(function (html) {
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(html, 'text/html');
|
||||
var doc = parser.parseFromString(html, "text/html");
|
||||
loadMore.remove();
|
||||
|
||||
for (var item of doc.querySelectorAll(itemClass)) {
|
||||
|
@ -57,10 +60,18 @@ window.onload = function() {
|
|||
if (isTweet) container.appendChild(newLoadMore);
|
||||
else insertBeforeLast(container, newLoadMore);
|
||||
}).catch(function (err) {
|
||||
console.warn('Something went wrong.', err);
|
||||
loading = true;
|
||||
console.warn("Something went wrong.", err);
|
||||
if (failed > 3) {
|
||||
loadMore.children[0].text = "Error";
|
||||
return;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
handleScroll((failed || 0) + 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", () => handleScroll());
|
||||
};
|
||||
// @license-end
|
||||
|
|
51
src/api.nim
51
src/api.nim
|
@ -7,20 +7,20 @@ import experimental/parser as newParser
|
|||
proc getGraphUser*(username: string): Future[User] {.async.} =
|
||||
if username.len == 0: return
|
||||
let
|
||||
variables = %*{"screen_name": username}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
variables = """{"screen_name": "$1"}""" % username
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetchRaw(graphUser ? params, Api.userScreenName)
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||
if id.len == 0 or id.any(c => not c.isDigit): return
|
||||
let
|
||||
variables = %*{"userId": id}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
variables = """{"rest_id": "$1"}""" % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetchRaw(graphUserById ? params, Api.userRestId)
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timeline] {.async.} =
|
||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
|
@ -33,6 +33,13 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timel
|
|||
js = await fetch(url ? params, apiId)
|
||||
result = parseGraphTimeline(js, "user", after)
|
||||
|
||||
# proc getTimeline*(id: string; after=""; replies=false): Future[Profile] {.async.} =
|
||||
# if id.len == 0: return
|
||||
# let
|
||||
# ps = genParams({"userId": id, "include_tweet_replies": $replies}, after)
|
||||
# url = oldUserTweets / (id & ".json") ? ps
|
||||
# result = parseTimeline(await fetch(url, Api.timeline), after)
|
||||
|
||||
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
|
@ -40,7 +47,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
|||
variables = listTweetsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphListTweets ? params, Api.listTweets)
|
||||
result = parseGraphTimeline(js, "list", after)
|
||||
result = parseGraphTimeline(js, "list", after).tweets
|
||||
|
||||
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
||||
let
|
||||
|
@ -50,8 +57,8 @@ proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
|||
|
||||
proc getGraphList*(id: string): Future[List] {.async.} =
|
||||
let
|
||||
variables = %*{"listId": id}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
variables = """{"listId": "$1"}""" % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
result = parseGraphList(await fetch(graphListById ? params, Api.list))
|
||||
|
||||
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
||||
|
@ -72,7 +79,7 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
|||
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
variables = tweetResultVariables % id
|
||||
variables = """{"rest_id": "$1"}""" % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweetResult ? params, Api.tweetResult)
|
||||
result = parseGraphTweetResult(js)
|
||||
|
@ -95,10 +102,10 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
|
|||
if after.len > 0:
|
||||
result.replies = await getReplies(id, after)
|
||||
|
||||
proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} =
|
||||
proc getGraphSearch*(query: Query; after=""): Future[Profile] {.async.} =
|
||||
let q = genQueryParam(query)
|
||||
if q.len == 0 or q == emptyQuery:
|
||||
return Result[Tweet](query: query, beginning: true)
|
||||
return Profile(tweets: Timeline(query: query, beginning: true))
|
||||
|
||||
var
|
||||
variables = %*{
|
||||
|
@ -112,7 +119,25 @@ proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} =
|
|||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphSearch(await fetch(url, Api.search), after)
|
||||
result = Profile(tweets: parseGraphSearch(await fetch(url, Api.search), after))
|
||||
result.tweets.query = query
|
||||
|
||||
proc getTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
||||
var q = genQueryParam(query)
|
||||
|
||||
if q.len == 0 or q == emptyQuery:
|
||||
return Timeline(query: query, beginning: true)
|
||||
|
||||
if after.len > 0:
|
||||
q &= " max_id:" & after
|
||||
|
||||
let url = tweetSearch ? genParams({
|
||||
"q": q ,
|
||||
"modules": "status",
|
||||
"result_type": "recent",
|
||||
})
|
||||
|
||||
result = parseTweetSearch(await fetch(url, Api.search), after)
|
||||
result.query = query
|
||||
|
||||
proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} =
|
||||
|
@ -139,7 +164,7 @@ proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
|||
ps = genParams({"screen_name": name, "trim_user": "true"},
|
||||
count="18", ext=false)
|
||||
url = photoRail ? ps
|
||||
result = parsePhotoRail(await fetch(url, Api.timeline))
|
||||
result = parsePhotoRail(await fetch(url, Api.photoRail))
|
||||
|
||||
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
||||
let client = newAsyncHttpClient(maxRedirects=0)
|
||||
|
|
|
@ -16,7 +16,7 @@ proc genParams*(pars: openArray[(string, string)] = @[]; cursor="";
|
|||
for p in pars:
|
||||
result &= p
|
||||
if ext:
|
||||
result &= ("ext", "mediaStats")
|
||||
result &= ("ext", "mediaStats,isBlueVerified,isVerified,blue,blueVerified")
|
||||
result &= ("include_ext_alt_text", "1")
|
||||
result &= ("include_ext_media_availability", "1")
|
||||
if count.len > 0:
|
||||
|
|
117
src/consts.nim
117
src/consts.nim
|
@ -2,57 +2,64 @@
|
|||
import uri, sequtils, strutils
|
||||
|
||||
const
|
||||
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
|
||||
|
||||
api = parseUri("https://api.twitter.com")
|
||||
activate* = $(api / "1.1/guest/activate.json")
|
||||
|
||||
photoRail* = api / "1.1/statuses/media_timeline.json"
|
||||
userSearch* = api / "1.1/users/search.json"
|
||||
tweetSearch* = api / "1.1/search/universal.json"
|
||||
|
||||
# oldUserTweets* = api / "2/timeline/profile"
|
||||
|
||||
graphql = api / "graphql"
|
||||
graphUser* = graphql / "pVrmNaXcxPjisIvKtLDMEA/UserByScreenName"
|
||||
graphUserById* = graphql / "1YAM811Q8Ry4XyPpJclURQ/UserByRestId"
|
||||
graphUserTweets* = graphql / "WzJjibAcDa-oCjCcLOotcg/UserTweets"
|
||||
graphUserTweetsAndReplies* = graphql / "fn9oRltM1N4thkh5CVusPg/UserTweetsAndReplies"
|
||||
graphUserMedia* = graphql / "qQoeS7szGavsi8-ehD2AWg/UserMedia"
|
||||
graphTweet* = graphql / "miKSMGb2R1SewIJv2-ablQ/TweetDetail"
|
||||
graphTweetResult* = graphql / "0kc0a_7TTr3dvweZlMslsQ/TweetResultByRestId"
|
||||
graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
|
||||
graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
|
||||
graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserMedia* = graphql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2"
|
||||
graphTweet* = graphql / "83h5UyHZ9wEKBVzALX8R_g/ConversationTimelineV2"
|
||||
graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
|
||||
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
|
||||
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
|
||||
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
|
||||
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
|
||||
graphListTweets* = graphql / "jZntL0oVJSdjhmPcdbw_eA/ListLatestTweetsTimeline"
|
||||
graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
|
||||
|
||||
timelineParams* = {
|
||||
"include_profile_interstitial_type": "0",
|
||||
"include_blocking": "0",
|
||||
"include_blocked_by": "0",
|
||||
"include_followed_by": "0",
|
||||
"include_want_retweets": "0",
|
||||
"include_mute_edge": "0",
|
||||
"include_can_dm": "0",
|
||||
"include_can_media_tag": "1",
|
||||
"include_ext_is_blue_verified": "1",
|
||||
"skip_status": "1",
|
||||
"cards_platform": "Web-12",
|
||||
"include_cards": "1",
|
||||
"include_composer_source": "0",
|
||||
"include_reply_count": "1",
|
||||
"cards_platform": "Web-13",
|
||||
"tweet_mode": "extended",
|
||||
"include_entities": "1",
|
||||
"include_user_entities": "1",
|
||||
"include_ext_media_color": "0",
|
||||
"ui_lang": "en-US",
|
||||
"send_error_codes": "1",
|
||||
"simple_quoted_tweet": "1",
|
||||
"include_quote_count": "1"
|
||||
"skip_status": "1",
|
||||
"include_blocked_by": "0",
|
||||
"include_blocking": "0",
|
||||
"include_can_dm": "0",
|
||||
"include_can_media_tag": "1",
|
||||
"include_cards": "1",
|
||||
"include_composer_source": "0",
|
||||
"include_entities": "1",
|
||||
"include_ext_is_blue_verified": "1",
|
||||
"include_ext_media_color": "0",
|
||||
"include_followed_by": "0",
|
||||
"include_mute_edge": "0",
|
||||
"include_profile_interstitial_type": "0",
|
||||
"include_quote_count": "1",
|
||||
"include_reply_count": "1",
|
||||
"include_user_entities": "1",
|
||||
"include_want_retweets": "0",
|
||||
}.toSeq
|
||||
|
||||
gqlFeatures* = """{
|
||||
"android_graphql_skip_api_media_color_palette": false,
|
||||
"blue_business_profile_image_shape_enabled": false,
|
||||
"creator_subscriptions_subscription_count_enabled": false,
|
||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||
"freedom_of_speech_not_reach_fetch_enabled": false,
|
||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
|
||||
"hidden_profile_likes_enabled": false,
|
||||
"highlights_tweets_tab_ui_enabled": false,
|
||||
"interactive_text_enabled": false,
|
||||
"longform_notetweets_consumption_enabled": true,
|
||||
|
@ -64,15 +71,25 @@ const
|
|||
"responsive_web_graphql_exclude_directive_enabled": true,
|
||||
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
||||
"responsive_web_graphql_timeline_navigation_enabled": false,
|
||||
"responsive_web_media_download_video_enabled": false,
|
||||
"responsive_web_text_conversations_enabled": false,
|
||||
"responsive_web_twitter_article_tweet_consumption_enabled": false,
|
||||
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
||||
"rweb_lists_timeline_redesign_enabled": true,
|
||||
"spaces_2022_h2_clipping": true,
|
||||
"spaces_2022_h2_spaces_communities": true,
|
||||
"standardized_nudges_misinfo": false,
|
||||
"subscriptions_verification_info_enabled": true,
|
||||
"subscriptions_verification_info_reason_enabled": true,
|
||||
"subscriptions_verification_info_verified_since_enabled": true,
|
||||
"super_follow_badge_privacy_enabled": false,
|
||||
"super_follow_exclusive_tweet_notifications_enabled": false,
|
||||
"super_follow_tweet_api_enabled": false,
|
||||
"super_follow_user_api_enabled": false,
|
||||
"tweet_awards_web_tipping_enabled": false,
|
||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
|
||||
"tweetypie_unmention_optimization_enabled": false,
|
||||
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
|
||||
"verified_phone_label_enabled": false,
|
||||
"vibe_api_enabled": false,
|
||||
"view_counts_everywhere_api_enabled": false
|
||||
|
@ -81,41 +98,27 @@ const
|
|||
tweetVariables* = """{
|
||||
"focalTweetId": "$1",
|
||||
$2
|
||||
"withBirdwatchNotes": false,
|
||||
"includePromotedContent": false,
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false
|
||||
"includeHasBirdwatchNotes": false
|
||||
}"""
|
||||
|
||||
tweetResultVariables* = """{
|
||||
"tweetId": "$1",
|
||||
"includePromotedContent": false,
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false,
|
||||
"withCommunity": false
|
||||
}"""
|
||||
# oldUserTweetsVariables* = """{
|
||||
# "userId": "$1", $2
|
||||
# "count": 20,
|
||||
# "includePromotedContent": false,
|
||||
# "withDownvotePerspective": false,
|
||||
# "withReactionsMetadata": false,
|
||||
# "withReactionsPerspective": false,
|
||||
# "withVoice": false,
|
||||
# "withV2Timeline": true
|
||||
# }
|
||||
# """
|
||||
|
||||
userTweetsVariables* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false,
|
||||
"withV2Timeline": true
|
||||
"rest_id": "$1", $2
|
||||
"count": 20
|
||||
}"""
|
||||
|
||||
listTweetsVariables* = """{
|
||||
"listId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false,
|
||||
"withVoice": false
|
||||
"rest_id": "$1", $2
|
||||
"count": 20
|
||||
}"""
|
||||
|
|
|
@ -9,12 +9,12 @@ proc parseGraphUser*(json: string): User =
|
|||
|
||||
let raw = json.fromJson(GraphUser)
|
||||
|
||||
if raw.data.user.result.reason.get("") == "Suspended":
|
||||
if raw.data.userResult.result.unavailableReason.get("") == "Suspended":
|
||||
return User(suspended: true)
|
||||
|
||||
result = toUser raw.data.user.result.legacy
|
||||
result.id = raw.data.user.result.restId
|
||||
result.verified = result.verified or raw.data.user.result.isBlueVerified
|
||||
result = toUser raw.data.userResult.result.legacy
|
||||
result.id = raw.data.userResult.result.restId
|
||||
result.verified = result.verified or raw.data.userResult.result.isBlueVerified
|
||||
|
||||
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||
result = Result[User](
|
||||
|
|
|
@ -3,7 +3,7 @@ import user
|
|||
|
||||
type
|
||||
GraphUser* = object
|
||||
data*: tuple[user: UserData]
|
||||
data*: tuple[userResult: UserData]
|
||||
|
||||
UserData* = object
|
||||
result*: UserResult
|
||||
|
@ -12,4 +12,4 @@ type
|
|||
legacy*: RawUser
|
||||
restId*: string
|
||||
isBlueVerified*: bool
|
||||
reason*: Option[string]
|
||||
unavailableReason*: Option[string]
|
||||
|
|
|
@ -87,7 +87,7 @@ routes:
|
|||
|
||||
error BadClientError:
|
||||
echo error.exc.name, ": ", error.exc.msg
|
||||
resp Http500, showError("Network error occured, please try again.", cfg)
|
||||
resp Http500, showError("Network error occurred, please try again.", cfg)
|
||||
|
||||
error RateLimitError:
|
||||
const link = a("another instance", href = instancesUrl)
|
||||
|
|
262
src/parser.nim
262
src/parser.nim
|
@ -1,5 +1,5 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, options, tables, times, math
|
||||
import strutils, options, times, math
|
||||
import packedjson, packedjson/deserialiser
|
||||
import types, parserutils, utils
|
||||
import experimental/parser/unifiedcard
|
||||
|
@ -29,7 +29,7 @@ proc parseUser(js: JsonNode; id=""): User =
|
|||
result.expandUserEntities(js)
|
||||
|
||||
proc parseGraphUser(js: JsonNode): User =
|
||||
let user = ? js{"user_results", "result"}
|
||||
let user = ? js{"user_result", "result"}
|
||||
result = parseUser(user{"legacy"})
|
||||
|
||||
if "is_blue_verified" in user:
|
||||
|
@ -82,12 +82,16 @@ proc parseVideo(js: JsonNode): Video =
|
|||
result = Video(
|
||||
thumb: js{"media_url_https"}.getImageStr,
|
||||
views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt),
|
||||
available: js{"ext_media_availability", "status"}.getStr.toLowerAscii == "available",
|
||||
available: true,
|
||||
title: js{"ext_alt_text"}.getStr,
|
||||
durationMs: js{"video_info", "duration_millis"}.getInt
|
||||
# playbackType: mp4
|
||||
)
|
||||
|
||||
with status, js{"ext_media_availability", "status"}:
|
||||
if status.getStr.len > 0 and status.getStr.toLowerAscii != "available":
|
||||
result.available = false
|
||||
|
||||
with title, js{"additional_media_info", "title"}:
|
||||
result.title = title.getStr
|
||||
|
||||
|
@ -219,7 +223,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
if result.hasThread and result.threadId == 0:
|
||||
result.threadId = js{"self_thread", "id_str"}.getId
|
||||
|
||||
if js{"is_quote_status"}.getBool:
|
||||
if "retweeted_status" in js:
|
||||
result.retweet = some Tweet()
|
||||
elif js{"is_quote_status"}.getBool:
|
||||
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
|
||||
|
||||
# legacy
|
||||
|
@ -262,6 +268,11 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
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"}:
|
||||
let withheldInCountries: seq[string] =
|
||||
if jsWithheld.kind != JArray: @[]
|
||||
|
@ -276,100 +287,129 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
result.text.removeSuffix(" Learn more.")
|
||||
result.available = false
|
||||
|
||||
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
|
||||
let intId = if id.len > 0: parseBiggestInt(id) else: 0
|
||||
result = global.tweets.getOrDefault(id, Tweet(id: intId))
|
||||
proc parseLegacyTweet(js: JsonNode): Tweet =
|
||||
result = parseTweet(js, js{"card"})
|
||||
if not result.isNil and result.available:
|
||||
result.user = parseUser(js{"user"})
|
||||
|
||||
if result.quote.isSome:
|
||||
let quote = get(result.quote).id
|
||||
if $quote in global.tweets:
|
||||
result.quote = some global.tweets[$quote]
|
||||
else:
|
||||
result.quote = some Tweet()
|
||||
if result.quote.isSome:
|
||||
result.quote = some parseLegacyTweet(js{"quoted_status"})
|
||||
|
||||
if result.retweet.isSome:
|
||||
let rt = get(result.retweet).id
|
||||
if $rt in global.tweets:
|
||||
result.retweet = some finalizeTweet(global, $rt)
|
||||
else:
|
||||
result.retweet = some Tweet()
|
||||
proc parseTweetSearch*(js: JsonNode; after=""): Timeline =
|
||||
result.beginning = after.len == 0
|
||||
|
||||
proc parsePin(js: JsonNode; global: GlobalObjects): Tweet =
|
||||
let pin = js{"pinEntry", "entry", "entryId"}.getStr
|
||||
if pin.len == 0: return
|
||||
|
||||
let id = pin.getId
|
||||
if id notin global.tweets: return
|
||||
|
||||
global.tweets[id].pinned = true
|
||||
return finalizeTweet(global, id)
|
||||
|
||||
proc parseGlobalObjects(js: JsonNode): GlobalObjects =
|
||||
result = GlobalObjects()
|
||||
let
|
||||
tweets = ? js{"globalObjects", "tweets"}
|
||||
users = ? js{"globalObjects", "users"}
|
||||
|
||||
for k, v in users:
|
||||
result.users[k] = parseUser(v, k)
|
||||
|
||||
for k, v in tweets:
|
||||
var tweet = parseTweet(v, v{"card"})
|
||||
if tweet.user.id in result.users:
|
||||
tweet.user = result.users[tweet.user.id]
|
||||
result.tweets[k] = tweet
|
||||
|
||||
proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNode) =
|
||||
if js.kind != JArray or js.len == 0:
|
||||
if js.kind == JNull or "modules" notin js or js{"modules"}.len == 0:
|
||||
return
|
||||
|
||||
for i in js:
|
||||
when T is Tweet:
|
||||
if res.beginning and i{"pinEntry"}.notNull:
|
||||
with pin, parsePin(i, global):
|
||||
res.content.add pin
|
||||
for item in js{"modules"}:
|
||||
with tweet, item{"status", "data"}:
|
||||
let parsed = parseLegacyTweet(tweet)
|
||||
|
||||
with r, i{"replaceEntry", "entry"}:
|
||||
if "top" in r{"entryId"}.getStr:
|
||||
res.top = r.getCursor
|
||||
elif "bottom" in r{"entryId"}.getStr:
|
||||
res.bottom = r.getCursor
|
||||
if parsed.retweet.isSome:
|
||||
parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"})
|
||||
|
||||
proc parseTimeline*(js: JsonNode; after=""): Timeline =
|
||||
result = Timeline(beginning: after.len == 0)
|
||||
let global = parseGlobalObjects(? js)
|
||||
result.content.add @[parsed]
|
||||
|
||||
let instructions = ? js{"timeline", "instructions"}
|
||||
if instructions.len == 0: return
|
||||
if result.content.len > 0:
|
||||
result.bottom = $(result.content[^1][0].id - 1)
|
||||
|
||||
result.parseInstructions(global, instructions)
|
||||
# proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
|
||||
# let intId = if id.len > 0: parseBiggestInt(id) else: 0
|
||||
# result = global.tweets.getOrDefault(id, Tweet(id: intId))
|
||||
|
||||
var entries: JsonNode
|
||||
for i in instructions:
|
||||
if "addEntries" in i:
|
||||
entries = i{"addEntries", "entries"}
|
||||
# if result.quote.isSome:
|
||||
# let quote = get(result.quote).id
|
||||
# if $quote in global.tweets:
|
||||
# result.quote = some global.tweets[$quote]
|
||||
# else:
|
||||
# result.quote = some Tweet()
|
||||
|
||||
for e in ? entries:
|
||||
let entry = e{"entryId"}.getStr
|
||||
if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry:
|
||||
let tweet = finalizeTweet(global, e.getEntryId)
|
||||
if not tweet.available: continue
|
||||
result.content.add tweet
|
||||
elif "cursor-top" in entry:
|
||||
result.top = e.getCursor
|
||||
elif "cursor-bottom" in entry:
|
||||
result.bottom = e.getCursor
|
||||
elif entry.startsWith("sq-cursor"):
|
||||
with cursor, e{"content", "operation", "cursor"}:
|
||||
if cursor{"cursorType"}.getStr == "Bottom":
|
||||
result.bottom = cursor{"value"}.getStr
|
||||
else:
|
||||
result.top = cursor{"value"}.getStr
|
||||
# if result.retweet.isSome:
|
||||
# let rt = get(result.retweet).id
|
||||
# if $rt in global.tweets:
|
||||
# result.retweet = some finalizeTweet(global, $rt)
|
||||
# else:
|
||||
# result.retweet = some Tweet()
|
||||
|
||||
# proc parsePin(js: JsonNode; global: GlobalObjects): Tweet =
|
||||
# let pin = js{"pinEntry", "entry", "entryId"}.getStr
|
||||
# if pin.len == 0: return
|
||||
|
||||
# let id = pin.getId
|
||||
# if id notin global.tweets: return
|
||||
|
||||
# global.tweets[id].pinned = true
|
||||
# return finalizeTweet(global, id)
|
||||
|
||||
# proc parseGlobalObjects(js: JsonNode): GlobalObjects =
|
||||
# result = GlobalObjects()
|
||||
# let
|
||||
# tweets = ? js{"globalObjects", "tweets"}
|
||||
# users = ? js{"globalObjects", "users"}
|
||||
|
||||
# for k, v in users:
|
||||
# result.users[k] = parseUser(v, k)
|
||||
|
||||
# for k, v in tweets:
|
||||
# var tweet = parseTweet(v, v{"card"})
|
||||
# if tweet.user.id in result.users:
|
||||
# tweet.user = result.users[tweet.user.id]
|
||||
# result.tweets[k] = tweet
|
||||
|
||||
# proc parseInstructions(res: var Profile; global: GlobalObjects; js: JsonNode) =
|
||||
# if js.kind != JArray or js.len == 0:
|
||||
# return
|
||||
|
||||
# for i in js:
|
||||
# if res.tweets.beginning and i{"pinEntry"}.notNull:
|
||||
# with pin, parsePin(i, global):
|
||||
# res.pinned = some pin
|
||||
|
||||
# with r, i{"replaceEntry", "entry"}:
|
||||
# if "top" in r{"entryId"}.getStr:
|
||||
# res.tweets.top = r.getCursor
|
||||
# elif "bottom" in r{"entryId"}.getStr:
|
||||
# res.tweets.bottom = r.getCursor
|
||||
|
||||
# proc parseTimeline*(js: JsonNode; after=""): Profile =
|
||||
# result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||
# let global = parseGlobalObjects(? js)
|
||||
|
||||
# let instructions = ? js{"timeline", "instructions"}
|
||||
# if instructions.len == 0: return
|
||||
|
||||
# result.parseInstructions(global, instructions)
|
||||
|
||||
# var entries: JsonNode
|
||||
# for i in instructions:
|
||||
# if "addEntries" in i:
|
||||
# entries = i{"addEntries", "entries"}
|
||||
|
||||
# for e in ? entries:
|
||||
# let entry = e{"entryId"}.getStr
|
||||
# if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry:
|
||||
# let tweet = finalizeTweet(global, e.getEntryId)
|
||||
# if not tweet.available: continue
|
||||
# result.tweets.content.add tweet
|
||||
# elif "cursor-top" in entry:
|
||||
# result.tweets.top = e.getCursor
|
||||
# elif "cursor-bottom" in entry:
|
||||
# result.tweets.bottom = e.getCursor
|
||||
# elif entry.startsWith("sq-cursor"):
|
||||
# with cursor, e{"content", "operation", "cursor"}:
|
||||
# if cursor{"cursorType"}.getStr == "Bottom":
|
||||
# result.tweets.bottom = cursor{"value"}.getStr
|
||||
# else:
|
||||
# result.tweets.top = cursor{"value"}.getStr
|
||||
|
||||
proc parsePhotoRail*(js: JsonNode): PhotoRail =
|
||||
with error, js{"error"}:
|
||||
if error.getStr == "Not authorized.":
|
||||
return
|
||||
|
||||
for tweet in js:
|
||||
let
|
||||
t = parseTweet(tweet, js{"card"})
|
||||
t = parseTweet(tweet, js{"tweet_card"})
|
||||
url = if t.photos.len > 0: t.photos[0]
|
||||
elif t.video.isSome: get(t.video).thumb
|
||||
elif t.gif.isSome: get(t.gif).thumb
|
||||
|
@ -387,13 +427,17 @@ proc parseGraphTweet(js: JsonNode): Tweet =
|
|||
of "TweetUnavailable":
|
||||
return Tweet()
|
||||
of "TweetTombstone":
|
||||
return Tweet(text: js{"tombstone", "text"}.getTombstone)
|
||||
with text, js{"tombstone", "richText"}:
|
||||
return Tweet(text: text.getTombstone)
|
||||
with text, js{"tombstone", "text"}:
|
||||
return Tweet(text: text.getTombstone)
|
||||
return Tweet()
|
||||
of "TweetPreviewDisplay":
|
||||
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
||||
of "TweetWithVisibilityResults":
|
||||
return parseGraphTweet(js{"tweet"})
|
||||
|
||||
var jsCard = copy(js{"card", "legacy"})
|
||||
var jsCard = copy(js{"tweet_card", "legacy"})
|
||||
if jsCard.kind != JNull:
|
||||
var values = newJObject()
|
||||
for val in jsCard["binding_values"]:
|
||||
|
@ -401,6 +445,7 @@ proc parseGraphTweet(js: JsonNode): Tweet =
|
|||
jsCard["binding_values"] = values
|
||||
|
||||
result = parseTweet(js{"legacy"}, jsCard)
|
||||
result.id = js{"rest_id"}.getId
|
||||
result.user = parseGraphUser(js{"core"})
|
||||
|
||||
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
||||
|
@ -414,32 +459,31 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
|||
for t in js{"content", "items"}:
|
||||
let entryId = t{"entryId"}.getStr
|
||||
if "cursor-showmore" in entryId:
|
||||
let cursor = t{"item", "itemContent", "value"}
|
||||
let cursor = t{"item", "content", "value"}
|
||||
result.thread.cursor = cursor.getStr
|
||||
result.thread.hasMore = true
|
||||
elif "tweet" in entryId:
|
||||
let tweet = parseGraphTweet(t{"item", "itemContent", "tweet_results", "result"})
|
||||
let tweet = parseGraphTweet(t{"item", "content", "tweetResult", "result"})
|
||||
result.thread.content.add tweet
|
||||
|
||||
if t{"item", "itemContent", "tweetDisplayType"}.getStr == "SelfThread":
|
||||
if t{"item", "content", "tweetDisplayType"}.getStr == "SelfThread":
|
||||
result.self = true
|
||||
|
||||
proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
||||
with tweet, js{"data", "tweetResult", "result"}:
|
||||
with tweet, js{"data", "tweet_result", "result"}:
|
||||
result = parseGraphTweet(tweet)
|
||||
|
||||
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
||||
result = Conversation(replies: Result[Chain](beginning: true))
|
||||
|
||||
let instructions = ? js{"data", "threaded_conversation_with_injections", "instructions"}
|
||||
let instructions = ? js{"data", "timeline_response", "instructions"}
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for e in instructions[0]{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
# echo entryId
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
with tweetResult, e{"content", "content", "tweetResult", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
|
||||
if not tweet.available:
|
||||
|
@ -454,7 +498,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
|||
let tweet = Tweet(
|
||||
id: parseBiggestInt(id),
|
||||
available: false,
|
||||
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
|
||||
text: e{"content", "content", "tombstoneInfo", "richText"}.getTombstone
|
||||
)
|
||||
|
||||
if id == tweetId:
|
||||
|
@ -468,34 +512,42 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
|||
else:
|
||||
result.replies.content.add thread
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.replies.bottom = e{"content", "itemContent", "value"}.getStr
|
||||
result.replies.bottom = e{"content", "content", "value"}.getStr
|
||||
|
||||
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline =
|
||||
result = Timeline(beginning: after.len == 0)
|
||||
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
||||
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||
|
||||
let instructions =
|
||||
if root == "list": ? js{"data", "list", "tweets_timeline", "timeline", "instructions"}
|
||||
else: ? js{"data", "user", "result", "timeline_v2", "timeline", "instructions"}
|
||||
if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"}
|
||||
else: ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i{"type"}.getStr == "TimelineAddEntries":
|
||||
if i{"__typename"}.getStr == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
with tweetResult, e{"content", "content", "tweetResult", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
result.content.add tweet
|
||||
elif entryId.startsWith("profile-conversation") or entryId.startsWith("homeConversation"):
|
||||
result.tweets.content.add tweet
|
||||
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
|
||||
let (thread, self) = parseGraphThread(e)
|
||||
for tweet in thread.content:
|
||||
result.content.add tweet
|
||||
result.tweets.content.add thread.content
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.bottom = e{"content", "value"}.getStr
|
||||
result.tweets.bottom = e{"content", "value"}.getStr
|
||||
if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry":
|
||||
with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
tweet.pinned = true
|
||||
if not tweet.available and tweet.tombstone.len == 0:
|
||||
let entryId = i{"entry", "entryId"}.getEntryId
|
||||
if entryId.len > 0:
|
||||
tweet.id = parseBiggestInt(entryId)
|
||||
result.pinned = some tweet
|
||||
|
||||
proc parseGraphSearch*(js: JsonNode; after=""): Timeline =
|
||||
result = Timeline(beginning: after.len == 0)
|
||||
|
|
|
@ -10,22 +10,22 @@ export api, embed, vdom, tweet, general, router_utils
|
|||
proc createEmbedRouter*(cfg: Config) =
|
||||
router embed:
|
||||
get "/i/videos/tweet/@id":
|
||||
let convo = await getTweet(@"id")
|
||||
if convo == nil or convo.tweet == nil or convo.tweet.video.isNone:
|
||||
let tweet = await getGraphTweetResult(@"id")
|
||||
if tweet == nil or tweet.video.isNone:
|
||||
resp Http404
|
||||
|
||||
resp renderVideoEmbed(convo.tweet, cfg, request)
|
||||
resp renderVideoEmbed(tweet, cfg, request)
|
||||
|
||||
get "/@user/status/@id/embed":
|
||||
let
|
||||
convo = await getTweet(@"id")
|
||||
tweet = await getGraphTweetResult(@"id")
|
||||
prefs = cookiePrefs()
|
||||
path = getPath()
|
||||
|
||||
if convo == nil or convo.tweet == nil:
|
||||
if tweet == nil:
|
||||
resp Http404
|
||||
|
||||
resp renderTweetEmbed(convo.tweet, path, prefs, cfg, request)
|
||||
resp renderTweetEmbed(tweet, path, prefs, cfg, request)
|
||||
|
||||
get "/embed/Tweet.html":
|
||||
let id = @"id"
|
||||
|
|
|
@ -27,14 +27,12 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
|
|||
else:
|
||||
var q = query
|
||||
q.fromUser = names
|
||||
profile = Profile(
|
||||
tweets: await getGraphSearch(q, after),
|
||||
# this is kinda dumb
|
||||
user: User(
|
||||
username: name,
|
||||
fullname: names.join(" | "),
|
||||
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
|
||||
)
|
||||
profile.tweets = await getTweetSearch(q, after)
|
||||
# this is kinda dumb
|
||||
profile.user = User(
|
||||
username: name,
|
||||
fullname: names.join(" | "),
|
||||
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
|
||||
)
|
||||
|
||||
if profile.user.suspended:
|
||||
|
@ -78,7 +76,7 @@ proc createRssRouter*(cfg: Config) =
|
|||
if rss.cursor.len > 0:
|
||||
respRss(rss, "Search")
|
||||
|
||||
let tweets = await getGraphSearch(query, cursor)
|
||||
let tweets = await getTweetSearch(query, cursor)
|
||||
rss.cursor = tweets.bottom
|
||||
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
|
||||
|
||||
|
@ -112,7 +110,7 @@ proc createRssRouter*(cfg: Config) =
|
|||
case tab
|
||||
of "with_replies": getReplyQuery(name)
|
||||
of "media": getMediaQuery(name)
|
||||
of "search": initQuery(params(request), name=name)
|
||||
# of "search": initQuery(params(request), name=name)
|
||||
else: Query(fromUser: @[name])
|
||||
|
||||
let searchKey = if tab != "search": ""
|
||||
|
|
|
@ -35,7 +35,7 @@ proc createSearchRouter*(cfg: Config) =
|
|||
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
|
||||
of tweets:
|
||||
let
|
||||
tweets = await getGraphSearch(query, getCursor())
|
||||
tweets = await getTweetSearch(query, getCursor())
|
||||
rss = "/search/rss?" & genQueryUrl(query)
|
||||
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
|
||||
request, cfg, prefs, title, rss=rss)
|
||||
|
|
|
@ -45,45 +45,44 @@ proc fetchProfile*(after: string; query: Query; skipRail=false;
|
|||
after.setLen 0
|
||||
|
||||
let
|
||||
timeline =
|
||||
case query.kind
|
||||
of posts: getGraphUserTweets(userId, TimelineKind.tweets, after)
|
||||
of replies: getGraphUserTweets(userId, TimelineKind.replies, after)
|
||||
of media: getGraphUserTweets(userId, TimelineKind.media, after)
|
||||
else: getGraphSearch(query, after)
|
||||
|
||||
rail =
|
||||
skipIf(skipRail or query.kind == media, @[]):
|
||||
getCachedPhotoRail(name)
|
||||
|
||||
user = await getCachedUser(name)
|
||||
user = getCachedUser(name)
|
||||
|
||||
var pinned: Option[Tweet]
|
||||
if not skipPinned and user.pinnedTweet > 0 and
|
||||
after.len == 0 and query.kind in {posts, replies}:
|
||||
let tweet = await getCachedTweet(user.pinnedTweet)
|
||||
if not tweet.isNil:
|
||||
tweet.pinned = true
|
||||
tweet.user = user
|
||||
pinned = some tweet
|
||||
result =
|
||||
case query.kind
|
||||
# of posts: await getTimeline(userId, after)
|
||||
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
|
||||
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
|
||||
else: Profile(tweets: await getTweetSearch(query, after))
|
||||
|
||||
result = Profile(
|
||||
user: user,
|
||||
pinned: pinned,
|
||||
tweets: await timeline,
|
||||
photoRail: await rail
|
||||
)
|
||||
result.user = await user
|
||||
result.photoRail = await rail
|
||||
|
||||
result.tweets.query = query
|
||||
|
||||
if result.user.protected or result.user.suspended:
|
||||
return
|
||||
|
||||
result.tweets.query = query
|
||||
if query.kind == posts:
|
||||
if result.user.verified:
|
||||
for chain in result.tweets.content:
|
||||
if chain[0].user.id == result.user.id:
|
||||
chain[0].user.verified = true
|
||||
if not skipPinned and result.user.pinnedTweet > 0 and after.len == 0:
|
||||
let tweet = await getCachedTweet(result.user.pinnedTweet)
|
||||
if not tweet.isNil:
|
||||
tweet.pinned = true
|
||||
tweet.user = result.user
|
||||
result.pinned = some tweet
|
||||
|
||||
proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
||||
rss, after: string): Future[string] {.async.} =
|
||||
if query.fromUser.len != 1:
|
||||
let
|
||||
timeline = await getGraphSearch(query, after)
|
||||
timeline = await getTweetSearch(query, after)
|
||||
html = renderTweetSearch(timeline, prefs, getPath())
|
||||
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
|
||||
|
||||
|
@ -138,7 +137,7 @@ proc createTimelineRouter*(cfg: Config) =
|
|||
# used for the infinite scroll feature
|
||||
if @"scroll".len > 0:
|
||||
if query.fromUser.len != 1:
|
||||
var timeline = await getGraphSearch(query, after)
|
||||
var timeline = await getTweetSearch(query, after)
|
||||
if timeline.content.len == 0: resp Http404
|
||||
timeline.beginning = true
|
||||
resp $renderTweetSearch(timeline, prefs, getPath())
|
||||
|
|
|
@ -110,3 +110,29 @@
|
|||
margin-left: 58px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.timeline-item.thread.more-replies-thread {
|
||||
padding: 0 0.75em;
|
||||
|
||||
&::before {
|
||||
top: 40px;
|
||||
margin-bottom: 31px;
|
||||
}
|
||||
|
||||
.more-replies {
|
||||
display: flex;
|
||||
padding-top: unset !important;
|
||||
margin-top: 8px;
|
||||
|
||||
&::before {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
line-height: 0.4em;
|
||||
}
|
||||
|
||||
.more-replies-text {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,11 +41,13 @@ proc getPoolJson*(): JsonNode =
|
|||
let
|
||||
maxReqs =
|
||||
case api
|
||||
of Api.search: 100000
|
||||
of Api.photoRail: 180
|
||||
of Api.timeline: 187
|
||||
of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets,
|
||||
Api.userTweets, Api.userTweetsAndReplies, Api.userMedia,
|
||||
Api.userRestId, Api.userScreenName,
|
||||
Api.tweetDetail, Api.tweetResult, Api.search: 500
|
||||
of Api.userTweets: 300
|
||||
of Api.userTweetsAndReplies, Api.userRestId,
|
||||
Api.userScreenName, Api.tweetDetail, Api.tweetResult: 500
|
||||
of Api.list, Api.listTweets, Api.listMembers, Api.listBySlug, Api.userMedia: 500
|
||||
of Api.userSearch: 900
|
||||
reqs = maxReqs - token.apis[api].remaining
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ type
|
|||
tweetDetail
|
||||
tweetResult
|
||||
timeline
|
||||
photoRail
|
||||
search
|
||||
userSearch
|
||||
list
|
||||
|
@ -205,6 +206,8 @@ type
|
|||
video*: Option[Video]
|
||||
photos*: seq[string]
|
||||
|
||||
Tweets* = seq[Tweet]
|
||||
|
||||
Result*[T] = object
|
||||
content*: seq[T]
|
||||
top*, bottom*: string
|
||||
|
@ -212,7 +215,7 @@ type
|
|||
query*: Query
|
||||
|
||||
Chain* = object
|
||||
content*: seq[Tweet]
|
||||
content*: Tweets
|
||||
hasMore*: bool
|
||||
cursor*: string
|
||||
|
||||
|
@ -222,7 +225,7 @@ type
|
|||
after*: Chain
|
||||
replies*: Result[Chain]
|
||||
|
||||
Timeline* = Result[Tweet]
|
||||
Timeline* = Result[Tweets]
|
||||
|
||||
Profile* = object
|
||||
user*: User
|
||||
|
@ -274,3 +277,6 @@ type
|
|||
|
||||
proc contains*(thread: Chain; tweet: Tweet): bool =
|
||||
thread.content.anyIt(it.id == tweet.id)
|
||||
|
||||
proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
|
||||
timeline.add @[tweet]
|
||||
|
|
|
@ -11,7 +11,7 @@ const doctype = "<!DOCTYPE html>\n"
|
|||
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||
let thumb = get(tweet.video).thumb
|
||||
let vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
let prefs = Prefs(hlsPlayback: true)
|
||||
let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
||||
|
||||
|
|
|
@ -56,24 +56,29 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string =
|
||||
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#var links: seq[string]
|
||||
#for t in tweets:
|
||||
# let retweet = if t.retweet.isSome: t.user.username else: ""
|
||||
# let tweet = if retweet.len > 0: t.retweet.get else: t
|
||||
# let link = getLink(tweet)
|
||||
# if link in links: continue
|
||||
# end if
|
||||
# links.add link
|
||||
<item>
|
||||
<title>${getTitle(tweet, retweet)}</title>
|
||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||
<guid>${urlPrefix & link}</guid>
|
||||
<link>${urlPrefix & link}</link>
|
||||
</item>
|
||||
#for thread in tweets:
|
||||
# for tweet in thread:
|
||||
# if userId.len > 0 and tweet.user.id != userId: continue
|
||||
# end if
|
||||
#
|
||||
# let retweet = if tweet.retweet.isSome: tweet.user.username else: ""
|
||||
# let tweet = if retweet.len > 0: tweet.retweet.get else: tweet
|
||||
# let link = getLink(tweet)
|
||||
# if link in links: continue
|
||||
# end if
|
||||
# links.add link
|
||||
<item>
|
||||
<title>${getTitle(tweet, retweet)}</title>
|
||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||
<guid>${urlPrefix & link}</guid>
|
||||
<link>${urlPrefix & link}</link>
|
||||
</item>
|
||||
# end for
|
||||
#end for
|
||||
#end proc
|
||||
#
|
||||
|
@ -102,13 +107,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||
<height>128</height>
|
||||
</image>
|
||||
#if profile.tweets.content.len > 0:
|
||||
${renderRssTweets(profile.tweets.content, cfg)}
|
||||
${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)}
|
||||
#end if
|
||||
</channel>
|
||||
</rss>
|
||||
#end proc
|
||||
#
|
||||
#proc renderListRss*(tweets: seq[Tweet]; list: List; cfg: Config): string =
|
||||
#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string =
|
||||
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
|
||||
#result = ""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
@ -125,7 +130,7 @@ ${renderRssTweets(tweets, cfg)}
|
|||
</rss>
|
||||
#end proc
|
||||
#
|
||||
#proc renderSearchRss*(tweets: seq[Tweet]; name, param: string; cfg: Config): string =
|
||||
#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string =
|
||||
#let link = &"{getUrlPrefix(cfg)}/search"
|
||||
#let escName = xmltree.escape(name)
|
||||
#result = ""
|
||||
|
|
|
@ -88,7 +88,7 @@ proc renderSearchPanel*(query: Query): VNode =
|
|||
span(class="search-title"): text "Near"
|
||||
genInput("near", "", query.near, "Location...", autofocus=false)
|
||||
|
||||
proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
|
||||
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||
pinned=none(Tweet)): VNode =
|
||||
let query = results.query
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, sequtils, algorithm, uri, options
|
||||
import strutils, strformat, algorithm, uri, options
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import ".."/[types, query, formatters]
|
||||
|
@ -39,24 +39,22 @@ proc renderNoneFound(): VNode =
|
|||
h2(class="timeline-none"):
|
||||
text "No items found"
|
||||
|
||||
proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode =
|
||||
proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="thread-line")):
|
||||
let sortedThread = thread.sortedByIt(it.id)
|
||||
for i, tweet in sortedThread:
|
||||
# thread has a gap, display "more replies" link
|
||||
if i > 0 and tweet.replyId != sortedThread[i - 1].id:
|
||||
tdiv(class="timeline-item thread more-replies-thread"):
|
||||
tdiv(class="more-replies"):
|
||||
a(class="more-replies-text", href=getLink(tweet)):
|
||||
text "more replies"
|
||||
|
||||
let show = i == thread.high and sortedThread[0].id != tweet.threadId
|
||||
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
|
||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||
index=i, last=(i == thread.high), showThread=show)
|
||||
|
||||
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
|
||||
result = @[it]
|
||||
if it.retweet.isSome or it.replyId in threads: return
|
||||
for t in tweets:
|
||||
if t.id == result[0].replyId:
|
||||
result.insert t
|
||||
elif t.replyId == result[0].id:
|
||||
result.add t
|
||||
|
||||
proc renderUser(user: User; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline-item")):
|
||||
a(class="tweet-link", href=("/" & user.username))
|
||||
|
@ -89,7 +87,7 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
|
|||
else:
|
||||
renderNoMore()
|
||||
|
||||
proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
|
||||
proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
|
||||
pinned=none(Tweet)): VNode =
|
||||
buildHtml(tdiv(class="timeline")):
|
||||
if not results.beginning:
|
||||
|
@ -105,26 +103,26 @@ proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
|
|||
else:
|
||||
renderNoneFound()
|
||||
else:
|
||||
var
|
||||
threads: seq[int64]
|
||||
retweets: seq[int64]
|
||||
var retweets: seq[int64]
|
||||
|
||||
for tweet in results.content:
|
||||
let rt = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
||||
for thread in results.content:
|
||||
if thread.len == 1:
|
||||
let
|
||||
tweet = thread[0]
|
||||
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
||||
|
||||
if tweet.id in threads or rt in retweets or tweet.id in retweets or
|
||||
tweet.pinned and prefs.hidePins: continue
|
||||
if retweetId in retweets or tweet.id in retweets or
|
||||
tweet.pinned and prefs.hidePins:
|
||||
continue
|
||||
|
||||
let thread = results.content.threadFilter(threads, tweet)
|
||||
if thread.len < 2:
|
||||
var hasThread = tweet.hasThread
|
||||
if rt != 0:
|
||||
retweets &= rt
|
||||
if retweetId != 0 and tweet.retweet.isSome:
|
||||
retweets &= retweetId
|
||||
hasThread = get(tweet.retweet).hasThread
|
||||
renderTweet(tweet, prefs, path, showThread=hasThread)
|
||||
else:
|
||||
renderThread(thread, prefs, path)
|
||||
threads &= thread.mapIt(it.id)
|
||||
|
||||
renderMore(results.query, results.bottom)
|
||||
if results.bottom.len > 0:
|
||||
renderMore(results.query, results.bottom)
|
||||
renderToTop()
|
||||
|
|
|
@ -14,15 +14,14 @@ proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
|
|||
buildHtml():
|
||||
img(class=(prefs.getAvatarClass & " mini"), src=url)
|
||||
|
||||
proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode =
|
||||
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv):
|
||||
if retweet.len > 0:
|
||||
tdiv(class="retweet-header"):
|
||||
span: icon "retweet", retweet & " retweeted"
|
||||
|
||||
if tweet.pinned:
|
||||
if pinned:
|
||||
tdiv(class="pinned"):
|
||||
span: icon "pin", "Pinned Tweet"
|
||||
elif retweet.len > 0:
|
||||
tdiv(class="retweet-header"):
|
||||
span: icon "retweet", retweet & " retweeted"
|
||||
|
||||
tdiv(class="tweet-header"):
|
||||
a(class="tweet-avatar", href=("/" & tweet.user.username)):
|
||||
|
@ -290,7 +289,10 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||
if tweet.quote.isSome:
|
||||
renderQuote(tweet.quote.get(), prefs, path)
|
||||
|
||||
let fullTweet = tweet
|
||||
let
|
||||
fullTweet = tweet
|
||||
pinned = tweet.pinned
|
||||
|
||||
var retweet: string
|
||||
var tweet = fullTweet
|
||||
if tweet.retweet.isSome:
|
||||
|
@ -303,7 +305,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||
|
||||
tdiv(class="tweet-body"):
|
||||
var views = ""
|
||||
renderHeader(tweet, retweet, prefs)
|
||||
renderHeader(tweet, retweet, pinned, prefs)
|
||||
|
||||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||||
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
|
||||
|
|
|
@ -16,7 +16,12 @@ card = [
|
|||
['FluentAI/status/1116417904831029248',
|
||||
'Amazon’s Alexa isn’t just AI — thousands of humans are listening',
|
||||
'One of the only ways to improve Alexa is to have human beings check it for errors',
|
||||
'theverge.com', True]
|
||||
'theverge.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 = [
|
||||
|
@ -33,12 +38,7 @@ no_thumb = [
|
|||
['voidtarget/status/1133028231672582145',
|
||||
'sinkingsugar/nimqt-example',
|
||||
'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
|
||||
'github.com'],
|
||||
|
||||
['nim_lang/status/1082989146040340480',
|
||||
'Nim in 2018: A short recap',
|
||||
'Posted by u/miran1 - 36 votes and 46 comments',
|
||||
'reddit.com']
|
||||
'github.com']
|
||||
]
|
||||
|
||||
playable = [
|
||||
|
@ -53,17 +53,6 @@ playable = [
|
|||
'youtube.com']
|
||||
]
|
||||
|
||||
# promo = [
|
||||
# ['BangOlufsen/status/1145698701517754368',
|
||||
# 'Upgrade your journey', '',
|
||||
# 'www.bang-olufsen.com'],
|
||||
|
||||
# ['BangOlufsen/status/1154934429900406784',
|
||||
# 'Learn more about Beosound Shape', '',
|
||||
# 'www.bang-olufsen.com']
|
||||
# ]
|
||||
|
||||
|
||||
class CardTest(BaseTestCase):
|
||||
@parameterized.expand(card)
|
||||
def test_card(self, tweet, title, description, destination, large):
|
||||
|
@ -98,13 +87,3 @@ class CardTest(BaseTestCase):
|
|||
self.assert_element_visible('.card-overlay')
|
||||
if len(description) > 0:
|
||||
self.assert_text(description, c.description)
|
||||
|
||||
# @parameterized.expand(promo)
|
||||
# def test_card_promo(self, tweet, title, description, destination):
|
||||
# self.open_nitter(tweet)
|
||||
# c = Card(Conversation.main + " ")
|
||||
# self.assert_text(title, c.title)
|
||||
# self.assert_text(destination, c.destination)
|
||||
# self.assert_element_visible('.video-overlay')
|
||||
# if len(description) > 0:
|
||||
# self.assert_text(description, c.description)
|
||||
|
|
|
@ -66,8 +66,8 @@ class ProfileTest(BaseTestCase):
|
|||
self.assert_text(f'User "{username}" not found')
|
||||
|
||||
def test_suspended(self):
|
||||
self.open_nitter('user')
|
||||
self.assert_text('User "user" has been suspended')
|
||||
self.open_nitter('suspendme')
|
||||
self.assert_text('User "suspendme" has been suspended')
|
||||
|
||||
@parameterized.expand(banner_image)
|
||||
def test_banner_image(self, username, url):
|
||||
|
|
|
@ -2,8 +2,8 @@ from base import BaseTestCase
|
|||
from parameterized import parameterized
|
||||
|
||||
|
||||
class SearchTest(BaseTestCase):
|
||||
@parameterized.expand([['@mobile_test'], ['@mobile_test_2']])
|
||||
def test_username_search(self, username):
|
||||
self.search_username(username)
|
||||
self.assert_text(f'{username}')
|
||||
#class SearchTest(BaseTestCase):
|
||||
#@parameterized.expand([['@mobile_test'], ['@mobile_test_2']])
|
||||
#def test_username_search(self, username):
|
||||
#self.search_username(username)
|
||||
#self.assert_text(f'{username}')
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
from base import BaseTestCase, Timeline
|
||||
from parameterized import parameterized
|
||||
|
||||
normal = [['mobile_test'], ['mobile_test_2']]
|
||||
normal = [['jack'], ['elonmusk']]
|
||||
|
||||
after = [['mobile_test', 'HBaAgJPsqtGNhA0AAA%3D%3D'],
|
||||
['mobile_test_2', 'HBaAgJPsqtGNhA0AAA%3D%3D']]
|
||||
after = [['jack', '1681686036294803456'],
|
||||
['elonmusk', '1681686036294803456']]
|
||||
|
||||
no_more = [['mobile_test_8?cursor=HBaAwJCsk%2F6%2FtgQAAA%3D%3D']]
|
||||
no_more = [['mobile_test_8?cursor=1000']]
|
||||
|
||||
empty = [['emptyuser'], ['mobile_test_10']]
|
||||
|
||||
|
|
|
@ -74,22 +74,22 @@ retweet = [
|
|||
[3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr']
|
||||
]
|
||||
|
||||
reply = [
|
||||
['mobile_test/with_replies', 15]
|
||||
]
|
||||
# reply = [
|
||||
# ['mobile_test/with_replies', 15]
|
||||
# ]
|
||||
|
||||
|
||||
class TweetTest(BaseTestCase):
|
||||
@parameterized.expand(timeline)
|
||||
def test_timeline(self, index, fullname, username, date, tid, text):
|
||||
self.open_nitter(username)
|
||||
tweet = get_timeline_tweet(index)
|
||||
self.assert_exact_text(fullname, tweet.fullname)
|
||||
self.assert_exact_text('@' + username, tweet.username)
|
||||
self.assert_exact_text(date, tweet.date)
|
||||
self.assert_text(text, tweet.text)
|
||||
permalink = self.find_element(tweet.date + ' a')
|
||||
self.assertIn(tid, permalink.get_attribute('href'))
|
||||
# @parameterized.expand(timeline)
|
||||
# def test_timeline(self, index, fullname, username, date, tid, text):
|
||||
# self.open_nitter(username)
|
||||
# tweet = get_timeline_tweet(index)
|
||||
# self.assert_exact_text(fullname, tweet.fullname)
|
||||
# self.assert_exact_text('@' + username, tweet.username)
|
||||
# self.assert_exact_text(date, tweet.date)
|
||||
# self.assert_text(text, tweet.text)
|
||||
# permalink = self.find_element(tweet.date + ' a')
|
||||
# self.assertIn(tid, permalink.get_attribute('href'))
|
||||
|
||||
@parameterized.expand(status)
|
||||
def test_status(self, tid, fullname, username, date, text):
|
||||
|
@ -123,22 +123,22 @@ class TweetTest(BaseTestCase):
|
|||
link = self.find_link_text(f'@{un}')
|
||||
self.assertIn(f'/{un}', link.get_property('href'))
|
||||
|
||||
@parameterized.expand(retweet)
|
||||
def test_retweet(self, index, url, retweet_by, fullname, username, text):
|
||||
self.open_nitter(url)
|
||||
tweet = get_timeline_tweet(index)
|
||||
self.assert_text(f'{retweet_by} retweeted', tweet.retweet)
|
||||
self.assert_text(text, tweet.text)
|
||||
self.assert_exact_text(fullname, tweet.fullname)
|
||||
self.assert_exact_text(username, tweet.username)
|
||||
# @parameterized.expand(retweet)
|
||||
# def test_retweet(self, index, url, retweet_by, fullname, username, text):
|
||||
# self.open_nitter(url)
|
||||
# tweet = get_timeline_tweet(index)
|
||||
# self.assert_text(f'{retweet_by} retweeted', tweet.retweet)
|
||||
# self.assert_text(text, tweet.text)
|
||||
# self.assert_exact_text(fullname, tweet.fullname)
|
||||
# self.assert_exact_text(username, tweet.username)
|
||||
|
||||
@parameterized.expand(invalid)
|
||||
def test_invalid_id(self, tweet):
|
||||
self.open_nitter(tweet)
|
||||
self.assert_text('Tweet not found', '.error-panel')
|
||||
|
||||
@parameterized.expand(reply)
|
||||
def test_thread(self, tweet, num):
|
||||
self.open_nitter(tweet)
|
||||
thread = self.find_element(f'.timeline > div:nth-child({num})')
|
||||
self.assertIn(thread.get_attribute('class'), 'thread-line')
|
||||
# @parameterized.expand(reply)
|
||||
# def test_thread(self, tweet, num):
|
||||
# self.open_nitter(tweet)
|
||||
# thread = self.find_element(f'.timeline > div:nth-child({num})')
|
||||
# self.assertIn(thread.get_attribute('class'), 'thread-line')
|
||||
|
|
Loading…
Reference in a new issue