Add Articles tab to profile pages

Fixes #1426
This commit is contained in:
Zed 2026-08-24 22:15:50 +07:00
commit 8142bab1a9
13 changed files with 103 additions and 39 deletions

View file

@ -26,6 +26,7 @@ enableRSS = true # master switch, set to false to disable all RSS fe
enableRSSUserTweets = true # /@user/rss enableRSSUserTweets = true # /@user/rss
enableRSSUserReplies = true # /@user/with_replies/rss enableRSSUserReplies = true # /@user/with_replies/rss
enableRSSUserMedia = true # /@user/media/rss enableRSSUserMedia = true # /@user/media/rss
enableRSSUserArticles = true # /@user/articles/rss
enableRSSSearch = true # /search/rss and /@user/search/rss enableRSSSearch = true # /search/rss and /@user/search/rss
enableRSSList = true # list RSS feeds enableRSSList = true # list RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.sessions) enableDebug = false # enable request logs and debug endpoints (/.sessions)

View file

@ -38,6 +38,12 @@ proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true) oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true)
) )
proc userArticlesUrl(id: string; cursor: string): ApiReq =
result = ApiReq(
cookie: apiUrl(graphUserArticles, userArticlesVars % [id, cursor], userTweetsFieldToggles),
oauth: apiUrl(graphUserArticlesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles)
)
proc tweetDetailUrl(id, cursor: string; mode = Relevance): ApiReq = proc tweetDetailUrl(id, cursor: string; mode = Relevance): ApiReq =
return apiReq(graphTweet, tweetVars % [id, cursor, $mode]) return apiReq(graphTweet, tweetVars % [id, cursor, $mode])
# let cookieVars = tweetDetailVars % [id, cursor] # let cookieVars = tweetDetailVars % [id, cursor]
@ -112,6 +118,7 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi
of TimelineKind.tweets: userTweetsUrl(id, cursor) of TimelineKind.tweets: userTweetsUrl(id, cursor)
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
of TimelineKind.media: mediaUrl(id, cursor) of TimelineKind.media: mediaUrl(id, cursor)
of TimelineKind.articles: userArticlesUrl(id, cursor)
js = await fetch(url) js = await fetch(url)
result = parseGraphTimeline(js, after) result = parseGraphTimeline(js, after)

View file

@ -42,6 +42,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true), enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true),
enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true), enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true),
enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true), enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true),
enableRSSUserArticles: masterRss and cfg.get("Config", "enableRSSUserArticles", true),
enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true), enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true),
enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true), enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true),
enableDebug: cfg.get("Config", "enableDebug", false), enableDebug: cfg.get("Config", "enableDebug", false),

View file

