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)
|
||||
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.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ const
|
|||
graphAudioSpace* = "rWRLsOhNJ2xjpI1tREYurQ/AudioSpaceById"
|
||||
restLiveStream* = "1.1/live_video_stream/status/"
|
||||
|
||||
graphFollowers* = "9jsVJ9l2uXUIKslHvJqIhw/Followers"
|
||||
graphFollowing* = "OLm4oHZBfqWx8jbcEhWoFw/Following"
|
||||
|
||||
gqlFeatures* = """{
|
||||
"rweb_video_screen_enabled": false,
|
||||
"rweb_cashtags_enabled": true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import options, strutils
|
||||
import jsony
|
||||
import user, utils, ../types/[graphuser, graphlistmembers]
|
||||
import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers]
|
||||
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
||||
|
||||
proc parseUserResult*(userResult: UserResult): User =
|
||||
|
|
@ -67,3 +67,25 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
|||
of TimelineTimelineCursor:
|
||||
if entry.content.cursorType == "Bottom":
|
||||
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
|
||||
membersTimeline*: tuple[timeline: Timeline]
|
||||
|
||||
Timeline = object
|
||||
Timeline* = object
|
||||
instructions*: seq[Instruction]
|
||||
|
||||
Instruction = object
|
||||
Instruction* = object
|
||||
kind*: string
|
||||
entries*: seq[tuple[content: Content]]
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ type
|
|||
TimelineTimelineItem
|
||||
TimelineTimelineCursor
|
||||
|
||||
Content = object
|
||||
Content* = object
|
||||
case entryType*: ContentEntryType
|
||||
of TimelineTimelineItem:
|
||||
itemContent*: tuple[userResults: UserData]
|
||||
|
|
|
|||
|
|
@ -135,6 +135,39 @@ proc createTimelineRouter*(cfg: Config) =
|
|||
resp renderMain(aboutHtml, request, cfg, prefs,
|
||||
"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?/?":
|
||||
cond '.' notin @"name"
|
||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ type
|
|||
rateLimited = 88
|
||||
expiredToken = 89
|
||||
listIdOrSlug = 112
|
||||
timelineUnavailable = 131
|
||||
tweetNotFound = 144
|
||||
tweetNotAuthorized = 179
|
||||
forbidden = 200
|
||||
|
|
@ -173,7 +174,7 @@ type
|
|||
variants*: seq[VideoVariant]
|
||||
|
||||
QueryKind* = enum
|
||||
posts, replies, media, users, tweets, userList
|
||||
posts, replies, media, users, tweets, userList, followers, following
|
||||
|
||||
Query* = object
|
||||
kind*: QueryKind
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import strutils, strformat
|
||||
import karax/[karaxdsl, vdom, vstyles]
|
||||
|
||||
import renderutils, search
|
||||
import renderutils, search, timeline
|
||||
import ".."/[types, utils, formatters]
|
||||
|
||||
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"):
|
||||
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 =
|
||||
buildHtml(tdiv(class="profile-card")):
|
||||
tdiv(class="profile-card-info"):
|
||||
|
|
@ -65,8 +72,8 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
|
|||
tdiv(class="profile-card-extra-links"):
|
||||
ul(class="profile-statlist"):
|
||||
renderStat(user.tweets, "posts", text="Tweets")
|
||||
renderStat(user.following, "following")
|
||||
renderStat(user.followers, "followers")
|
||||
renderStatLink(user.following, "following", &"/{user.username}/following")
|
||||
renderStatLink(user.followers, "followers", &"/{user.username}/followers")
|
||||
renderStat(user.likes, "likes")
|
||||
|
||||
proc renderPhotoRail(profile: Profile): VNode =
|
||||
|
|
@ -99,7 +106,7 @@ proc renderBanner(banner: string): VNode =
|
|||
else:
|
||||
a(href=getPicUrl(banner), target="_blank"): genImg(banner)
|
||||
|
||||
proc renderProtected(username: string): VNode =
|
||||
proc renderProtected*(username: string): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
tdiv(class="timeline-header timeline-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)
|
||||
else:
|
||||
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