فهرست منبع

Add Twitter Article rendering

Fixes #646
Zed 2 ماه پیش
والد
کامیت
35882ed88d

+ 18 - 7
src/api.nim

@@ -2,7 +2,7 @@
 import asyncdispatch, httpclient, strutils, sequtils, sugar
 import asyncdispatch, httpclient, strutils, sequtils, sugar
 import packedjson
 import packedjson
 import types, query, formatters, consts, apiutils, parser, utils
 import types, query, formatters, consts, apiutils, parser, utils
-import experimental/parser as newParser
+import experimental/parser
 
 
 # Helper to generate params object for GraphQL requests
 # Helper to generate params object for GraphQL requests
 proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
 proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
@@ -25,14 +25,10 @@ proc mediaUrl(id, cursor: string; count=20): ApiReq =
   )
   )
 
 
 proc userTweetsUrl(id: string; cursor: string): ApiReq =
 proc userTweetsUrl(id: string; cursor: string): ApiReq =
-  return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
-  # result = ApiReq(
-  #   cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
-  #   oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
-  # )
+  return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles)
 
 
 proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
 proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
-  return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], skipTid=true)
+  return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true)
 
 
 proc tweetDetailUrl(id: string; cursor: string): ApiReq =
 proc tweetDetailUrl(id: string; cursor: string): ApiReq =
   return apiReq(graphTweet, tweetVars % [id, cursor])
   return apiReq(graphTweet, tweetVars % [id, cursor])
@@ -228,6 +224,21 @@ proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
   let js = await fetch(mediaUrl(id, "", 30))
   let js = await fetch(mediaUrl(id, "", 30))
   result = parseGraphPhotoRail(js)
   result = parseGraphPhotoRail(js)
 
 
+proc getGraphArticle*(id: string): Future[Article] {.async.} =
+  if id.len == 0: return
+  let
+    url = apiReq(graphTweetResultByRestId, articleVars % id, articleFieldToggles)
+    json = await fetchRaw(url)
+  result = parseGraphArticle(json)
+
+proc getGraphTweetResults*(ids: seq[string]): Future[seq[Tweet]] {.async.} =
+  if ids.len == 0: return
+  let
+    idsJson = "[" & ids.mapIt("\"" & it & "\"").join(",") & "]"
+    url = apiReq(graphTweetResultsByRestIds, articleBatchVars % idsJson, articleFieldToggles)
+    js = await fetch(url)
+  result = parseGraphTweetResults(js)
+
 proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
 proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
   let client = newAsyncHttpClient(maxRedirects=0)
   let client = newAsyncHttpClient(maxRedirects=0)
   try:
   try:

+ 23 - 2
src/consts.nim

@@ -10,7 +10,7 @@ const
   graphUser* = "IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName"
   graphUser* = "IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName"
   graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery"
   graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery"
   graphUserById* = "-DAaa9jPxPswYeI2fZ9rug/UserResultByIdQuery"
   graphUserById* = "-DAaa9jPxPswYeI2fZ9rug/UserResultByIdQuery"
-  graphUserTweetsV2* = "PHTSTXqZYuHIeK4B1HQprQ/UserWithProfileTweetsQueryV2"
+  graphUserTweetsV2* = "LE3eTyeqhBh2g-fX85O2eQ/UserWithProfileTweetsQueryV2"
   graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2"
   graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2"
   graphUserTweets* = "PNd0vlufvrcIwrAnBYKE9g/UserTweets"
   graphUserTweets* = "PNd0vlufvrcIwrAnBYKE9g/UserTweets"
   graphUserTweetsAndReplies* = "EqtpEwt0CoQXmDfq5DKH0A/UserTweetsAndReplies"
   graphUserTweetsAndReplies* = "EqtpEwt0CoQXmDfq5DKH0A/UserTweetsAndReplies"
@@ -28,6 +28,9 @@ const
   graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
   graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
   graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery"
   graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery"
 
 
+  graphTweetResultByRestId* = "qtXMy1p5Y62uCskc_NUPJw/TweetResultByRestId"
+  graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds"
+
   graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery"
   graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery"
   restLiveStream* = "1.1/live_video_stream/status/"
   restLiveStream* = "1.1/live_video_stream/status/"
 
 
@@ -131,6 +134,24 @@ const
   "withVoice": true
   "withVoice": true
 }""".replace(" ", "").replace("\n", "")
 }""".replace(" ", "").replace("\n", "")
 
 