@ -16,6 +16,8 @@ const
graphUserTweetsAndReplies* = "qUpkZU6eN8MbtQb7rC_pYg/UserTweetsAndReplies" graphUserTweetsAndReplies* = "qUpkZU6eN8MbtQb7rC_pYg/UserTweetsAndReplies"
graphUserMedia* = "VyudDWQnr9vJNw7GasFz2g/UserMedia" graphUserMedia* = "VyudDWQnr9vJNw7GasFz2g/UserMedia"
graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2" graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2"
graphUserArticles* = "ZmMjUyrTpwYfTGAdylEyMw/UserArticlesTweets"
graphUserArticlesV2* = "PsGixN38UZz2RheyayNB5Q/UserProfileArticlesTimelineQuery"
graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline" graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline"
graphTweetDetail* = "XMOz5h24KAZ86qKffKTLdQ/TweetDetail" graphTweetDetail* = "XMOz5h24KAZ86qKffKTLdQ/TweetDetail"
graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery" graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery"
@ -148,6 +150,13 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userArticlesVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
articleVars* = """{ articleVars* = """{
"tweetId": "$1", "tweetId": "$1",
"includePromotedContent": false, "includePromotedContent": false,

View file

@ -479,7 +479,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
result.url = getPicUrl(result.image) result.url = getPicUrl(result.image)
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
replyId: int64 = 0): Tweet = replyId: int64 = 0; hasArticle = false): Tweet =
if js.isNull: return Tweet() if js.isNull: return Tweet()
let time = let time =
@ -547,7 +547,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
elif name.len > 0 and jsCard{"binding_values"}.notNull: elif name.len > 0 and jsCard{"binding_values"}.notNull:
result.card = some parseCard(jsCard, js{"entities", "urls"}) result.card = some parseCard(jsCard, js{"entities", "urls"})
result.expandTweetEntities(js) result.expandTweetEntities(js, hasArticle)
parseLegacyMediaEntities(js, result) parseLegacyMediaEntities(js, result)
with jsWithheld, js{"withheld_in_countries"}: with jsWithheld, js{"withheld_in_countries"}:
@ -605,6 +605,8 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
with restId, js{"reply_to_results", "rest_id"}: with restId, js{"reply_to_results", "rest_id"}:
replyId = restId.getId replyId = restId.getId
let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0
if "details" in js: if "details" in js:
result = Tweet( result = Tweet(
id: js{"rest_id"}.getId, id: js{"rest_id"}.getId,
@ -639,7 +641,6 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
if result.attribution.isNone: if result.attribution.isNone:
parseLegacyMediaEntities(js{"legacy"}, result) parseLegacyMediaEntities(js{"legacy"}, result)
let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0
result.expandTweetEntitiesV2(js, hasArticle) result.expandTweetEntitiesV2(js, hasArticle)
# Strip video source URL from text (for videos from other tweets) # Strip video source URL from text (for videos from other tweets)
@ -653,7 +654,7 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
result.text = result.text[0 ..< idx].strip() result.text = result.text[0 ..< idx].strip()
break break
else: else:
result = parseTweet(js{"legacy"}, jsCard, replyId) result = parseTweet(js{"legacy"}, jsCard, replyId, hasArticle)
result.id = js{"rest_id"}.getId result.id = js{"rest_id"}.getId
with artNode, js{"article", "article_results", "result"}: with artNode, js{"article", "article_results", "result"}:
@ -662,7 +663,10 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
result.articlePreview = some ArticlePreview( result.articlePreview = some ArticlePreview(
title: artTitle, title: artTitle,
previewText: artNode{"preview_text"}.getStr, previewText: artNode{"preview_text"}.getStr,
coverImage: artNode{"cover_media_results", "result", "media_info", "original_img_url"}.getImageStr, coverImage: select(
artNode{"cover_media_results", "result", "media_info", "original_img_url"},
artNode{"cover_media", "media_info", "original_img_url"}
).getImageStr,
tweetId: result.id tweetId: result.id
) )

View file

@ -281,7 +281,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
.replacef(htRegex, htReplace) .replacef(htRegex, htReplace)
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int]; proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
replyTo=""; hasRedundantLink=false) = replyTo=""; hasRedundantLink=false; hasArticle=false) =
let hasCard = tweet.card.isSome let hasCard = tweet.card.isSome
var replacements = newSeq[ReplaceSlice]() var replacements = newSeq[ReplaceSlice]()
@ -292,7 +292,8 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
if urlStr.len == 0 or urlStr notin text: if urlStr.len == 0 or urlStr notin text:
continue continue
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink,
hideArticle = hasArticle)
if hasCard and u{"url"}.getStr == get(tweet.card).url: if hasCard and u{"url"}.getStr == get(tweet.card).url:
get(tweet.card).url = u.getExpandedUrl get(tweet.card).url = u.getExpandedUrl
@ -329,7 +330,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false) tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = proc expandTweetEntities*(tweet: Tweet; js: JsonNode; hasArticle=false) =
let let
entities = ? js{"entities"} entities = ? js{"entities"}
textRange = js{"display_text_range"} textRange = js{"display_text_range"}
@ -344,7 +345,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
tweet.reply.add replyTo tweet.reply.add replyTo
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo,
hasQuote or hasJobCard) hasQuote or hasJobCard, hasArticle)
proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
hasRedundantLink=false; hasArticle=false) = hasRedundantLink=false; hasArticle=false) =

View file

