Merge remote-tracking branch 'upstream/master'

This commit is contained in:
PrivacyDev 2023-07-13 20:53:45 -04:00
commit 8bcab11109
24 changed files with 360 additions and 257 deletions

62
.github/workflows/build-docker.yml vendored Normal file
View file

@ -0,0 +1,62 @@
name: Docker
on:
push:
paths-ignore:
- "README.md"
branches:
- master
jobs:
tests:
uses: ./.github/workflows/run-tests.yml
build-docker-amd64:
needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push AMD64 Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
build-docker-arm64:
needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204-arm
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push ARM64 Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile.arm64
platforms: linux/arm64
push: true
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64

View file

@ -1,9 +1,12 @@
name: Run tests
name: Tests
on:
push:
paths-ignore:
- "*.md"
branches-ignore:
- master
workflow_call:
jobs:
test:

View file

@ -1,6 +1,7 @@
# Nitter
[![Test Matrix](https://github.com/zedeus/nitter/workflows/CI/CD/badge.svg)](https://github.com/zedeus/nitter/actions?query=workflow%3ACI/CD)
[![Test Matrix](https://github.com/zedeus/nitter/workflows/Tests/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/run-tests.yml)
[![Test Matrix](https://github.com/zedeus/nitter/workflows/Docker/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml)
[![License](https://img.shields.io/github/license/zedeus/nitter?style=flat)](#license)
A free and open source alternative Twitter front-end focused on privacy and

View file

@ -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: ""
@ -40,7 +40,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 +50,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.} =
@ -79,7 +79,7 @@ proc getFavorites*(id: string; cfg: Config; after=""): Future[Timeline] {.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)
@ -138,10 +138,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 = %*{
@ -155,8 +155,24 @@ 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.} =
let q = genQueryParam(query)
if q.len == 0 or q == emptyQuery:
return Timeline(query: query, beginning: true)
let url = tweetSearch ? genParams({
"q": q,
"tweet_search_mode": "live",
"max_id": after
})
result = parseTweetSearch(await fetch(url, Api.search))
result.query = query
if after.len == 0:
result.beginning = true
proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} =
if query.text.len == 0:

View file

@ -2,7 +2,7 @@
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")
@ -12,20 +12,21 @@ const
timelineApi = api / "2/timeline"
favorites* = timelineApi / "favorites"
userSearch* = api / "1.1/users/search.json"
tweetSearch* = api / "1.1/search/tweets.json"
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"
graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters"
graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters"
graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers"
@ -56,10 +57,13 @@ const
}.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,
@ -71,15 +75,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
@ -88,43 +102,17 @@ const
tweetVariables* = """{
"focalTweetId": "$1",
$2
"withBirdwatchNotes": false,
"includePromotedContent": false,
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false,
"withVoice": false
}"""
tweetResultVariables* = """{
"tweetId": "$1",
"includePromotedContent": false,
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false,
"withVoice": false,
"withCommunity": false
"includeHasBirdwatchNotes": false
}"""
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
}"""
reactorsVariables* = """{

View file

@ -4,14 +4,17 @@ import user, ../types/[graphuser, graphlistmembers]
from ../../types import User, Result, Query, QueryKind
proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{':
return
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](

View file

@ -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]

View file

@ -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
@ -265,6 +271,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: @[]
@ -279,6 +290,30 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.text.removeSuffix(" Learn more.")
result.available = false
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:
result.quote = some parseLegacyTweet(js{"quoted_status"})
proc parseTweetSearch*(js: JsonNode): Timeline =
if js.kind == JNull or "statuses" notin js:
return Timeline(beginning: true)
for tweet in js{"statuses"}:
let parsed = parseLegacyTweet(tweet)
if parsed.retweet.isSome:
parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"})
result.content.add @[parsed]
let cursor = js{"search_metadata", "next_results"}.getStr
if cursor.len > 0 and "max_id" in cursor:
result.bottom = cursor[cursor.find("=") + 1 .. cursor.find("&q=")]
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))
@ -297,16 +332,6 @@ proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
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
@ -317,7 +342,7 @@ proc parseGlobalObjects(js: JsonNode): GlobalObjects =
result.users[k] = parseUser(v, k)
for k, v in tweets:
var tweet = parseTweet(v, v{"card"})
var tweet = parseTweet(v, v{"tweet_card"})
if tweet.user.id in result.users:
tweet.user = result.users[tweet.user.id]
result.tweets[k] = tweet
@ -327,11 +352,6 @@ proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNod
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
with r, i{"replaceEntry", "entry"}:
if "top" in r{"entryId"}.getStr:
res.top = r.getCursor
@ -370,9 +390,13 @@ proc parseTimeline*(js: JsonNode; after=""): Timeline =
result.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
@ -390,13 +414,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"]:
@ -404,6 +432,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"}:
@ -417,32 +446,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:
@ -457,7 +485,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:
@ -471,34 +499,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 parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline =
result = UsersTimeline(beginning: after.len == 0)

View file

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

View file

@ -27,15 +27,13 @@ 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),
profile.tweets = await getTweetSearch(q, after)
# this is kinda dumb
user: User(
profile.user = User(
username: name,
fullname: names.join(" | "),
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
)
)
if profile.user.suspended:
return Rss(feed: profile.user.username, cursor: "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)

View file

@ -35,9 +35,9 @@ 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, cfg, prefs, getPath()),
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
request, cfg, prefs, title, rss=rss)
else:
resp Http404, showError("Invalid search", cfg)

View file

@ -46,35 +46,22 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; 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)
of favorites: getFavorites(userId, cfg, 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 getGraphUserTweets(userId, TimelineKind.tweets, after)
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
of favorites: Profile(tweets: await getFavorites(userId, cfg, 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
if result.user.protected or result.user.suspended:
return
@ -85,8 +72,8 @@ 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)
html = renderTweetSearch(timeline, cfg, prefs, getPath())
timeline = await getTweetSearch(query, after)
html = renderTweetSearch(timeline, prefs, getPath())
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
var profile = await fetchProfile(after, query, cfg, skipPinned=prefs.hidePins)
@ -147,10 +134,10 @@ 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 getGraphSearch(query, after)).tweets
if timeline.content.len == 0: resp Http404
timeline.beginning = true
resp $renderTweetSearch(timeline, cfg, prefs, getPath())
resp $renderTweetSearch(timeline, prefs, getPath())
else:
var profile = await fetchProfile(after, query, cfg, skipRail=true)
if profile.tweets.content.len == 0: resp Http404

View file

@ -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;
}
}
}

