mirror of
https://github.com/zedeus/nitter.git
synced 2026-04-03 20:32:10 -04:00
@@ -66,6 +66,13 @@ proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||
js = await fetchRaw(url)
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} =
|
||||
if username.len == 0: return
|
||||
let
|
||||
url = apiReq(graphAboutAccount, """{"screenName":"$1"}""" % username)
|
||||
js = await fetch(url)
|
||||
result = parseAboutAccount(js)
|
||||
|
||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
|
||||
@@ -25,6 +25,7 @@ const
|
||||
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
||||
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
||||
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
||||
graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery"
|
||||
|
||||
gqlFeatures* = """{
|
||||
"android_ad_formats_media_component_render_overlay_enabled": false,
|
||||
|
||||
@@ -68,6 +68,48 @@ proc parseGraphUser(js: JsonNode): User =
|
||||
with verifiedType, user{"verification", "verified_type"}:
|
||||
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||
|
||||
proc parseAboutAccount*(js: JsonNode): AccountInfo =
|
||||
if js.isNull: return
|
||||
|
||||
let user = ? js{"data", "user_result_by_screen_name", "result"}
|
||||
|
||||
if user{"unavailable_reason"}.getStr == "Suspended":
|
||||
result.suspended = true
|
||||
return
|
||||
|
||||
result = AccountInfo(
|
||||
username: user{"core", "screen_name"}.getStr,
|
||||
fullname: user{"core", "name"}.getStr,
|
||||
joinDate: user{"core", "created_at"}.getTime,
|
||||
userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""),
|
||||
affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr,
|
||||
)
|
||||
|
||||
if user{"is_blue_verified"}.getBool(false):
|
||||
result.verifiedType = blue
|
||||
with verifiedType, user{"verification", "verified_type"}:
|
||||
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||
|
||||
with about, user{"about_profile"}:
|
||||
result.basedIn = about{"account_based_in"}.getStr
|
||||
result.source = about{"source"}.getStr
|
||||
result.affiliateUsername = about{"affiliate_username"}.getStr
|
||||
|
||||
try:
|
||||
result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt
|
||||
except ValueError:
|
||||
discard
|
||||
|
||||
with lastChange, about{"username_changes", "last_changed_at_msec"}:
|
||||
result.lastUsernameChange = lastChange.getTimeFromMsStr
|
||||
|
||||
with info, user{"verification_info"}:
|
||||
result.isIdentityVerified = info{"is_identity_verified"}.getBool
|
||||
with reason, info{"reason"}:
|
||||
result.overrideVerifiedYear = reason{"override_verified_year"}.getInt
|
||||
with since, reason{"verified_since_msec"}:
|
||||
result.verifiedSince = since.getTimeFromMsStr
|
||||
|
||||
proc parseGraphList*(js: JsonNode): List =
|
||||
if js.isNull: return
|
||||
|
||||
|
||||
@@ -88,6 +88,14 @@ proc getTimeFromMs*(js: JsonNode): DateTime =
|
||||
let seconds = ms div 1000
|
||||
return fromUnix(seconds).utc()
|
||||
|
||||
proc getTimeFromMsStr*(js: JsonNode): DateTime =
|
||||
var ms: int64
|
||||
try: ms = parseBiggestInt(js.getStr("0"))
|
||||
except ValueError: return
|
||||
if ms == 0: return
|
||||
let seconds = ms div 1000
|
||||
return fromUnix(seconds).utc()
|
||||
|
||||
proc getId*(id: string): int64 {.inline.} =
|
||||
let start = id.rfind("-")
|
||||
if start < 0:
|
||||
|
||||
@@ -158,6 +158,19 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
||||
# if not result.isNil:
|
||||
# await cache(result)
|
||||
|
||||
proc cache*(data: AccountInfo; name: string) {.async.} =
|
||||
await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))
|
||||
|
||||
proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} =
|
||||
if username.len == 0: return
|
||||
let name = toLower(username)
|
||||
let cached = await get("ai:" & name)
|
||||
if cached != redisNil:
|
||||
cached.deserialize(AccountInfo)
|
||||
elif fetch:
|
||||
result = await getAboutAccount(username)
|
||||
await cache(result, name)
|
||||
|
||||
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||
if id.len == 0: return
|
||||
let rail = await get("pr2:" & toLower(id))
|
||||
|
||||
@@ -4,13 +4,13 @@ import jester, karax/vdom
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, redis_cache, formatters, query, api]
|
||||
import ../views/[general, profile, timeline, status, search]
|
||||
import ../views/[general, profile, timeline, status, search, about_account]
|
||||
|
||||
export vdom
|
||||
export uri, sequtils
|
||||
export router_utils
|
||||
export redis_cache, formatters, query, api
|
||||
export profile, timeline, status
|
||||
export profile, timeline, status, about_account
|
||||
|
||||
proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
|
||||
let view = request.params.getOrDefault("view")
|
||||
@@ -57,6 +57,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
|
||||
getCachedPhotoRail(userId)
|
||||
|
||||
user = getCachedUser(name)
|
||||
info = getCachedAccountInfo(name, fetch=false)
|
||||
|
||||
result =
|
||||
case query.kind
|
||||
@@ -67,6 +68,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
|
||||
|
||||
result.user = await user
|
||||
result.photoRail = await rail
|
||||
result.accountInfo = await info
|
||||
|
||||
result.tweets.query = query
|
||||
|
||||
@@ -119,6 +121,20 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
resp Http400, showError("Missing screen_name parameter", cfg)
|
||||
redirect("/" & username)
|
||||
|
||||
get "/@name/about/?":
|
||||
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
|
||||
let
|
||||
prefs = requestPrefs()
|
||||
name = @"name"
|
||||
info = await getCachedAccountInfo(name)
|
||||
if info.suspended:
|
||||
resp showError(getSuspended(name), cfg)
|
||||
if info.username.len == 0:
|
||||
resp Http404, showError("User \"" & name & "\" not found", cfg)
|
||||
let aboutHtml = renderAboutAccount(info)
|
||||
resp renderMain(aboutHtml, request, cfg, prefs,
|
||||
"About @" & info.username)
|
||||
|
||||
get "/@name/?@tab?/?":
|
||||
cond '.' notin @"name"
|
||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@import "_mixins";
|
||||
|
||||
@import "card";
|
||||
@import "about-account";
|
||||
@import "photo-rail";
|
||||
|
||||
.profile-tabs {
|
||||
|
||||
71
src/sass/profile/about-account.scss
Normal file
71
src/sass/profile/about-account.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
@import '_variables';
|
||||
|
||||
.about-account {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
margin: 20px auto 0;
|
||||
align-self: flex-start;
|
||||
background: var(--bg_panel);
|
||||
border-radius: 4px;
|
||||
padding: 12px 20px 20px;
|
||||
}
|
||||
|
||||
.about-account-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border_grey);
|
||||
}
|
||||
|
||||
.about-account-avatar img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.about-account-name {
|
||||
@include breakable;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.about-account-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.about-account-at {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.about-account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
> span:first-child {
|
||||
color: var(--fg_faded);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.about-account-label {
|
||||
color: var(--fg_faded);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media(max-width: 700px) {
|
||||
.about-account {
|
||||
max-width: none;
|
||||
margin: 10px;
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,23 @@ type
|
||||
suspended*: bool
|
||||
joinDate*: DateTime
|
||||
|
||||
AccountInfo* = object
|
||||
username*: string
|
||||
fullname*: string
|
||||
userPic*: string
|
||||
joinDate*: DateTime
|
||||
verifiedType*: VerifiedType
|
||||
suspended*: bool
|
||||
basedIn*: string
|
||||
source*: string
|
||||
usernameChanges*: int
|
||||
lastUsernameChange*: DateTime
|
||||
affiliateUsername*: string
|
||||
affiliateLabel*: string
|
||||
isIdentityVerified*: bool
|
||||
verifiedSince*: DateTime
|
||||
overrideVerifiedYear*: int
|
||||
|
||||
VideoType* = enum
|
||||
m3u8 = "application/x-mpegURL"
|
||||
mp4 = "video/mp4"
|
||||
@@ -273,6 +290,7 @@ type
|
||||
photoRail*: PhotoRail
|
||||
pinned*: Option[Tweet]
|
||||
tweets*: Timeline
|
||||
accountInfo*: AccountInfo
|
||||
|
||||
List* = object
|
||||
id*: string
|
||||
|
||||
93
src/views/about_account.nim
Normal file
93
src/views/about_account.nim
Normal file
@@ -0,0 +1,93 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, times
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils
|
||||
import ".."/[types, formatters]
|
||||
|
||||
proc renderAboutAccount*(info: AccountInfo): VNode =
|
||||
let user = User(
|
||||
username: info.username,
|
||||
fullname: info.fullname,
|
||||
userPic: info.userPic,
|
||||
verifiedType: info.verifiedType
|
||||
)
|
||||
|
||||
buildHtml(tdiv(class="about-account")):
|
||||
tdiv(class="about-account-header"):
|
||||
a(class="about-account-avatar", href=(&"/{info.username}")):
|
||||
genImg(getUserPic(info.userPic, "_200x200"))
|
||||
tdiv(class="about-account-name"):
|
||||
linkUser(user, class="profile-card-fullname")
|
||||
verifiedIcon(user)
|
||||
linkUser(user, class="profile-card-username")
|
||||
|
||||
tdiv(class="about-account-body"):
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "calendar"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "Date joined"
|
||||
span(class="about-account-value"):
|
||||
text info.joinDate.format("MMMM YYYY")
|
||||
|
||||
if info.basedIn.len > 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "location"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "Account based in"
|
||||
span(class="about-account-value"): text info.basedIn
|
||||
|
||||
if info.verifiedType != VerifiedType.none:
|
||||
if info.overrideVerifiedYear != 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "ok"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "Verified"
|
||||
span(class="about-account-value"):
|
||||
let year = abs(info.overrideVerifiedYear)
|
||||
let era = if info.overrideVerifiedYear < 0: " BCE" else: ""
|
||||
text "Since " & $year & era
|
||||
elif info.verifiedSince.year > 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "ok"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "Verified"
|
||||
span(class="about-account-value"):
|
||||
text "Since " & info.verifiedSince.format("MMMM YYYY")
|
||||
|
||||
if info.isIdentityVerified:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "ok"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "ID Verified"
|
||||
span(class="about-account-value"): text "Yes"
|
||||
|
||||
if info.affiliateUsername.len > 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "group"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "An affiliate of"
|
||||
span(class="about-account-value"):
|
||||
a(href=(&"/{info.affiliateUsername}")):
|
||||
if info.affiliateLabel.len > 0:
|
||||
text info.affiliateLabel & " (@" & info.affiliateUsername & ")"
|
||||
else:
|
||||
text "@" & info.affiliateUsername
|
||||
|
||||
if info.usernameChanges > 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span(class="about-account-at"): text "@"
|
||||
tdiv:
|
||||
span(class="about-account-label"):
|
||||
text $info.usernameChanges & " username change"
|
||||
if info.usernameChanges > 1: text "s"
|
||||
if info.lastUsernameChange.year > 0:
|
||||
span(class="about-account-value"):
|
||||
text "Last on " & info.lastUsernameChange.format("MMMM YYYY")
|
||||
|
||||
if info.source.len > 0:
|
||||
tdiv(class="about-account-row"):
|
||||
span: icon "link"
|
||||
tdiv:
|
||||
span(class="about-account-label"): text "Connected via"
|
||||
span(class="about-account-value"): text info.source
|
||||
@@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||
|
||||
buildHtml(head):
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=32")
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=34")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
|
||||
|
||||
if theme.len > 0:
|
||||
|
||||
@@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
||||
span(class="profile-stat-num"):
|
||||
text insertSep($num, ',')
|
||||
|
||||
proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
|
||||
buildHtml(tdiv(class="profile-card")):
|
||||
tdiv(class="profile-card-info"):
|
||||
let
|
||||
@@ -46,6 +46,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
else:
|
||||
span: text place
|
||||
|
||||
if info.basedIn.len > 0:
|
||||
tdiv(class="profile-location"):
|
||||
span: icon "location"
|
||||
span: text "Based in " & info.basedIn
|
||||
|
||||
if user.website.len > 0:
|
||||
tdiv(class="profile-website"):
|
||||
span:
|
||||
@@ -54,7 +59,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
a(href=url): text url.shortLink
|
||||
|
||||
tdiv(class="profile-joindate"):
|
||||
span(title=getJoinDateFull(user)):
|
||||
a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
|
||||
icon "calendar", getJoinDate(user)
|
||||
|
||||
tdiv(class="profile-card-extra-links"):
|
||||
@@ -115,7 +120,7 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
||||
if not isGalleryView:
|
||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||
tdiv(class=("profile-tab" & sticky)):
|
||||
renderUserCard(profile.user, prefs)
|
||||
renderUserCard(profile.user, prefs, profile.accountInfo)
|
||||
if profile.photoRail.len > 0:
|
||||
renderPhotoRail(profile)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user