@ -29,6 +29,10 @@ proc initQuery*(pms: Table[string, string]; name=""): Query =
minLikes: validateNumber(@"min_faves") minLikes: validateNumber(@"min_faves")
) )
# articles is an internal tab kind, not a valid search filter
if result.kind == QueryKind.articles:
result.kind = tweets
if name.len > 0: if name.len > 0:
result.fromUser = name.split(",") result.fromUser = name.split(",")
@ -40,6 +44,12 @@ proc getMediaQuery*(name: string): Query =
sep: "OR" sep: "OR"
) )
proc getArticlesQuery*(name: string): Query =
Query(
kind: QueryKind.articles,
fromUser: @[name]
)
proc getReplyQuery*(name: string): Query = proc getReplyQuery*(name: string): Query =
Query( Query(
kind: replies, kind: replies,

View file

@ -106,24 +106,16 @@ proc createRssRouter*(cfg: Config) =
get "/@name/@tab/rss": get "/@name/@tab/rss":
cond '.' notin @"name" cond '.' notin @"name"
cond @"tab" in ["with_replies", "media", "search"] cond @"tab" in ["with_replies", "media", "search", "articles"]
let rssEnabled = case @"tab" # articles can't be approximated by search, so multi-user is unsupported
of "with_replies": cfg.enableRSSUserReplies cond not (@"tab" == "articles" and ',' in @"name")
of "media": cfg.enableRSSUserMedia if not cfg.tabRssEnabled(@"tab"):
of "search": cfg.enableRSSSearch
else: false
if not rssEnabled:
resp Http403, showError("RSS feed is disabled", cfg) resp Http403, showError("RSS feed is disabled", cfg)
let let
prefs = requestPrefs() prefs = requestPrefs()
name = @"name" name = @"name"
tab = @"tab" tab = @"tab"
query = query = request.getQuery(tab, name, prefs)
case tab
of "with_replies": getReplyQuery(name)
of "media": getMediaQuery(name)
of "search": initQuery(params(request), name=name)
else: Query(fromUser: @[name])
let searchKey = if tab != "search": "" let searchKey = if tab != "search": ""
else: ":" & $hash(genQueryUrl(query)) else: ":" & $hash(genQueryUrl(query))

View file

@ -12,11 +12,22 @@ export router_utils
export redis_cache, formatters, query, api export redis_cache, formatters, query, api
export profile, timeline, status, about_account export profile, timeline, status, about_account
proc tabRssEnabled*(cfg: Config; tab: string): bool =
case tab
of "": cfg.enableRSSUserTweets
of "with_replies": cfg.enableRSSUserReplies
of "media": cfg.enableRSSUserMedia
of "articles": cfg.enableRSSUserArticles
of "search": cfg.enableRSSSearch
else: false
proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query = proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
let view = request.params.getOrDefault("view") let view = request.params.getOrDefault("view")
case tab case tab
of "with_replies": of "with_replies":
result = getReplyQuery(name) result = getReplyQuery(name)
of "articles":
result = getArticlesQuery(name)
of "media": of "media":
result = getMediaQuery(name) result = getMediaQuery(name)
result.view = result.view =
@ -64,6 +75,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
of media: await getGraphUserTweets(userId, TimelineKind.media, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after)
of QueryKind.articles: await getGraphUserTweets(userId, TimelineKind.articles, after)
else: Profile(tweets: await getGraphTweetSearch(query, after)) else: Profile(tweets: await getGraphTweetSearch(query, after))
result.user = await user result.user = await user
@ -172,7 +184,9 @@ proc createTimelineRouter*(cfg: Config) =
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"]
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','}) cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
cond @"tab" in ["with_replies", "media", "search", ""] cond @"tab" in ["with_replies", "media", "search", "articles", ""]
# articles can't be approximated by search, so multi-user is unsupported
cond not (@"tab" == "articles" and ',' in @"name")
let let
prefs = requestPrefs() prefs = requestPrefs()
after = getCursor() after = getCursor()
@ -196,15 +210,8 @@ proc createTimelineRouter*(cfg: Config) =
profile.tweets.beginning = true profile.tweets.beginning = true
resp $renderTimelineTweets(profile.tweets, prefs, getPath()) resp $renderTimelineTweets(profile.tweets, prefs, getPath())
let rssEnabled =
if @"tab".len == 0: cfg.enableRSSUserTweets
elif @"tab" == "with_replies": cfg.enableRSSUserReplies
elif @"tab" == "media": cfg.enableRSSUserMedia
elif @"tab" == "search": cfg.enableRSSSearch
else: false
let rss = let rss =
if not rssEnabled: if not cfg.tabRssEnabled(@"tab"):
"" ""
elif @"tab".len == 0: elif @"tab".len == 0:
"/$1/rss" % @"name" "/$1/rss" % @"name"

View file

