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 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:
|
||||||
|
|
|
||||||
|
|
@ -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}"""
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
import parser/[user, graphql]
|
import parser/[user, graphql, article]
|
||||||
export user, graphql
|
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 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, ""
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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 "timeline";
|
||||||
@import "search";
|
@import "search";
|
||||||
@import "broadcast";
|
@import "broadcast";
|
||||||
|
@import "_article";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
// colors
|
// colors
|
||||||
|
|
|
||||||
|
|
@ -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
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"
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
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