View file

@ -41,11 +41,10 @@ proc getPoolJson*(): JsonNode =
let
maxReqs =
case api
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, Api.favorites, Api.retweeters, Api.favoriters: 500
of Api.timeline, Api.search: 180
of Api.userTweets, Api.userTweetsAndReplies, Api.userRestId,
Api.userScreenName, Api.tweetDetail, Api.tweetResult: 500
of Api.list, Api.listTweets, Api.listMembers, Api.listBySlug, Api.userMedia, Api.favorites, Api.retweeters, Api.favoriters: 500
of Api.userSearch: 900
else: 180
reqs = maxReqs - token.apis[api].remaining

View file

@ -210,6 +210,8 @@ type
video*: Option[Video]
photos*: seq[string]
Tweets* = seq[Tweet]
Result*[T] = object
content*: seq[T]
top*, bottom*: string
@ -217,7 +219,7 @@ type
query*: Query
Chain* = object
content*: seq[Tweet]
content*: Tweets
hasMore*: bool
cursor*: string
@ -227,7 +229,7 @@ type
after*: Chain
replies*: Result[Chain]
Timeline* = Result[Tweet]
Timeline* = Result[Tweets]
UsersTimeline* = Result[User]
Profile* = object
@ -283,3 +285,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]

View file

@ -120,4 +120,4 @@ proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: strin
if profile.user.protected:
renderProtected(profile.user.username)
else:
renderTweetSearch(profile.tweets, cfg, prefs, path, profile.pinned)
renderTweetSearch(profile.tweets, prefs, path, profile.pinned)

View file

@ -56,12 +56,16 @@ 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
#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
@ -75,6 +79,7 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
<link>${urlPrefix & link}</link>
</item>
# end for
#end for
#end proc
#
#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string =
@ -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 = ""

View file

@ -3,7 +3,7 @@ import strutils, strformat, sequtils, unicode, tables, options
import karax/[karaxdsl, vdom]
import renderutils, timeline
import ".."/[types, query]
import ".."/[types, query, config]
const toggles = {
"nativeretweets": "Retweets",
@ -91,7 +91,7 @@ proc renderSearchPanel*(query: Query): VNode =
span(class="search-title"): text "Near"
genInput("near", "", query.near, "Location...", autofocus=false)
proc renderTweetSearch*(results: Result[Tweet]; cfg: Config; 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")):

View file

@ -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)
if results.bottom.len > 0:
renderMore(results.query, results.bottom)
renderToTop()

View file

@ -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)):
@ -295,7 +294,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:
@ -308,7 +310,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):

View file

@ -16,7 +16,12 @@ card = [
['FluentAI/status/1116417904831029248',
'Amazons Alexa isnt 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,17 +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'],
['mobile_test/status/490378953744318464',
'Nantasket Beach',
'Explore this photo titled Nantasket Beach by Ben Sandofsky (@sandofsky) on 500px',
'500px.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 = [
@ -58,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):
@ -76,7 +60,7 @@ class CardTest(BaseTestCase):
c = Card(Conversation.main + " ")
self.assert_text(title, c.title)
self.assert_text(destination, c.destination)
self.assertIn('_img', self.get_image_url(c.image + ' img'))
self.assertIn('/pic/', self.get_image_url(c.image + ' img'))
if len(description) > 0:
self.assert_text(description, c.description)
if large:
@ -99,17 +83,7 @@ class CardTest(BaseTestCase):
c = Card(Conversation.main + " ")
self.assert_text(title, c.title)
self.assert_text(destination, c.destination)
self.assertIn('_img', self.get_image_url(c.image + ' img'))
self.assertIn('/pic/', self.get_image_url(c.image + ' img'))
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)

View file

@ -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):

View file

@ -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}')

View file

@ -74,9 +74,9 @@ 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):
@ -137,8 +137,8 @@ class TweetTest(BaseTestCase):
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')