@ -11,7 +11,7 @@ type
BadClientError* = object of CatchableError BadClientError* = object of CatchableError
TimelineKind* {.pure.} = enum TimelineKind* {.pure.} = enum
tweets, replies, media tweets, replies, media, articles
ApiUrl* = object ApiUrl* = object
endpoint*: string endpoint*: string
@ -174,7 +174,8 @@ type
variants*: seq[VideoVariant] variants*: seq[VideoVariant]
QueryKind* = enum QueryKind* = enum
posts, replies, media, users, tweets, userList, followers, following, lists, top posts, replies, media, users, tweets, userList, followers, following, lists, top,
articles
RankingMode* = enum RankingMode* = enum
Relevance, Recency, Likes Relevance, Recency, Likes
@ -431,6 +432,7 @@ type
enableRSSUserTweets*: bool enableRSSUserTweets*: bool
enableRSSUserReplies*: bool enableRSSUserReplies*: bool
enableRSSUserMedia*: bool enableRSSUserMedia*: bool
enableRSSUserArticles*: bool
enableRSSSearch*: bool enableRSSSearch*: bool
enableRSSList*: bool enableRSSList*: bool
enableDebug*: bool enableDebug*: bool

View file

@ -17,6 +17,11 @@
## text = unicode.runeSubStr(text, 0, 32) & "..." ## text = unicode.runeSubStr(text, 0, 32) & "..."
##end if ##end if
#text = xmltree.escape(text) #text = xmltree.escape(text)
## article tweets' text is just the article link; the title says more
#if tweet.articlePreview.isSome and tweet.articlePreview.get().title.len > 0:
# result = prefix & xmltree.escape(tweet.articlePreview.get().title)
# return
#end if
#if text.len > 0: #if text.len > 0:
# result = prefix & text # result = prefix & text
# return # return
@ -34,12 +39,6 @@
# end case # end case
# end if # end if
#end if #end if
#if result.len == 0 and tweet.articlePreview.isSome:
# let art = tweet.articlePreview.get()
# if art.title.len > 0:
# result = prefix & xmltree.escape(art.title)
# end if
#end if
#if result.len == 0 and tweet.card.isSome: #if result.len == 0 and tweet.card.isSome:
# let card = tweet.card.get() # let card = tweet.card.get()
# if card.kind notin {hidden, unknown} and card.title.len > 0: # if card.kind notin {hidden, unknown} and card.title.len > 0:

View file

@ -36,6 +36,9 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
a(href=(link & "/with_replies")): text "Tweets & Replies" a(href=(link & "/with_replies")): text "Tweets & Replies"
li(class=query.getTabClass(media)): li(class=query.getTabClass(media)):
a(href=(link & "/media")): text "Media" a(href=(link & "/media")): text "Media"
if query.fromUser.len == 1:
li(class=query.getTabClass(QueryKind.articles)):
a(href=(link & "/articles")): text "Articles"
li(class=query.getTabClass(tweets)): li(class=query.getTabClass(tweets)):
a(href=(link & "/search")): text "Search" a(href=(link & "/search")): text "Search"

View file

@ -84,3 +84,31 @@ class TweetTest(BaseTestCase):
#for i, url in enumerate(images): #for i, url in enumerate(images):
#img = self.get_attribute(Timeline.photo_rail + f' a:nth-child({i + 1}) img', 'src') #img = self.get_attribute(Timeline.photo_rail + f' a:nth-child({i + 1}) img', 'src')
#self.assertIn(url, img) #self.assertIn(url, img)
class ArticlesTabTest(BaseTestCase):
def test_articles_tab_on_profile(self):
self.open_nitter('satyanadella')
self.assert_element_present('.tab .tab-item a[href="/satyanadella/articles"]')
def test_articles_timeline(self):
self.open_nitter('satyanadella/articles')
self.assert_text('Articles', '.tab .tab-item.active a')
self.assert_element_present('.timeline .article-card')
self.assert_element_present('.timeline .article-card a[href^="/i/article/"]')
def test_articles_card_cover_and_no_raw_link(self):
self.open_nitter('jack/articles')
self.assert_element_present('.timeline .article-card .card-image img')
# the article link tweet text is redundant with the card and is stripped
self.assert_element_absent('.timeline .tweet-content a[href*="x.com/i/article"]')
def test_articles_empty(self):
self.open_nitter('mobile_test/articles')
self.assert_text('No items found', Timeline.none)
def test_articles_multi_user_unsupported(self):
self.open_nitter('jack,satyanadella')
self.assert_element_absent('.tab .tab-item a[href$="/articles"]')
self.open_nitter('jack,satyanadella/articles')
self.assert_text('Page not found')