mirror of
https://github.com/zedeus/nitter
synced 2026-09-05 14:49:32 +00:00
parent
bd9d492d36
commit
35882ed88d
16 changed files with 1192 additions and 24 deletions
25
src/api.nim
25
src/api.nim
|
|
@ -2,7 +2,7 @@
|
|||
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||
import packedjson
|
||||
import types, query, formatters, consts, apiutils, parser, utils
|
||||
import experimental/parser as newParser
|
||||
import experimental/parser
|
||||
|
||||
# Helper to generate params object for GraphQL requests
|
||||
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 =
|
||||
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 =
|
||||
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 =
|
||||
return apiReq(graphTweet, tweetVars % [id, cursor])
|
||||
|
|
@ -228,6 +224,21 @@ proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
|||
let js = await fetch(mediaUrl(id, "", 30))
|
||||
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.} =
|
||||
let client = newAsyncHttpClient(maxRedirects=0)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const
|
|||
graphUser* = "IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName"
|
||||
graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery"
|
||||
graphUserById* = "-DAaa9jPxPswYeI2fZ9rug/UserResultByIdQuery"
|
||||
graphUserTweetsV2* = "PHTSTXqZYuHIeK4B1HQprQ/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsV2* = "LE3eTyeqhBh2g-fX85O2eQ/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserTweets* = "PNd0vlufvrcIwrAnBYKE9g/UserTweets"
|
||||
graphUserTweetsAndReplies* = "EqtpEwt0CoQXmDfq5DKH0A/UserTweetsAndReplies"
|
||||
|
|
@ -28,6 +28,9 @@ const
|
|||
graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
|
||||
graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery"
|
||||
|
||||
graphTweetResultByRestId* = "qtXMy1p5Y62uCskc_NUPJw/TweetResultByRestId"
|
||||
graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds"
|
||||
|
||||
graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery"
|
||||
restLiveStream* = "1.1/live_video_stream/status/"
|
||||
|
||||
|
|
@ -131,6 +134,24 @@ const
|
|||
"withVoice": true
|
||||
}""".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}"""
|
||||
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
|
||||
userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}"""
|
||||
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
import parser/[user, graphql]
|
||||
export user, graphql
|
||||
import parser/[user, graphql, article]
|
||||
export user, graphql, article
|
||||
|
|
|
|||
87
src/experimental/parser/article.nim
Normal file
87
src/experimental/parser/article.nim
Normal 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
|
||||
78
src/experimental/types/article.nim
Normal file
78
src/experimental/types/article.nim
Normal 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"
|
||||
|
|
@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
|
|||
import views/[general, about]
|
||||
import routes/[
|
||||
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 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.flushFile
|
||||
|
||||
createArticleRouter(cfg)
|
||||
createUnsupportedRouter(cfg)
|
||||
createResolverRouter(cfg)
|
||||
createPrefRouter(cfg)
|
||||
|
|
@ -118,6 +119,7 @@ routes:
|
|||
resp Http429, showError(
|
||||
&"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg)
|
||||
|
||||
extend articleRoute, ""
|
||||
extend rss, ""
|
||||
extend status, ""
|
||||
extend search, ""
|
||||
|
|
|
|||
|
|
@ -542,7 +542,8 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
|
|||
if result.attribution.isNone:
|
||||
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)
|
||||
with mediaEntities, js{"media_entities"}:
|
||||
|
|
@ -558,6 +559,16 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
|
|||
result = parseTweet(js{"legacy"}, jsCard, replyId)
|
||||
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"})
|
||||
|
||||
if result.reply.len == 0:
|
||||
|
|
@ -627,6 +638,16 @@ proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
|||
with tweet, js{"data", "tweet_result", "result"}:
|
||||
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 =
|
||||
result = Conversation(replies: Result[Chain](beginning: true))
|
||||
|
||||
|
|
|
|||
|
|
@ -185,12 +185,16 @@ proc extractSlice(js: JsonNode): Slice[int] =
|
|||
result = js["indices"][0].getInt ..< js["indices"][1].getInt
|
||||
|
||||
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
||||
textLen: int; hideTwitter = false) =
|
||||
textLen: int; hideTwitter = false;
|
||||
hideArticle = false) =
|
||||
let
|
||||
url = js.getExpandedUrl
|
||||
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:
|
||||
result.add ReplaceSlice(kind: rkRemove, slice: slice)
|
||||
else:
|
||||
|
|
@ -343,7 +347,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
|||
hasQuote or hasJobCard)
|
||||
|
||||
proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
|
||||
hasRedundantLink=false) =
|
||||
hasRedundantLink=false; hasArticle=false) =
|
||||
let hasCard = tweet.card.isSome
|
||||
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
|
||||
proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) =
|
||||
proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode; hasArticle=false) =
|
||||
let
|
||||
textRange = js{"details", "display_text_range"}
|
||||
textSlice = textRange{0}.getInt .. textRange{1}.getInt
|
||||
|
|
@ -394,7 +399,8 @@ proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) =
|
|||
hasAttribution = tweet.attribution.isSome
|
||||
|
||||
tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
|
||||
hasQuote or hasJobCard or hasAttribution)
|
||||
hasQuote or hasJobCard or hasAttribution,
|
||||
hasArticle)
|
||||
|
||||
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
let
|
||||
|
|
|
|||
48
src/routes/article.nim
Normal file
48
src/routes/article.nim
Normal 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
272
src/sass/_article.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
@import "timeline";
|
||||
@import "search";
|
||||
@import "broadcast";
|
||||
@import "_article";
|
||||
|
||||
body {
|
||||
// colors
|
||||
|
|
|
|||
|
|
@ -198,6 +198,43 @@ type
|
|||
|
||||
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
|
||||
options*: seq[string]
|
||||
values*: seq[int]
|
||||
|
|
@ -247,6 +284,12 @@ type
|
|||
likes*: int
|
||||
views*: int
|
||||
|
||||
ArticlePreview* = object
|
||||
title*: string
|
||||
previewText*: string
|
||||
coverImage*: string
|
||||
tweetId*: int64
|
||||
|
||||
Tweet* = ref object
|
||||
id*: int64
|
||||
threadId*: int64
|
||||
|
|
@ -275,6 +318,7 @@ type
|
|||
note*: string
|
||||
isAd*: bool
|
||||
isAI*: bool
|
||||
articlePreview*: Option[ArticlePreview]
|
||||
|
||||
Tweets* = seq[Tweet]
|
||||
|
||||
|
|
|
|||
246
src/views/article.nim
Normal file
246
src/views/article.nim
Normal 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()
|
||||
|
|
@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||
|
||||
buildHtml(head):
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=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")
|
||||
|
||||
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;
|
||||
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")):
|
||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,24 @@ import general
|
|||
|
||||
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"))
|
||||
|
||||
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 =
|
||||
buildHtml(tdiv):
|
||||
if pinned:
|
||||
|
|
@ -225,7 +240,7 @@ func formatStat(stat: int): string =
|
|||
if stat > 0: insertSep($stat, ',')
|
||||
else: ""
|
||||
|
||||
proc renderStats(stats: TweetStats): VNode =
|
||||
proc renderStats*(stats: TweetStats): VNode =
|
||||
buildHtml(tdiv(class="tweet-stats")):
|
||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||||
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:
|
||||
renderQuoteMedia(quote, prefs, path)
|
||||
|
||||
if quote.articlePreview.isSome:
|
||||
renderArticleCard(quote.articlePreview.get(), prefs)
|
||||
|
||||
if quote.note.len > 0 and not prefs.hideCommunityNotes:
|
||||
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:
|
||||
renderCard(tweet.card.get(), prefs, path)
|
||||
|
||||
if tweet.articlePreview.isSome:
|
||||
renderArticleCard(tweet.articlePreview.get(), prefs)
|
||||
|
||||
if tweet.media.len > 0:
|
||||
renderMedia(tweet.media, prefs, path, bigThumb)
|
||||
|
||||
|
|
|
|||
307
tests/test_article.py
Normal file
307
tests/test_article.py
Normal 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')
|
||||
Loading…
Reference in a new issue