mirror of
https://github.com/zedeus/nitter
synced 2026-09-05 14:49:32 +00:00
parent
1e03595784
commit
55a331cc50
9 changed files with 185 additions and 9 deletions
22
src/api.nim
22
src/api.nim
|
|
@ -201,6 +201,28 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
||||||
js = await fetchRaw(url)
|
js = await fetchRaw(url)
|
||||||
result = parseGraphListMembers(js, after)
|
result = parseGraphListMembers(js, after)
|
||||||
|
|
||||||
|
proc getGraphUserConnections(userId: string; endpoint: string; kind: QueryKind;
|
||||||
|
after=""): Future[Result[User]] {.async.} =
|
||||||
|
if userId.len == 0: return
|
||||||
|
var variables = %*{
|
||||||
|
"userId": userId,
|
||||||
|
"count": 20,
|
||||||
|
"includePromotedContent": false,
|
||||||
|
"withGrokTranslatedBio": true
|
||||||
|
}
|
||||||
|
if after.len > 0:
|
||||||
|
variables["cursor"] = %after
|
||||||
|
let
|
||||||
|
url = apiReq(endpoint, $variables)
|
||||||
|
js = await fetchRaw(url)
|
||||||
|
result = parseGraphFollowers(js, after, kind)
|
||||||
|
|
||||||
|
proc getGraphFollowers*(userId: string; after=""): Future[Result[User]] {.async.} =
|
||||||
|
result = await getGraphUserConnections(userId, graphFollowers, followers, after)
|
||||||
|
|
||||||
|
proc getGraphFollowing*(userId: string; after=""): Future[Result[User]] {.async.} =
|
||||||
|
result = await getGraphUserConnections(userId, graphFollowing, following, after)
|
||||||
|
|
||||||
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ const
|
||||||
graphAudioSpace* = "rWRLsOhNJ2xjpI1tREYurQ/AudioSpaceById"
|
graphAudioSpace* = "rWRLsOhNJ2xjpI1tREYurQ/AudioSpaceById"
|
||||||
restLiveStream* = "1.1/live_video_stream/status/"
|
restLiveStream* = "1.1/live_video_stream/status/"
|
||||||
|
|
||||||
|
graphFollowers* = "9jsVJ9l2uXUIKslHvJqIhw/Followers"
|
||||||
|
graphFollowing* = "OLm4oHZBfqWx8jbcEhWoFw/Following"
|
||||||
|
|
||||||
gqlFeatures* = """{
|
gqlFeatures* = """{
|
||||||
"rweb_video_screen_enabled": false,
|
"rweb_video_screen_enabled": false,
|
||||||
"rweb_cashtags_enabled": true,
|
"rweb_cashtags_enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import options, strutils
|
import options, strutils
|
||||||
import jsony
|
import jsony
|
||||||
import user, utils, ../types/[graphuser, graphlistmembers]
|
import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers]
|
||||||
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
||||||
|
|
||||||
proc parseUserResult*(userResult: UserResult): User =
|
proc parseUserResult*(userResult: UserResult): User =
|
||||||
|
|
@ -67,3 +67,25 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||||
of TimelineTimelineCursor:
|
of TimelineTimelineCursor:
|
||||||
if entry.content.cursorType == "Bottom":
|
if entry.content.cursorType == "Bottom":
|
||||||
result.bottom = entry.content.value
|
result.bottom = entry.content.value
|
||||||
|
|
||||||
|
proc parseGraphFollowers*(json, cursor: string; kind: QueryKind): Result[User] =
|
||||||
|
result = Result[User](
|
||||||
|
beginning: cursor.len == 0,
|
||||||
|
query: Query(kind: kind)
|
||||||
|
)
|
||||||
|
|
||||||
|
if json.len == 0 or json[0] != '{':
|
||||||
|
return
|
||||||
|
|
||||||
|
let raw = json.fromJson(GraphFollowers)
|
||||||
|
for instruction in raw.data.user.result.timeline.timeline.instructions:
|
||||||
|
if instruction.kind == "TimelineAddEntries":
|
||||||
|
for entry in instruction.entries:
|
||||||
|
case entry.content.entryType
|
||||||
|
of TimelineTimelineItem:
|
||||||
|
let userResult = entry.content.itemContent.userResults.result
|
||||||
|
if userResult.restId.len > 0:
|
||||||
|
result.content.add parseUserResult(userResult)
|
||||||
|
of TimelineTimelineCursor:
|
||||||
|
if entry.content.cursorType == "Bottom":
|
||||||
|
result.bottom = entry.content.value
|
||||||
|
|
|
||||||
17
src/experimental/types/graphfollowers.nim
Normal file
17
src/experimental/types/graphfollowers.nim
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import graphlistmembers
|
||||||
|
|
||||||
|
type
|
||||||
|
GraphFollowers* = object
|
||||||
|
data*: tuple[user: UserWrapper]
|
||||||
|
|
||||||
|
UserWrapper = object
|
||||||
|
result*: UserResultWrapper
|
||||||
|
|
||||||
|
UserResultWrapper = object
|
||||||
|
timeline*: tuple[timeline: graphlistmembers.Timeline]
|
||||||
|
|
||||||
|
# Hook to normalize snake_case field from API to camelCase used by shared types
|
||||||
|
proc renameHook*(v: var Content; fieldName: var string) =
|
||||||
|
if fieldName == "user_results":
|
||||||
|
fieldName = "userResults"
|
||||||
|
|
@ -7,10 +7,10 @@ type
|
||||||
List = object
|
List = object
|
||||||
membersTimeline*: tuple[timeline: Timeline]
|
membersTimeline*: tuple[timeline: Timeline]
|
||||||
|
|
||||||
Timeline = object
|
Timeline* = object
|
||||||
instructions*: seq[Instruction]
|
instructions*: seq[Instruction]
|
||||||
|
|
||||||
Instruction = object
|
Instruction* = object
|
||||||
kind*: string
|
kind*: string
|
||||||
entries*: seq[tuple[content: Content]]
|
entries*: seq[tuple[content: Content]]
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ type
|
||||||
TimelineTimelineItem
|
TimelineTimelineItem
|
||||||
TimelineTimelineCursor
|
TimelineTimelineCursor
|
||||||
|
|
||||||
Content = object
|
Content* = object
|
||||||
case entryType*: ContentEntryType
|
case entryType*: ContentEntryType
|
||||||
of TimelineTimelineItem:
|
of TimelineTimelineItem:
|
||||||
itemContent*: tuple[userResults: UserData]
|
itemContent*: tuple[userResults: UserData]
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,39 @@ proc createTimelineRouter*(cfg: Config) =
|
||||||
resp renderMain(aboutHtml, request, cfg, prefs,
|
resp renderMain(aboutHtml, request, cfg, prefs,
|
||||||
"About @" & info.username)
|
"About @" & info.username)
|
||||||
|
|
||||||
|
get "/@name/@kind/?":
|
||||||
|
cond @"kind" in ["followers", "following"]
|
||||||
|
cond '.' notin @"name"
|
||||||
|
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
|
||||||
|
let
|
||||||
|
prefs = requestPrefs()
|
||||||
|
name = @"name"
|
||||||
|
userId = await getUserId(name)
|
||||||
|
|
||||||
|
if userId.len == 0:
|
||||||
|
resp Http404, showError("User \"" & name & "\" not found", cfg)
|
||||||
|
if userId == "suspended":
|
||||||
|
resp showError(getSuspended(name), cfg)
|
||||||
|
|
||||||
|
let
|
||||||
|
cursor = getCursor()
|
||||||
|
isFollowers = @"kind" == "followers"
|
||||||
|
user = await getCachedUser(name)
|
||||||
|
results = if isFollowers: await getGraphFollowers(userId, cursor)
|
||||||
|
else: await getGraphFollowing(userId, cursor)
|
||||||
|
|
||||||
|
if user.protected:
|
||||||
|
resp renderMain(renderProtected(user.username), request, cfg, prefs,
|
||||||
|
"Protected account @" & name)
|
||||||
|
|
||||||
|
let
|
||||||
|
tab = if isFollowers: "Followers" else: "Following"
|
||||||
|
title = (if isFollowers: "People following @" else: "People followed by @") & name
|
||||||
|
html = renderUserList(user, results, prefs, request.path, tab)
|
||||||
|
|
||||||
|
resp renderMain(html, request, cfg, prefs, title,
|
||||||
|
images = @[user.getUserPic("_400x400")])
|
||||||
|
|
||||||
get "/@name/?@tab?/?":
|
get "/@name/?@tab?/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ type
|
||||||
rateLimited = 88
|
rateLimited = 88
|
||||||
expiredToken = 89
|
expiredToken = 89
|
||||||
listIdOrSlug = 112
|
listIdOrSlug = 112
|
||||||
|
timelineUnavailable = 131
|
||||||
tweetNotFound = 144
|
tweetNotFound = 144
|
||||||
tweetNotAuthorized = 179
|
tweetNotAuthorized = 179
|
||||||
forbidden = 200
|
forbidden = 200
|
||||||
|
|
@ -173,7 +174,7 @@ type
|
||||||
variants*: seq[VideoVariant]
|
variants*: seq[VideoVariant]
|
||||||
|
|
||||||
QueryKind* = enum
|
QueryKind* = enum
|
||||||
posts, replies, media, users, tweets, userList
|
posts, replies, media, users, tweets, userList, followers, following
|
||||||
|
|
||||||
Query* = object
|
Query* = object
|
||||||
kind*: QueryKind
|
kind*: QueryKind
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import strutils, strformat
|
import strutils, strformat
|
||||||
import karax/[karaxdsl, vdom, vstyles]
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
|
|
||||||
import renderutils, search
|
import renderutils, search, timeline
|
||||||
import ".."/[types, utils, formatters]
|
import ".."/[types, utils, formatters]
|
||||||
|
|
||||||
proc renderStat(num: int; class: string; text=""): VNode =
|
proc renderStat(num: int; class: string; text=""): VNode =
|
||||||
|
|
@ -12,6 +12,13 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
||||||
span(class="profile-stat-num"):
|
span(class="profile-stat-num"):
|
||||||
text insertSep($num, ',')
|
text insertSep($num, ',')
|
||||||
|
|
||||||
|
proc renderStatLink(num: int; class, href: string): VNode =
|
||||||
|
buildHtml(li(class=class)):
|
||||||
|
a(href=href):
|
||||||
|
span(class="profile-stat-header"): text capitalizeAscii(class)
|
||||||
|
span(class="profile-stat-num"):
|
||||||
|
text insertSep($num, ',')
|
||||||
|
|
||||||
proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
|
proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
|
||||||
buildHtml(tdiv(class="profile-card")):
|
buildHtml(tdiv(class="profile-card")):
|
||||||
tdiv(class="profile-card-info"):
|
tdiv(class="profile-card-info"):
|
||||||
|
|
@ -65,8 +72,8 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
|
||||||
tdiv(class="profile-card-extra-links"):
|
tdiv(class="profile-card-extra-links"):
|
||||||
ul(class="profile-statlist"):
|
ul(class="profile-statlist"):
|
||||||
renderStat(user.tweets, "posts", text="Tweets")
|
renderStat(user.tweets, "posts", text="Tweets")
|
||||||
renderStat(user.following, "following")
|
renderStatLink(user.following, "following", &"/{user.username}/following")
|
||||||
renderStat(user.followers, "followers")
|
renderStatLink(user.followers, "followers", &"/{user.username}/followers")
|
||||||
renderStat(user.likes, "likes")
|
renderStat(user.likes, "likes")
|
||||||
|
|
||||||
proc renderPhotoRail(profile: Profile): VNode =
|
proc renderPhotoRail(profile: Profile): VNode =
|
||||||
|
|
@ -99,7 +106,7 @@ proc renderBanner(banner: string): VNode =
|
||||||
else:
|
else:
|
||||||
a(href=getPicUrl(banner), target="_blank"): genImg(banner)
|
a(href=getPicUrl(banner), target="_blank"): genImg(banner)
|
||||||
|
|
||||||
proc renderProtected(username: string): VNode =
|
proc renderProtected*(username: string): VNode =
|
||||||
buildHtml(tdiv(class="timeline-container")):
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
tdiv(class="timeline-header timeline-protected"):
|
tdiv(class="timeline-header timeline-protected"):
|
||||||
h2: text "This account's tweets are protected."
|
h2: text "This account's tweets are protected."
|
||||||
|
|
@ -128,3 +135,31 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
||||||
renderProtected(profile.user.username)
|
renderProtected(profile.user.username)
|
||||||
else:
|
else:
|
||||||
renderTweetSearch(profile.tweets, prefs, path, profile.pinned)
|
renderTweetSearch(profile.tweets, prefs, path, profile.pinned)
|
||||||
|
|
||||||
|
proc renderFollowTabs(user: User; activeTab: string): VNode =
|
||||||
|
buildHtml(ul(class="tab")):
|
||||||
|
for tab in ["Following", "Followers"]:
|
||||||
|
li(class=(if activeTab == tab: "tab-item active" else: "tab-item")):
|
||||||
|
a(href=(&"/{user.username}/{tab.toLowerAscii()}")): text tab
|
||||||
|
|
||||||
|
proc renderUserList*(user: User; results: Result[User]; prefs: Prefs;
|
||||||
|
path, activeTab: string): VNode =
|
||||||
|
# Check if we've loaded all results (for page 1)
|
||||||
|
var displayResults = results
|
||||||
|
if results.beginning:
|
||||||
|
let expectedCount = if activeTab == "Followers": user.followers else: user.following
|
||||||
|
if results.content.len >= expectedCount:
|
||||||
|
displayResults.bottom = ""
|
||||||
|
|
||||||
|
buildHtml(tdiv(class="profile-tabs")):
|
||||||
|
if not prefs.hideBanner:
|
||||||
|
tdiv(class="profile-banner"):
|
||||||
|
renderBanner(user.banner)
|
||||||
|
|
||||||
|
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||||
|
tdiv(class=("profile-tab" & sticky)):
|
||||||
|
renderUserCard(user, prefs, AccountInfo())
|
||||||
|
|
||||||
|
tdiv(class="timeline-container"):
|
||||||
|
renderFollowTabs(user, activeTab)
|
||||||
|
renderTimelineUsers(displayResults, prefs, path)
|
||||||
|
|
|
||||||
43
tests/test_followers.py
Normal file
43
tests/test_followers.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
from base import BaseTestCase, Timeline
|
||||||
|
|
||||||
|
|
||||||
|
class FollowersTest(BaseTestCase):
|
||||||
|
"""Tests for followers and following pages"""
|
||||||
|
|
||||||
|
def test_followers_page_loads(self):
|
||||||
|
"""Test that followers page loads and shows users"""
|
||||||
|
self.open_nitter('jack/followers')
|
||||||
|
self.assert_title_contains('following @jack')
|
||||||
|
# Check for user list
|
||||||
|
self.assert_element('.timeline-item')
|
||||||
|
|
||||||
|
def test_following_page_loads(self):
|
||||||
|
"""Test that following page loads and shows users"""
|
||||||
|
self.open_nitter('jack/following')
|
||||||
|
self.assert_title_contains('followed by @jack')
|
||||||
|
# Check for user list
|
||||||
|
self.assert_element('.timeline-item')
|
||||||
|
|
||||||
|
def test_followers_has_navigation_tabs(self):
|
||||||
|
"""Test that followers page has following/followers tabs"""
|
||||||
|
self.open_nitter('jack/followers')
|
||||||
|
self.assert_element('.profile-statlist')
|
||||||
|
# Check for Following and Followers links
|
||||||
|
self.assert_element('a[href="/jack/following"]')
|
||||||
|
self.assert_element('a[href="/jack/followers"]')
|
||||||
|
|
||||||
|
def test_following_pagination(self):
|
||||||
|
"""Test that following page has load more button or scroll-to-top"""
|
||||||
|
self.open_nitter('jack/following')
|
||||||
|
# Should have either more button or top-ref (scroll to top arrow)
|
||||||
|
try:
|
||||||
|
self.assert_element('.show-more')
|
||||||
|
except:
|
||||||
|
self.assert_element('.top-ref')
|
||||||
|
|
||||||
|
def test_nonexistent_user_followers(self):
|
||||||
|
"""Test 404 for non-existent user"""
|
||||||
|
self.open_nitter('this_user_definitely_does_not_exist_12345/followers')
|
||||||
|
self.assert_element('.error-panel')
|
||||||
|
self.assert_text_visible('not found')
|
||||||
Loading…
Reference in a new issue