Просмотр исходного кода

Add Articles tab to profile pages

Fixes #1426
Zed 2 недель назад
Родитель
Сommit
8142bab1a9
13 измененных файлов с 103 добавлено и 39 удалено
  1. 1 0
      nitter.example.conf
  2. 7 0
      src/api.nim
  3. 1 0
      src/config.nim
  4. 9 0
      src/consts.nim
  5. 9 5
      src/parser.nim
  6. 5 4
      src/parserutils.nim
  7. 10 0
      src/query.nim
  8. 5 13
      src/routes/rss.nim
  9. 16 9
      src/routes/timeline.nim
  10. 4 2
      src/types.nim
  11. 5 6
      src/views/rss.nimf
  12. 3 0
      src/views/search.nim
  13. 28 0
      tests/test_timeline.py

+ 1 - 0
nitter.example.conf

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

+ 7 - 0
src/api.nim

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

+ 1 - 0
src/config.nim

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

+ 9 - 0
src/consts.nim

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

+ 9 - 5
src/parser.nim

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

+ 5 - 4
src/parserutils.nim

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

+ 10 - 0
src/query.nim

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

+ 5 - 13
src/routes/rss.nim

@@ -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"]
-      let rssEnabled = case @"tab"
-        of "with_replies": cfg.enableRSSUserReplies
-        of "media": cfg.enableRSSUserMedia
-        of "search": cfg.enableRSSSearch
-        else: false
-      if not rssEnabled:
+      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")
+      if not cfg.tabRssEnabled(@"tab"):
         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 =
-          case tab
-          of "with_replies": getReplyQuery(name)
-          of "media": getMediaQuery(name)
-          of "search": initQuery(params(request), name=name)
-          else: Query(fromUser: @[name])
+        query = request.getQuery(tab, name, prefs)
 
 
       let searchKey = if tab != "search": ""
       let searchKey = if tab != "search": ""
                       else: ":" & $hash(genQueryUrl(query))
                       else: ":" & $hash(genQueryUrl(query))

+ 16 - 9
src/routes/timeline.nim

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

+ 4 - 2
src/types.nim

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

+ 5 - 6
src/views/rss.nimf

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

+ 3 - 0
src/views/search.nim

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

+ 28 - 0
tests/test_timeline.py

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