+  articleVars* = """{
+  "tweetId": "$1",
+  "includePromotedContent": false,
+  "withBirdwatchNotes": true,
+  "withVoice": true,
+  "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+  articleBatchVars* = """{
+  "tweetIds": $1,
+  "includePromotedContent": false,
+  "withBirdwatchNotes": true,
+  "withVoice": true,
+  "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+  articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}"""
+
   userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
   userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
-  userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
+  userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}"""
   tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
   tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""

+ 2 - 2
src/experimental/parser.nim

@@ -1,2 +1,2 @@
-import parser/[user, graphql]
-export user, graphql
+import parser/[user, graphql, article]
+export user, graphql, article

+ 87 - 0
src/experimental/parser/article.nim

@@ -0,0 +1,87 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import std/[strutils, tables, times, options]
+import jsony
+import utils, graphql, ../types/article
+from ../../types import Article, ArticleParagraph, ArticleEntity, ArticleMedia,
+                        User, TweetStats
+
+proc parseGraphArticle*(json: string): Article =
+  if json.len == 0 or json[0] != '{':
+    return
+
+  var raw: GraphArticle
+  try:
+    raw = json.fromJson(GraphArticle)
+  except CatchableError:
+    return
+
+  let
+    tweet = raw.data.tweetResult.result
+    article = tweet.article.articleResults.result
+
+  if article.title.len == 0:
+    return
+
+  let publishedAt = article.metadata.firstPublishedAtSecs
+  var articleTime: DateTime
+  if publishedAt > 0:
+    articleTime = publishedAt.int64.fromUnix.utc
+  elif tweet.legacy.createdAt.len > 0:
+    articleTime = parseTwitterDate(tweet.legacy.createdAt)
+
+  result = Article(
+    title: article.title,
+    coverImage: getImageUrl(article.coverMedia.mediaInfo.originalImgUrl),
+    time: articleTime,
+    user: parseUserResult(tweet.core.userResults.result),
+  )
+
+  result.stats = TweetStats(
+    replies: tweet.legacy.replyCount,
+    retweets: tweet.legacy.retweetCount,
+    likes: tweet.legacy.favoriteCount,
+  )
+  if tweet.views.count.len > 0:
+    try: result.stats.views = parseInt(tweet.views.count)
+    except ValueError: discard
+
+  for blk in article.contentState.blocks:
+    result.paragraphs.add ArticleParagraph(
+      text: blk.text,
+      kind: blk.blockKind,
+      inlineStyles: blk.inlineStyleRanges,
+      entityRanges: blk.entityRanges,
+    )
+
+  for entry in article.contentState.entityMap:
+    let key = try: parseInt(entry.key) except ValueError: continue
+    var entity = ArticleEntity(kind: entry.value.entityKind)
+    case entity.kind
+    of "LINK": entity.url = entry.value.data.url
+    of "MEDIA":
+      for mi in entry.value.data.mediaItems:
+        entity.mediaIds.add mi.mediaId
+    of "TWEET": entity.tweetId = entry.value.data.tweetId
+    of "MARKDOWN": entity.markdown = entry.value.data.markdown
+    else: discard
+    result.entities[key] = entity
+
+  for me in article.mediaEntities:
+    let typeName = me.mediaInfo.typeName
+    var media = ArticleMedia(kind: typeName)
+    if me.mediaInfo.videoInfo.isSome:
+      let variants = me.mediaInfo.videoInfo.get.variants
+      case typeName
+      of "ApiGif":
+        if variants.len > 0:
+          media.url = variants[0].url
+      of "ApiVideo":
+        var bestBitrate = -1
+        for v in variants:
+          if v.bitrate > bestBitrate:
+            bestBitrate = v.bitrate
+            media.url = v.url
+      else: discard
+    elif typeName == "ApiImage":
+      media.url = getImageUrl(me.mediaInfo.originalImgUrl)
+    result.media[me.mediaId] = media

+ 78 - 0
src/experimental/types/article.nim

@@ -0,0 +1,78 @@
+import std/options
+import graphuser
+from ../../types import ArticleStyle, ArticleEntityRange
+
+type
+  GraphArticle* = object
+    data*: tuple[tweetResult: tuple[result: TweetResultNode]]
+
+  TweetResultNode* = object
+    article*: tuple[articleResults: tuple[result: ArticleResultNode]]
+    legacy*: TweetLegacy
+    core*: tuple[userResults: UserData]
+    views*: tuple[count: string]
+
+  TweetLegacy* = object
+    createdAt*: string
+    replyCount*: int
+    retweetCount*: int
+    favoriteCount*: int
+
+  ArticleResultNode* = object
+    title*: string
+    coverMedia*: tuple[mediaInfo: MediaInfoNode]
+    contentState*: ContentState
+    metadata*: tuple[firstPublishedAtSecs: int]
+    mediaEntities*: seq[RawMediaEntity]
+
+  ContentState* = object
+    blocks*: seq[ContentBlock]
+    entityMap*: seq[EntityMapEntry]
+
+  ContentBlock* = object
+    text*: string
+    blockKind*: string
+    inlineStyleRanges*: seq[ArticleStyle]
+    entityRanges*: seq[ArticleEntityRange]
+
+  EntityMapEntry* = object
+    key*: string
+    value*: EntityMapValue
+
+  EntityMapValue* = object
+    entityKind*: string
+    data*: EntityDataNode
+
+  EntityDataNode* = object
+    url*: string
+    mediaItems*: seq[tuple[mediaId: string]]
+    tweetId*: string
+    markdown*: string
+
+  RawMediaEntity* = object
+    mediaId*: string
+    mediaInfo*: MediaInfoNode
+
+  MediaInfoNode* = object
+    typeName*: string
+    originalImgUrl*: string
+    videoInfo*: Option[VideoInfoNode]
+
+  VideoInfoNode* = object
+    variants*: seq[VideoVariant]
+
+  VideoVariant* = object
+    url*: string
+    bitrate*: int
+
+proc renameHook*(v: var ContentBlock; fieldName: var string) =
+  if fieldName == "type":
+    fieldName = "blockKind"
+
+proc renameHook*(v: var EntityMapValue; fieldName: var string) =
+  if fieldName == "type":
+    fieldName = "entityKind"
+
+proc renameHook*(v: var MediaInfoNode; fieldName: var string) =
+  if fieldName == "__typename":
+    fieldName = "typeName"

+ 3 - 1
src/nitter.nim

@@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
 import views/[general, about]
 import views/[general, about]
 import routes/[
 import routes/[
   preferences, timeline, status, media, search, rss, list, debug,
   preferences, timeline, status, media, search, rss, list, debug,
-  unsupported, embed, resolver, broadcast, router_utils]
+  unsupported, embed, resolver, broadcast, article, router_utils]
 
 
 const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
 const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
 const issuesUrl = "https://github.com/zedeus/nitter/issues"
 const issuesUrl = "https://github.com/zedeus/nitter/issues"
@@ -48,6 +48,7 @@ waitFor initRedisPool(cfg)
 stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
 stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
 stdout.flushFile
 stdout.flushFile
 
 
+createArticleRouter(cfg)
 createUnsupportedRouter(cfg)
 createUnsupportedRouter(cfg)
 createResolverRouter(cfg)
 createResolverRouter(cfg)
 createPrefRouter(cfg)
 createPrefRouter(cfg)
@@ -118,6 +119,7 @@ routes:
     resp Http429, showError(
     resp Http429, showError(
       &"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg)
       &"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg)
 
 
+  extend articleRoute, ""
   extend rss, ""
   extend rss, ""
   extend status, ""
   extend status, ""
   extend search, ""
   extend search, ""

+ 22 - 1
src/parser.nim

@@ -542,7 +542,8 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
     if result.attribution.isNone:
     if result.attribution.isNone:
       parseLegacyMediaEntities(js{"legacy"}, result)
       parseLegacyMediaEntities(js{"legacy"}, result)
 
 
-    result.expandTweetEntitiesV2(js)
+    let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0
+    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)
     with mediaEntities, js{"media_entities"}:
     with mediaEntities, js{"media_entities"}:
@@ -558,6 +559,16 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
     result = parseTweet(js{"legacy"}, jsCard, replyId)
     result = parseTweet(js{"legacy"}, jsCard, replyId)
     result.id = js{"rest_id"}.getId
     result.id = js{"rest_id"}.getId
 
 
+  with artNode, js{"article", "article_results", "result"}:
+    let artTitle = artNode{"title"}.getStr
+    if artTitle.len > 0:
+      result.articlePreview = some ArticlePreview(
+        title: artTitle,
+        previewText: artNode{"preview_text"}.getStr,
+        coverImage: artNode{"cover_media_results", "result", "media_info", "original_img_url"}.getImageStr,
+        tweetId: result.id
+      )
+
   result.user = parseGraphUser(js{"core"})
   result.user = parseGraphUser(js{"core"})
 
 
   if result.reply.len == 0:
   if result.reply.len == 0:
@@ -627,6 +638,16 @@ proc parseGraphTweetResult*(js: JsonNode): Tweet =
   with tweet, js{"data", "tweet_result", "result"}:
   with tweet, js{"data", "tweet_result", "result"}:
     result = parseGraphTweet(tweet)
     result = parseGraphTweet(tweet)
 
 
+proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] =
+  let results = js{"data", "tweetResult"}
+  if results.kind != JArray: return
+  for item in results:
+    let tweet = item{"result"}
+    if tweet.isNull: continue
+    let t = parseGraphTweet(tweet)
+    if t != nil:
+      result.add t
+
 proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
 proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
   result = Conversation(replies: Result[Chain](beginning: true))
   result = Conversation(replies: Result[Chain](beginning: true))
 
 

+ 12 - 6
src/parserutils.nim

@@ -185,12 +185,16 @@ proc extractSlice(js: JsonNode): Slice[int] =
   result = js["indices"][0].getInt ..< js["indices"][1].getInt
   result = js["indices"][0].getInt ..< js["indices"][1].getInt
 
 
 proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
 proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
-                 textLen: int; hideTwitter = false) =
+                 textLen: int; hideTwitter = false;
+                 hideArticle = false) =
   let
   let
     url = js.getExpandedUrl
     url = js.getExpandedUrl
     slice = js.extractSlice
     slice = js.extractSlice
 
 
-  if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
+  if hideArticle and url.isTwitterUrl and "/article/" in url:
+    if slice.a < textLen:
+      result.add ReplaceSlice(kind: rkRemove, slice: slice)
+  elif hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
     if slice.a < textLen:
     if slice.a < textLen:
       result.add ReplaceSlice(kind: rkRemove, slice: slice)
       result.add ReplaceSlice(kind: rkRemove, slice: slice)
   else:
   else:
@@ -343,7 +347,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
                            hasQuote or hasJobCard)
                            hasQuote or hasJobCard)
 
 
 proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
 proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
-                          hasRedundantLink=false) =
+                          hasRedundantLink=false; hasArticle=false) =
   let hasCard = tweet.card.isSome
   let hasCard = tweet.card.isSome
 
 
   var replacements = newSeq[ReplaceSlice]()
   var replacements = newSeq[ReplaceSlice]()
@@ -354,7 +358,8 @@ proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: S
       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
@@ -385,7 +390,7 @@ proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: S
 
 
   tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
   tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
 
 
-proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) =
+proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode; hasArticle=false) =
   let
   let
     textRange = js{"details", "display_text_range"}
     textRange = js{"details", "display_text_range"}
     textSlice = textRange{0}.getInt .. textRange{1}.getInt
     textSlice = textRange{0}.getInt .. textRange{1}.getInt
@@ -394,7 +399,8 @@ proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) =
     hasAttribution = tweet.attribution.isSome
     hasAttribution = tweet.attribution.isSome
 
 
   tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
   tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
-                             hasQuote or hasJobCard or hasAttribution)
+                             hasQuote or hasJobCard or hasAttribution,
+                             hasArticle)
 
 
 proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
 proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
   let
   let

+ 48 - 0
src/routes/article.nim

@@ -0,0 +1,48 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import asyncdispatch, tables, strutils
+import jester, karax/vdom
+import ".."/[types, api]
+import ../views/[article, general]
+import router_utils
+
+export api, article, vdom, general, router_utils
+
+proc createArticleRouter*(cfg: Config) =
+  router articleRoute:
+    get "/i/article/@id":
+      cond @"id".allCharsInSet(Digits)
+
+      let article = await getGraphArticle(@"id")
+      if article == nil:
+        resp Http404, showError("Article not found", cfg)
+
+      var tweetIds: seq[string]
+      for e in article.entities.values:
+        if e.kind == "TWEET":
+          tweetIds.add e.tweetId
+
+      var tweets = initTable[int64, Tweet]()
+      if tweetIds.len > 0:
+        try:
+          for t in await getGraphTweetResults(tweetIds):
+            tweets[t.id] = t
+        except CatchableError:
+          discard
+
+      let
+        prefs = requestPrefs()
+        path = getPath()
+        html = renderArticle(article, tweets, path, prefs, @"id")
+        twitterUrl = "https://x.com/" & article.user.username & "/article/" & @"id"
+      resp renderMain(html, request, cfg, prefs, titleText=article.title,
+                      twitterLink=twitterUrl)
+
+    get "/@name/article/@id/?":
+      cond '.' notin @"name"
+      cond @"id".allCharsInSet(Digits)
+      redirect("/i/article/" & @"id")
+
+    get "/@name/status/@id/article":
+      cond '.' notin @"name"
+      cond @"id".allCharsInSet(Digits)
+      redirect("/i/article/" & @"id")

+ 272 - 0
src/sass/_article.scss

@@ -0,0 +1,272 @@
+.article-page {
+  max-width: 700px;
+  margin: 0 auto 20px;
+  background-color: var(--bg_panel);
+
+  > .top-ref {
+    padding-top: 20px;
+  }
+
+  .article-cover {
+    width: 100%;
+    display: block;
+  }
+
+  .article-body {
+    padding: 20px;
+
+    > :last-child {
+      margin-bottom: 0;
+    }
+
+    .article-title {
+      display: block;
+      font-size: 2rem;
+      line-height: 1.3;
+      margin: 0 0 10px;
+      color: var(--fg_color);
+    }
+
+    .article-author {
+      margin-bottom: 12px;
+      padding-bottom: 10px;
+      border-bottom: 1px solid var(--border_grey);
+      font-size: 14px;
+
+      .article-author-row {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+      }
+
+      .article-avatar {
+        display: flex;
+      }
+
+      .avatar {
+        width: 40px;
+        height: 40px;
+      }
+
+      .article-author-name {
+        display: flex;
+        align-items: center;
+        margin-bottom: 2px;
+
+        .fullname {
+          font-size: 15px;
+        }
+
+        .verified-icon {
+          margin-left: 2px;
+        }
+      }
+
+      .article-author-meta {
+        display: flex;
+        align-items: center;
+      }
+
+      .fullname {
+        font-weight: 700;
+        color: var(--fg_color);
+        max-width: unset;
+        text-overflow: unset;
+        overflow: visible;
+        white-space: normal;
+      }
+
+      .username,
+      .article-date-sep,
+      .article-date {
+        color: var(--fg_dark);
+      }
+
+      .username {
+        margin-left: 0;
+      }
+
+      .article-date-sep {
+        margin: 0 4px;
+      }
+
+      .article-date {
+        text-decoration: none;
+
+        &:hover {
+          text-decoration: underline;
+        }
+      }
+
+      .tweet-stats {
+        margin-top: 6px;
+
+        .tweet-stat {
+          padding-top: 0;
+        }
+      }
+    }
+
+    > h1 {
+      display: block;
+      font-size: 1.8rem;
+      margin: 25px 0 15px;
+    }
+
+    > h2 {
+      font-size: 1.4rem;
+      font-weight: bold;
+      margin: 20px 0 12px;
+    }
+
+    > h3 {
+      font-size: 1.2rem;
+      font-weight: bold;
+      margin: 18px 0 10px;
+    }
+
+    > p {
+      font-size: 16px;
+      line-height: 1.7;
+      margin: 16px 0;
+      word-wrap: break-word;
+    }
+
+    .blockquote-attribution {
+      display: block;
+      margin-top: 0.5em;
+    }
+
+    > blockquote {
+      border-left: 3px solid var(--accent);
+      padding-left: 16px;
+      margin: 16px 0;
+      color: var(--fg_faded);
+      font-size: 16px;
+      line-height: 1.7;
+    }
+
+    > pre {
+      background-color: var(--bg_elements);
+      padding: 12px 16px;
+      border-radius: 6px;
+      overflow-x: auto;
+      margin: 16px 0;
+
+      code {
+        font-family: monospace;
+        font-size: 14px;
+        color: var(--fg_color);
+      }
+    }
+
+    code {
+      background-color: var(--bg_elements);
+      padding: 2px 5px;
+      border-radius: 3px;
+      font-family: monospace;
+      font-size: 0.9em;
+    }
+
+    > ul,
+    > ol {
+      margin: 16px 0;
+      padding-left: 2em;
+
+      li {
+        font-size: 16px;
+        line-height: 1.7;
+        margin: 6px 0;
+      }
+    }
+
+    .article-media {
+      text-align: center;
+      margin: 20px 0;
+
+      img,
+      video {
+        max-width: 100%;
+        border-radius: 12px;
+      }
+    }
+
+    > a,
+    > p a,
+    > h1 a,
+    > h2 a,
+    > h3 a,
+    > blockquote a,
+    > ul a,
+    > ol a {
+      color: var(--accent);
+      text-decoration: none;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+
+    .article-divider {
+      border: none;
+      border-top: 1px solid var(--border_grey);
+      margin: 30px 0;
+    }
+
+    .timeline-item {
+      margin: 20px 0;
+      border: 1px solid var(--border_grey);
+      border-radius: 12px;
+      overflow: hidden;
+    }
+  }
+}
+
+.conversation .article-page {
+  max-width: 100%;
+  margin-bottom: 0;
+}
+
+.article-card {
+  .card-image-container {
+    position: relative;
+  }
+
+  .card-image img {
+    height: auto;
+  }
+
+  .article-card-badge {
+    position: absolute;
+    bottom: 8px;
+    left: 8px;
+    background: rgba(0, 0, 0, 0.75);
+    color: #fff;
+    font-size: 13px;
+    font-weight: 700;
+    padding: 2px 8px;
+    border-radius: 4px;
+  }
+}
+
+.quote .article-card {
+  margin: 0;
+
+  .card-container {
+    border: none;
+    border-radius: 0;
+    border-top: solid 1px var(--dark_grey);
+  }
+}
+
+@media (max-width: 700px) {
+  .article-page {
+    .article-body {
+      padding: 12px 15px 25px;
+
+      .article-title {
+        font-size: 1.6rem;
+      }
+    }
+  }
+}

+ 1 - 0
src/sass/index.scss

@@ -8,6 +8,7 @@
 @import "timeline";
 @import "timeline";
 @import "search";
 @import "search";
 @import "broadcast";
 @import "broadcast";
+@import "_article";
 
 
 body {
 body {
   // colors
   // colors

+ 44 - 0
src/types.nim

@@ -198,6 +198,43 @@ type
 
 
   PhotoRail* = seq[GalleryPhoto]
   PhotoRail* = seq[GalleryPhoto]
 
 
+  Article* = ref object
+    title*: string
+    coverImage*: string
+    user*: User
+    time*: DateTime
+    stats*: TweetStats
+    paragraphs*: seq[ArticleParagraph]
+    entities*: Table[int, ArticleEntity]
+    media*: Table[string, ArticleMedia]
+
+  ArticleParagraph* = object
+    text*: string
+    kind*: string
+    inlineStyles*: seq[ArticleStyle]
+    entityRanges*: seq[ArticleEntityRange]
+
+  ArticleStyle* = object
+    offset*: int
+    length*: int
+    style*: string
+
+  ArticleEntityRange* = object
+    offset*: int
+    length*: int
+    key*: int
+
+  ArticleEntity* = object
+    kind*: string
+    url*: string
+    mediaIds*: seq[string]
+    tweetId*: string
+    markdown*: string
+
+  ArticleMedia* = object
+    kind*: string
+    url*: string
+
   Poll* = object
   Poll* = object
     options*: seq[string]
     options*: seq[string]
     values*: seq[int]
     values*: seq[int]
@@ -247,6 +284,12 @@ type
     likes*: int
     likes*: int
     views*: int
     views*: int
 
 
+  ArticlePreview* = object
+    title*: string
+    previewText*: string
+    coverImage*: string
+    tweetId*: int64
+
   Tweet* = ref object
   Tweet* = ref object
     id*: int64
     id*: int64
     threadId*: int64
     threadId*: int64
@@ -275,6 +318,7 @@ type
     note*: string
     note*: string
     isAd*: bool
     isAd*: bool
     isAI*: bool
     isAI*: bool
+    articlePreview*: Option[ArticlePreview]
 
 
   Tweets* = seq[Tweet]
   Tweets* = seq[Tweet]
 
 

+ 246 - 0
src/views/article.nim

@@ -0,0 +1,246 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, strformat, tables, unicode, bitops, uri
+import karax/[karaxdsl, vdom]
+
+import renderutils, tweet, timeline
+import ".."/[types, utils, formatters]
+
+proc renderAtomicParagraph(paragraph: ArticleParagraph; article: Article;
+                           tweets: Table[int64, Tweet]; path: string;
+                           prefs: Prefs): VNode =
+  if paragraph.entityRanges.len == 0:
+    return text ""
+
+  let er = paragraph.entityRanges[0]
+  if er.key notin article.entities:
+    return text ""
+
+  let entity = article.entities[er.key]
+
+  case entity.kind
+  of "MEDIA":
+    buildHtml(tdiv(class="article-media")):
+      for id in entity.mediaIds:
+        let media = article.media.getOrDefault(id)
+        if media.url.len == 0:
+          continue
+        case media.kind
+        of "ApiGif":
+          video(src=getVidUrl(media.url), controls="", autoplay="", loop="",
+                muted="")
+        of "ApiVideo":
+          video(src=getVidUrl(media.url), controls="")
+        else:
+          a(href=getOrigPicUrl(media.url), target="_blank"):
+            img(src=getSmallPic(media.url), alt="", loading="lazy")
+  of "TWEET":
+    let tweet = tweets.getOrDefault(
+      try: parseBiggestInt(entity.tweetId)
+      except ValueError: 0, nil)
+    if tweet != nil:
+      renderTweet(tweet, prefs, path)
+    else:
+      text ""
+  of "MARKDOWN":
+    var content = entity.markdown
+    if content.startsWith("```"):
+      let firstNl = content.find('\n')
+      if firstNl >= 0: content = content[firstNl + 1 .. ^1]
+      if content.endsWith("```"): content = content[0 .. ^4]
+      content = content.strip
+    buildHtml(pre()):
+      code(): text content
+  of "DIVIDER":
+    buildHtml(hr(class="article-divider"))
+  else:
+    text ""
+
+proc wrapStyle(node: VNode; style: int): VNode =
+  result = node
+  if style.testBit(4): result = buildHtml(code()): result
+  if style.testBit(0): result = buildHtml(strong()): result
+  if style.testBit(1): result = buildHtml(em()): result
+  if style.testBit(2): result = buildHtml(del()): result
+  if style.testBit(3): result = buildHtml(underlined()): result
+
+proc addContent(target: VNode; content: string; style = 0) =
+  var first = true
+  for line in content.split('\n'):
+    if not first:
+      target.add VNode(kind: VNodeKind.br)
+    first = false
+    var pos = 0
+    while pos < line.len:
+      let atPos = line.find('@', pos)
+      if atPos == -1:
+        target.add wrapStyle(text line[pos .. ^1], style)
+        break
+      if atPos > 0 and line[atPos - 1] in Letters + Digits + {'_'}:
+        target.add wrapStyle(text line[pos .. atPos], style)
+        pos = atPos + 1
+        continue
+      var j = atPos + 1
+      while j < line.len and j - atPos - 1 < 15 and
+            line[j] in Letters + Digits + {'_'}:
+        inc j
+      if j == atPos + 1:
+        target.add wrapStyle(text line[pos .. atPos], style)
+        pos = atPos + 1
+        continue
+      if atPos > pos:
+        target.add wrapStyle(text line[pos ..< atPos], style)
+      let username = line[atPos + 1 ..< j]
+      let link = a.newVNode()
+      link.setAttr("href", "/" & username)
+      link.add wrapStyle(text ("@" & username), style)
+      target.add link
+      pos = j
+
+proc applyInlineStyles(target: VNode; runes: seq[Rune]; start, length: int;
+                       styles: seq[ArticleStyle]) =
+  if styles.len == 0:
+    target.addContent($runes[start ..< start + length])
+    return
+
+  var
+    lastStyle = 0
+    lastStart = start
+  let endPos = start + length
+
+  for i in start ..< endPos:
+    var style = 0
+    for sr in styles:
+      let
+        sStart = sr.offset
+        sEnd = sStart + sr.length
+      if sStart <= i and sEnd > i:
+        case sr.style
+        of "Bold": style.setBit(0)
+        of "Italic": style.setBit(1)
+        of "Strikethrough": style.setBit(2)
+        of "Underline": style.setBit(3)
+        of "Code": style.setBit(4)
+        else: discard
+
+    if style != lastStyle:
+      if i > lastStart:
+        addContent(target, $runes[lastStart ..< i], lastStyle)
+      lastStyle = style
+      lastStart = i
+
+  if lastStart < endPos:
+    addContent(target, $runes[lastStart ..< endPos], lastStyle)
+
+proc renderTextParagraph(paragraph: ArticleParagraph; article: Article): VNode =
+  let text = paragraph.text
+
+  result = case paragraph.kind
+    of "header-one": h1.newVNode()
+    of "header-two": h2.newVNode()
+    of "header-three": h3.newVNode()
+    of "ordered-list-item", "unordered-list-item": li.newVNode()
+    of "blockquote": VNode(kind: VNodeKind.blockquote)
+    of "code-block":
+      let pre = pre.newVNode()
+      let code = code.newVNode()
+      code.add text text
+      pre.add code
+      return pre
+    else: p.newVNode()
+
+  let
+    runes = text.toRunes
+    textLen = runes.len
+  var last = 0
+  for er in paragraph.entityRanges:
+    if er.offset > last:
+      applyInlineStyles(result, runes, last, er.offset - last,
+                        paragraph.inlineStyles)
+
+    last = er.offset + er.length
+
+    var target = result
+    if er.key in article.entities:
+      let entity = article.entities[er.key]
+      if entity.kind == "LINK":
+        let parsed = parseUri(entity.url)
+        if parsed.scheme in ["http", "https"]:
+          target = a.newVNode()
+          if parsed.isTwitterUrl:
+            target.setAttr("href", parsed.path)
+          else:
+            target.setAttr("href", entity.url)
+
+    applyInlineStyles(target, runes, er.offset, er.length,
+                      paragraph.inlineStyles)
+    if target != result:
+      result.add target
+
+  if last < textLen:
+    applyInlineStyles(result, runes, last, textLen - last,
+                      paragraph.inlineStyles)
+
+  if paragraph.kind == "blockquote" and result.len > 0:
+    let lastChild = result[result.len - 1]
+    if lastChild.kind == VNodeKind.strong and lastChild.len > 0 and
+       lastChild[0].kind == VNodeKind.text:
+      lastChild.setAttr("class", "blockquote-attribution")
+
+proc renderArticle*(article: Article; tweets: Table[int64, Tweet];
+                    path: string; prefs: Prefs; tweetId=""): VNode =
+  let author = article.user
+
+  let main = buildHtml(article(class="article-body")):
+    h1(class="article-title"): text article.title
+
+    tdiv(class="article-author"):
+      tdiv(class="article-author-row"):
+        a(class="article-avatar", href=("/" & author.username)):
+          genImg(author.getUserPic("_bigger"), class=prefs.getAvatarClass)
+        tdiv(class="article-author-info"):
+          tdiv(class="article-author-name"):
+            linkUser(author, class="fullname")
+            verifiedIcon(author)
+          tdiv(class="article-author-meta"):
+            linkUser(author, class="username")
+            span(class="article-date-sep"): text " · "
+            a(class="article-date",
+              href=("/" & author.username & "/status/" & tweetId)):
+              text article.time.getShortTime
+      if not prefs.hideTweetStats:
+        renderStats(article.stats)
+
+  var listKind = ""
+  var list: VNode = nil
+
+  for paragraph in article.paragraphs:
+    let isListItem = paragraph.kind in [
+      "ordered-list-item", "unordered-list-item"]
+
+    if not isListItem and list != nil:
+      main.add list
+      list = nil
+      listKind = ""
+
+    if paragraph.kind == "atomic":
+      main.add renderAtomicParagraph(paragraph, article, tweets, path, prefs)
+    elif isListItem:
+      if paragraph.kind != listKind:
+        if list != nil:
+          main.add list
+        list = if paragraph.kind == "ordered-list-item": ol.newVNode()
+               else: ul.newVNode()
+        listKind = paragraph.kind
+      list.add renderTextParagraph(paragraph, article)
+    else:
+      main.add renderTextParagraph(paragraph, article)
+
+  if list != nil:
+    main.add list
+
+  buildHtml(tdiv(class="article-page")):
+    if article.coverImage.len > 0:
+      a(href=getOrigPicUrl(article.coverImage), target="_blank"):
+        img(class="article-cover", src=getSmallPic(article.coverImage), alt="")
+    main
+    renderToTop()

+ 6 - 3
src/views/general.nim

@@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
   let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
   let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
 
 
   buildHtml(head):
   buildHtml(head):
-    link(rel="stylesheet", type="text/css", href="/css/style.css?v=35")
+    link(rel="stylesheet", type="text/css", href="/css/style.css?v=38")
     link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
     link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
 
 
     if theme.len > 0:
     if theme.len > 0:
@@ -122,9 +122,12 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
 
 
 proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
 proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
                  titleText=""; desc=""; ogTitle=""; rss=""; video="";
                  titleText=""; desc=""; ogTitle=""; rss=""; video="";
-                 images: seq[string] = @[]; banner=""): string =
+                 images: seq[string] = @[]; banner="";
+                 twitterLink=""): string =
 
 
-  let twitterLink = getTwitterLink(req.path, req.params)
+  let twitterLink =
+    if twitterLink.len > 0: twitterLink
+    else: getTwitterLink(req.path, req.params)
 
 
   let node = buildHtml(html(lang="en")):
   let node = buildHtml(html(lang="en")):
     renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
     renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,

+ 23 - 2
src/views/tweet.nim

@@ -9,9 +9,24 @@ import general
 
 
 const doctype = "<!DOCTYPE html>\n"
 const doctype = "<!DOCTYPE html>\n"
 
 
-proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
+proc renderMiniAvatar*(user: User; prefs: Prefs): VNode =
   genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini"))
   genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini"))
 
 
+proc renderArticleCard(preview: ArticlePreview; prefs: Prefs): VNode =
+  let url = "/i/article/" & $preview.tweetId
+  buildHtml(tdiv(class="article-card card large")):
+    a(class="card-container", href=url):
+      if preview.coverImage.len > 0:
+        tdiv(class="card-image-container"):
+          tdiv(class="card-image"):
+            genImg(preview.coverImage)
+          span(class="article-card-badge"): text "Article"
+      tdiv(class="card-content-container"):
+        tdiv(class="card-content"):
+          h2(class="card-title"): text preview.title
+          if preview.previewText.len > 0:
+            p(class="card-description"): text preview.previewText
+
 proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
 proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
   buildHtml(tdiv):
   buildHtml(tdiv):
     if pinned:
     if pinned:
@@ -225,7 +240,7 @@ func formatStat(stat: int): string =
   if stat > 0: insertSep($stat, ',')
   if stat > 0: insertSep($stat, ',')
   else: ""
   else: ""
 
 
-proc renderStats(stats: TweetStats): VNode =
+proc renderStats*(stats: TweetStats): VNode =
   buildHtml(tdiv(class="tweet-stats")):
   buildHtml(tdiv(class="tweet-stats")):
     span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
     span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
     span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
     span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
@@ -308,6 +323,9 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
     if quote.media.len > 0:
     if quote.media.len > 0:
       renderQuoteMedia(quote, prefs, path)
       renderQuoteMedia(quote, prefs, path)
 
 
+    if quote.articlePreview.isSome:
+      renderArticleCard(quote.articlePreview.get(), prefs)
+
     if quote.note.len > 0 and not prefs.hideCommunityNotes:
     if quote.note.len > 0 and not prefs.hideCommunityNotes:
       renderCommunityNote(quote.note, prefs)
       renderCommunityNote(quote.note, prefs)
 
 
@@ -392,6 +410,9 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
       if tweet.card.isSome and tweet.card.get().kind != hidden:
       if tweet.card.isSome and tweet.card.get().kind != hidden:
         renderCard(tweet.card.get(), prefs, path)
         renderCard(tweet.card.get(), prefs, path)
 
 
+      if tweet.articlePreview.isSome:
+        renderArticleCard(tweet.articlePreview.get(), prefs)
+
       if tweet.media.len > 0:
       if tweet.media.len > 0:
         renderMedia(tweet.media, prefs, path, bigThumb)
         renderMedia(tweet.media, prefs, path, bigThumb)
 
 

+ 307 - 0
tests/test_article.py

@@ -0,0 +1,307 @@
+from base import BaseTestCase
+from parameterized import parameterized
+
+
+class ArticleSelectors:
+    page = '.article-page'
+    cover = '.article-cover'
+    body = '.article-body'
+    title = '.article-title'
+    author = '.article-author'
+    fullname = '.article-author .fullname'
+    username = '.article-author .username'
+    date = '.article-author .article-date'
+    avatar = '.article-author img.avatar'
+    verified = '.article-author .verified-icon'
+    media = '.article-media'
+    divider = '.article-divider'
+
+
+articles = [
+    ['2064166507438059759',
+     '1s,秒杀一切,开源一个 X 文章发布 Skill【重磅升级】',
+     'punk2898', 'Punk'],
+
+    ['2064689664213041529',
+     'SpaceX Thesis & Valuation Memorandum',
+     'Dialectic_Group', 'Dialectic'],
+
+    ['2064691088636424322',
+     'Consciousness and AI: The Problem of Inner Experience',
+     'CosmicOrFun', 'Cosmic Orphan'],
+
+    ['2064696491948777658',
+     'NC Push for Data Centers + Stablecoin Crypto= Data Centers are defacto BAILOUT OF Fed Reserve System',
+     'June_12_1776', 'June_12_1776'],
+
+    ['2064755789391110154',
+     'DeFi Markets Update 2026-06-10',
+     'SteakhouseFi', 'Steakhouse Financial'],
+
+    ['2064755231901319527',
+     'The machine economy has a killswitch and somebody just pulled it.',
+     '1914ad', 'Justin Bechler HMP-028'],
+
+    ['2062858677149675788',
+     'Yakshinis',
+     'CosmicOrFun', 'Cosmic Orphan'],
+]
+
+articles_with_media = [
+    ['2064166507438059759', 6],
+    ['2064689664213041529', 11],
+    ['2064755789391110154', 5],
+]
+
+articles_with_dividers = [
+    ['2064166507438059759', 1],
+    ['2064689664213041529', 6],
+]
+
+
+class ArticleBasicTest(BaseTestCase):
+    @parameterized.expand(articles)
+    def test_article_loads(self, tweet_id, title, username, fullname):
+        self.open_nitter(f'i/article/{tweet_id}')
+        self.assert_element_visible(ArticleSelectors.page)
+        self.assert_element_visible(ArticleSelectors.body)
+        self.assert_text(title, ArticleSelectors.title)
+
+    @parameterized.expand(articles)
+    def test_article_author(self, tweet_id, title, username, fullname):
+        self.open_nitter(f'i/article/{tweet_id}')
+        self.assert_element_visible(ArticleSelectors.author)
+        self.assert_text(f'@{username}', ArticleSelectors.username)
+
+    @parameterized.expand(articles)
+    def test_article_has_cover(self, tweet_id, title, username, fullname):
+        self.open_nitter(f'i/article/{tweet_id}')
+        self.assert_element_visible(ArticleSelectors.cover)
+        src = self.get_attribute(ArticleSelectors.cover, 'src')
+        self.assertIn('/pic/', src)
+
+    @parameterized.expand(articles)
+    def test_article_has_date(self, tweet_id, title, username, fullname):
+        self.open_nitter(f'i/article/{tweet_id}')
+        date_text = self.get_text(ArticleSelectors.date)
+        self.assertTrue(len(date_text) > 3)
+
+    @parameterized.expand(articles)
+    def test_article_author_avatar(self, tweet_id, title, username, fullname):
+        self.open_nitter(f'i/article/{tweet_id}')
+        self.assert_element_visible(ArticleSelectors.avatar)
+        src = self.get_attribute(ArticleSelectors.avatar, 'src')
+        self.assertIn('/pic/', src)
+        self.assertGreater(len(src), len('/pic/'))
+
+    @parameterized.expand(articles)
+    def test_article_author_verified(self, tweet_id, title, username, fullname):
+        self.open_nitter(f'i/article/{tweet_id}')
+        self.assert_element_visible(ArticleSelectors.verified)
+
+    def test_article_author_verified_business(self):
+        self.open_nitter('i/article/2064755789391110154')
+        self.assert_element_visible('.article-author .verified-icon.business')
+
+
+class ArticleContentTest(BaseTestCase):
+    def test_article_has_paragraphs(self):
+        self.open_nitter('i/article/2064689664213041529')
+        paragraphs = self.find_elements('.article-body p')
+        self.assertGreater(len(paragraphs), 10)
+
+    def test_article_has_headers(self):
+        self.open_nitter('i/article/2064689664213041529')
+        headers = self.find_elements('.article-body h1, .article-body h2')
+        self.assertGreater(len(headers), 5)
+
+    def test_article_has_bold_text(self):
+        self.open_nitter('i/article/2064166507438059759')
+        bold = self.find_elements('.article-body strong')
+        self.assertGreater(len(bold), 0)
+
+    def test_article_has_italic_text(self):
+        self.open_nitter('i/article/2064166507438059759')
+        italic = self.find_elements('.article-body em')
+        self.assertGreater(len(italic), 0)
+
+    def test_article_has_blockquotes(self):
+        self.open_nitter('i/article/2064696491948777658')
+        self.assert_element_visible('.article-body blockquote')
+
+    def test_article_has_lists(self):
+        self.open_nitter('i/article/2064696491948777658')
+        self.assert_element_visible('.article-body ul')
+
+    def test_article_has_emoji_text(self):
+        self.open_nitter('i/article/2064166507438059759')
+        body = self.get_text(ArticleSelectors.body)
+        self.assertTrue(any(ord(c) > 0x1F000 for c in body))
+
+    def test_article_has_links(self):
+        self.open_nitter('i/article/2064691088636424322')
+        links = self.find_elements('.article-body a[href]')
+        self.assertGreater(len(links), 0)
+
+    def test_article_twitter_links_localized(self):
+        self.open_nitter('i/article/2064755789391110154')
+        links = self.find_elements('.article-body a[href^="https://x.com"]')
+        self.assertEqual(len(links), 0, 'x.com links should be converted to local paths')
+
+    @parameterized.expand(articles_with_media)
+    def test_article_media_count(self, tweet_id, expected_count):
+        self.open_nitter(f'i/article/{tweet_id}')
+        media = self.find_elements(ArticleSelectors.media)
+        self.assertEqual(len(media), expected_count)
+
+    @parameterized.expand(articles_with_dividers)
+    def test_article_divider_count(self, tweet_id, expected_count):
+        self.open_nitter(f'i/article/{tweet_id}')
+        dividers = self.find_elements(ArticleSelectors.divider)
+        self.assertEqual(len(dividers), expected_count)
+
+
+class ArticleMediaTest(BaseTestCase):
+    def test_media_images_proxied(self):
+        self.open_nitter('i/article/2064689664213041529')
+        self.assert_element_visible(ArticleSelectors.media)
+        img = self.find_element(f'{ArticleSelectors.media} img')
+        src = img.get_attribute('src')
+        self.assertIn('/pic/', src)
+        self.assertFalse(src.startswith('https://pbs.twimg.com'))
+
+    def test_cover_image_proxied(self):
+        self.open_nitter('i/article/2064689664213041529')
+        self.assert_element_visible(ArticleSelectors.cover)
+        src = self.get_attribute(ArticleSelectors.cover, 'src')
+        self.assertIn('/pic/', src)
+        self.assertFalse(src.startswith('https://pbs.twimg.com'))
+
+    def test_embedded_tweet(self):
+        self.open_nitter('i/article/2064755789391110154')
+        self.assert_element_visible('.article-body .timeline-item')
+
+    def test_multiple_embedded_tweets(self):
+        self.open_nitter('i/article/2064755231901319527')
+        tweets = self.find_elements('.article-body .timeline-item')
+        self.assertGreaterEqual(len(tweets), 3)
+
+
+class ArticleMentionTest(BaseTestCase):
+    def test_mention_linkified(self):
+        self.open_nitter('i/article/2064755231901319527')
+        link = self.find_element('.article-body a[href="/ZachXBT"]')
+        self.assertEqual(link.text, '@ZachXBT')
+
+    def test_multiple_mentions_linkified(self):
+        self.open_nitter('i/article/2064755231901319527')
+        links = self.find_elements('.article-body a[href^="/"]')
+        mention_hrefs = [l.get_attribute('href') for l in links
+                         if l.text.startswith('@')]
+        usernames = [h.split('/')[-1] for h in mention_hrefs]
+        self.assertIn('ZachXBT', usernames)
+        self.assertIn('River', usernames)
+
+    def test_mention_in_different_article(self):
+        self.open_nitter('i/article/2064689664213041529')
+        link = self.find_element('.article-body a[href="/FutureJurvetson"]')
+        self.assertEqual(link.text, '@FutureJurvetson')
+
+    def test_no_spurious_whitespace_in_styled_paragraph(self):
+        """Styled paragraphs should not have extra whitespace from VNode serialization."""
+        self.open_nitter('i/article/2064696491948777658')
+        source = self.get_page_source()
+        self.assertNotIn('white-space: pre-wrap', source)
+        self.assertNotIn('white-space:pre-wrap', source)
+
+
+class ArticleCardTest(BaseTestCase):
+    @parameterized.expand(articles)
+    def test_status_page_shows_article_card(self, tweet_id, title, username, fullname):
+        self.open_nitter(f'{username}/status/{tweet_id}')
+        self.assert_element_visible('.article-card')
+        self.assert_text(title, '.article-card .card-title')
+
+    def test_article_card_has_cover_image(self):
+        self.open_nitter('Dialectic_Group/status/2064689664213041529')
+        self.assert_element_visible('.article-card .card-image img')
+        src = self.get_attribute('.article-card .card-image img', 'src')
+        self.assertIn('/pic/', src)
+
+    def test_article_card_has_badge(self):
+        self.open_nitter('Dialectic_Group/status/2064689664213041529')
+        self.assert_element_visible('.article-card-badge')
+        self.assert_text('Article', '.article-card-badge')
+
+    def test_article_card_has_preview_text(self):
+        self.open_nitter('CosmicOrFun/status/2064691088636424322')
+        self.assert_element_visible('.article-card .card-description')
+
+    def test_article_card_links_to_article(self):
+        self.open_nitter('punk2898/status/2064166507438059759')
+        href = self.get_attribute('.article-card .card-container', 'href')
+        self.assertIn('/article/', href)
+
+    def test_article_url_stripped_from_tweet_text(self):
+        self.open_nitter('punk2898/status/2064166507438059759')
+        self.assert_element_visible('.article-card')
+        source = self.get_page_source()
+        # Main tweet text should not contain article URL
+        import re
+        main = re.search(r'id="m".*?tweet-content[^>]*>(.*?)</div>', source, re.DOTALL)
+        self.assertIsNotNone(main)
+        self.assertNotIn('/article/', main.group(1))
+
+
+class ArticleQuotedCardTest(BaseTestCase):
+    """Article cards inside quoted tweets (1914ad quoting own article)."""
+    quoted_tweet = '1914ad/status/2064789532071891085'
+    quoted_article_id = '2063677483548102688'
+
+    def test_quoted_card_visible(self):
+        self.open_nitter(self.quoted_tweet)
+        self.assert_element_visible('.quote .article-card')
+
+    def test_quoted_card_has_title(self):
+        self.open_nitter(self.quoted_tweet)
+        self.assert_text('David Bailey Already Won', '.quote .article-card .card-title')
+
+    def test_quoted_card_has_badge(self):
+        self.open_nitter(self.quoted_tweet)
+        self.assert_element_visible('.quote .article-card-badge')
+        self.assert_text('Article', '.quote .article-card-badge')
+
+    def test_quoted_card_has_cover_image(self):
+        self.open_nitter(self.quoted_tweet)
+        self.assert_element_visible('.quote .article-card .card-image img')
+        src = self.get_attribute('.quote .article-card .card-image img', 'src')
+        self.assertIn('/pic/', src)
+
+    def test_quoted_card_has_description(self):
+        self.open_nitter(self.quoted_tweet)
+        self.assert_element_visible('.quote .article-card .card-description')
+
+    def test_quoted_card_links_to_article(self):
+        self.open_nitter(self.quoted_tweet)
+        href = self.get_attribute('.quote .article-card .card-container', 'href')
+        self.assertIn(f'/article/{self.quoted_article_id}', href)
+
+
+class ArticleRoutingTest(BaseTestCase):
+    def test_username_article_route_redirects(self):
+        self.open_nitter('punk2898/article/2064166507438059759')
+        self.assert_element_visible(ArticleSelectors.page)
+        self.assert_text('1s', ArticleSelectors.title)
+
+    def test_status_article_route_redirects(self):
+        self.open_nitter('punk2898/status/2064166507438059759/article')
+        self.assert_element_visible(ArticleSelectors.page)
+        self.assert_text('1s', ArticleSelectors.title)
+
+    def test_invalid_id_returns_404(self):
+        self.open_nitter('i/article/notanumber')
+        self.assert_element_not_visible(ArticleSelectors.page)
+
+    def test_nonexistent_article(self):
+        self.open_nitter('i/article/1')
+        self.assert_element_visible('.error-panel')