Add Twitter Article rendering

Fixes #646
This commit is contained in:
Zed 2026-06-16 14:37:23 +02:00
commit 35882ed88d
16 changed files with 1192 additions and 24 deletions

View file

@ -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"]) return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles)
# result = ApiReq(
# cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
# oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
# )
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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
src/routes/article.nim Normal file
View file

@ -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
src/sass/_article.scss Normal file
View file

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

View file

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

View file

@ -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
src/views/article.nim Normal file
View file

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

View file

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

View file

@ -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
tests/test_article.py Normal file
View file

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