Compare commits

...

44 commits

Author SHA1 Message Date
Gabriel Simmer ab48e58851
Add readOnly mode
readOnly mode prevents Nitter from reaching out to Twitter when
something isn't found in the cache, allowing one to safely expose a
Nitter depoyment to the internet without getting accounts
flagged. This relies on a second, private Nitter instance to do the
actual loading/caching of data, which should *not* be exposed to the
wider internet. As long as both instances use the same Redis instance,
everything will just work.
2024-03-26 09:24:00 +00:00
PrivacyDev cb0d360516 Disabling token expiration 2024-02-21 22:05:50 -05:00
PrivacyDev 7d846ed759 Merge remote-tracking branch 'upstream/guest_accounts' 2023-12-07 11:53:06 -05:00
Zed a9740fec8b Fix compilation with old Nim again 2023-11-25 10:11:57 +00:00
Zed f8254c2f0f Add support for business and gov verification
Also improve icon rendering on Firefox
2023-11-25 10:07:28 +00:00
Zed d6be08d093 Fix jobDetails error on old Nim versions 2023-11-25 05:53:13 +00:00
Zed 4dac9f0798 Add simple job_details card support 2023-11-25 05:31:15 +00:00
Zed 06ab1ea2e7 Enable disabled tests 2023-11-15 11:11:56 +00:00
Zed c2819dab44 Fix #1106
Closes #831
2023-11-15 11:11:53 +00:00
Zed eaedd2aee7 Fix ARM64 Dockerfile versions 2023-11-08 16:38:43 +00:00
PrivacyDev e4e7fe5f00 Merge remote-tracking branch 'upstream/guest_accounts' 2023-09-15 17:03:51 -04:00
PrivacyDev b313bb0e72 Merge remote-tracking branch 'upstream/guest_accounts' 2023-09-01 17:44:44 -04:00
PrivacyDev f290b7b5e7 Merge remote-tracking branch 'upstream/guest_accounts' 2023-08-20 17:13:23 -04:00
PrivacyDev 813a71e4d3 fixed build errors 2023-07-22 11:48:49 -04:00
PrivacyDev b2beabf6cd Merge remote-tracking branch 'upstream/master' 2023-07-22 09:30:11 -04:00
PrivacyDev 41787a9451 fixed build errors 2023-07-21 18:56:13 -04:00
PrivacyDevel b2cc63cd99
Merge branch 'zedeus:master' into master 2023-07-21 22:38:31 +00:00
PrivacyDev b67dc67b22 removed .github/workflows/build-docker.yml 2023-07-13 23:24:09 -04:00
PrivacyDev f15a72e89d fixed empty tweet author headers 2023-07-13 23:22:02 -04:00
PrivacyDev 8bcab11109 Merge remote-tracking branch 'upstream/master' 2023-07-13 21:28:33 -04:00
PrivacyDev 0f3203e903 fixed bug that caused some retweets to be rendered as truncated tweets starting with the text "RT @" 2023-06-17 23:06:20 -04:00
Zed efdedd3619 Add proper tombstone for subscriber tweets 2023-06-17 21:28:57 -04:00
PrivacyDev 6bd21d6f0a turned user tweets and likes stats into hyperlinks 2023-06-14 17:34:15 -04:00
PrivacyDev ced0599e89 updated docker-compose to use fork image from ghcr.io 2023-06-14 15:35:28 -04:00
PrivacyDevel 719a08169d
Merge pull request #9 from yingziwu/master
Add the github action to build and publish docker image
2023-06-14 19:07:19 +00:00
bgme 71fab89046 update build-publish-docker.yml 2023-06-07 17:50:13 +08:00
bgme 4bdad64060 add build-publish-docker.yml 2023-06-07 17:34:22 +08:00
PrivacyDev 25b788428b fixed compiler error by using a variable for a case statement 2023-06-06 07:05:02 -04:00
PrivacyDev 2ce3ee6d84 added feature to view who a user follows or is followed by (won't compile because of a compiler bug) 2023-06-05 22:38:17 -04:00
PrivacyDev 1150a59e38 added missing Api.favorites to getPoolJson 2023-06-05 19:48:25 -04:00
PrivacyDev 7a89401f04 turned quote stat in tweet-stat into a clickable link to the quotes 2023-06-05 19:47:10 -04:00
PrivacyDev ba9a4714e2 added favoriters and retweeters links to tweet-stats 2023-06-04 23:31:07 -04:00
PrivacyDev e4eea3d2df added favoriters and retweeters endpoints 2023-06-02 23:47:05 -04:00
PrivacyDev 208c39db87 fixed bug that caused everybody to be displayed as verified 2023-05-30 12:02:22 -04:00
PrivacyDevel 7753e44d36
Merge pull request #2 from PrivacyDevel/graphql
Graphql
2023-05-26 21:34:07 +00:00
PrivacyDev 1634ffdf43 fixed bug that caused threads on user profiles to be hidden 2023-05-26 17:23:40 -04:00
PrivacyDev 12f2e16c81 Merge branch 'master' of https://github.com/zedeus/nitter into graphql 2023-04-21 17:43:18 -04:00
Zed 892caaf796 Prevent search endpoint from discarding tokens 2023-04-21 14:17:50 -04:00
PrivacyDev e6e30baa43 raise a RateLimitError when Twitter returns HTTP status 429 2023-04-18 22:19:38 -04:00
PrivacyDev 11279e2b4f added authentication headers to user search for nsfw users 2023-04-16 02:05:45 -04:00
PrivacyDev 6875569bf2 stopped using Twitter session info for userID requests 2023-04-09 17:32:57 -04:00
PrivacyDev d5689f2253 added login-based workaround to view NSFW content 2023-04-08 10:33:49 -04:00
PrivacyDev a6dd229444 fixed token issue that broke all pages besides the favorites / likes timeline 2023-04-05 01:14:30 -04:00
PrivacyDev 7d2a558e89 added favorites endpoint and added likes tab to profile pages 2023-04-04 23:55:01 -04:00
38 changed files with 642 additions and 228 deletions

View file

@ -1,62 +0,0 @@
name: Docker
on:
push:
paths-ignore:
- "README.md"
branches:
- master
jobs:
tests:
uses: ./.github/workflows/run-tests.yml
build-docker-amd64:
needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push AMD64 Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
build-docker-arm64:
needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204-arm
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push ARM64 Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile.arm64
platforms: linux/arm64
push: true
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64

View file

@ -0,0 +1,60 @@
name: Build and Publish Docker
on:
push:
branches: ["master"]
paths-ignore: ["README.md"]
pull_request:
branches: ["master"]
paths-ignore: ["README.md"]
env:
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
with:
platforms: arm64
- name: Setup Docker buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Log in to GHCR
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/${{ env.IMAGE_NAME }}
- name: Build and push all platforms Docker image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -1,7 +1,7 @@
FROM alpine:3.18 as nim
LABEL maintainer="setenforce@protonmail.com"
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.16-r0" "nimble=0.13.1-r3"
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.14-r0" "nimble=0.13.1-r2"
WORKDIR /src/nitter

View file

@ -3,7 +3,7 @@ version: "3"
services:
nitter:
image: zedeus/nitter:latest
image: ghcr.io/privacydevel/nitter:master
container_name: nitter
ports:
- "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy

View file

@ -6,6 +6,7 @@ port = 8080
https = false # disable to enable cookies when not using https
httpMaxConnections = 100
staticDir = "./public"
readOnly = false # setting to true prevents nitter from reaching out to twitter and will only use redis data
[Cache]
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
@ -33,6 +34,9 @@ tokenCount = 10
# always at least `tokenCount` usable tokens. only increase this if you receive
# major bursts all the time and don't have a rate limiting setup via e.g. nginx
#cookieHeader = "ct0=XXXXXXXXXXXXXXXXX; auth_token=XXXXXXXXXXXXXX" # authentication cookie of a logged in account, required for the likes tab and NSFW content
#xCsrfToken = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # required for the likes tab and NSFW content
# Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences]
theme = "Nitter"

View file

@ -69,6 +69,13 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
proc getFavorites*(id: string; cfg: Config; after=""): Future[Profile] {.async.} =
if id.len == 0: return
let
ps = genParams({"userId": id}, after)
url = consts.favorites / (id & ".json") ? ps
result = parseTimeline(await fetch(url, Api.favorites), after)
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return
let
@ -86,6 +93,42 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
js = await fetch(graphTweet ? params, Api.tweetDetail)
result = parseGraphConversation(js, id)
proc getGraphFavoriters*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = reactorsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFavoriters ? params, Api.favoriters)
result = parseGraphFavoritersTimeline(js, id)
proc getGraphRetweeters*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = reactorsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphRetweeters ? params, Api.retweeters)
result = parseGraphRetweetersTimeline(js, id)
proc getGraphFollowing*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = followVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFollowing ? params, Api.following)
result = parseGraphFollowTimeline(js, id)
proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = followVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFollowers ? params, Api.followers)
result = parseGraphFollowTimeline(js, id)
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
result = (await getGraphTweet(id, after)).replies
result.beginning = after.len == 0

View file

@ -3,6 +3,7 @@ import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
import jsony, packedjson, zippy, oauth1
import types, auth, consts, parserutils, http_pool
import experimental/types/common
import config
const
rlRemaining = "x-rate-limit-remaining"
@ -61,7 +62,14 @@ proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders =
"DNT": "1"
})
template fetchImpl(result, fetchBody) {.dirty.} =
template updateAccount() =
if resp.headers.hasKey(rlRemaining):
let
remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset])
account.setRateLimit(api, remaining, reset)
template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
once:
pool = HttpPool()
@ -72,13 +80,19 @@ template fetchImpl(result, fetchBody) {.dirty.} =
try:
var resp: AsyncResponse
pool.use(genHeaders($url, account.oauthToken, account.oauthSecret)):
var headers = genHeaders($url, account.oauthToken, account.oauthSecret)
for key, value in additional_headers.pairs():
headers.add(key, value)
pool.use(headers):
template getContent =
resp = await c.get($url)
result = await resp.body
getContent()
if resp.status == $Http429:
raise rateLimitError()
if resp.status == $Http503:
badClient = true
raise newException(BadClientError, "Bad client")
@ -133,10 +147,16 @@ template retry(bod) =
echo "[accounts] Rate limited, retrying ", api, " request..."
bod
proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
if cfg.readOnly: return
if len(cfg.cookieHeader) != 0:
additional_headers.add("Cookie", cfg.cookieHeader)
if len(cfg.xCsrfToken) != 0:
additional_headers.add("x-csrf-token", cfg.xCsrfToken)
retry:
var body: string
fetchImpl body:
fetchImpl(body, additional_headers):
if body.startsWith('{') or body.startsWith('['):
result = parseJson(body)
else:
@ -149,9 +169,10 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
invalidate(account)
raise rateLimitError()
proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} =
proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
if cfg.readOnly: return
retry:
fetchImpl result:
fetchImpl(result, additional_headers):
if not (result.startsWith('{') or result.startsWith('[')):
echo resp.status, ": ", result, " --- url: ", url
result.setLen(0)

View file

@ -202,7 +202,7 @@ proc initAccountPool*(cfg: Config; path: string) =
quit 1
let accountsPrePurge = accountPool.len
accountPool.keepItIf(not it.hasExpired)
#accountPool.keepItIf(not it.hasExpired)
log "Successfully added ", accountPool.len, " valid accounts."
if accountsPrePurge > accountPool.len:

View file

@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
import parsecfg except Config
import types, strutils
from os import getEnv
proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
let val = config.getSectionValue(section, key)
@ -22,6 +23,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
staticDir: cfg.get("Server", "staticDir", "./public"),
title: cfg.get("Server", "title", "Nitter"),
hostname: cfg.get("Server", "hostname", "nitter.net"),
readOnly: cfg.get("Server", "readOnly", false),
# Cache
listCacheTime: cfg.get("Cache", "listMinutes", 120),
@ -40,7 +42,13 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
enableRss: cfg.get("Config", "enableRSS", true),
enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""),
proxyAuth: cfg.get("Config", "proxyAuth", "")
proxyAuth: cfg.get("Config", "proxyAuth", ""),
cookieHeader: cfg.get("Config", "cookieHeader", ""),
xCsrfToken: cfg.get("Config", "xCsrfToken", "")
)
return (conf, cfg)
let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
let (cfg*, fullCfg*) = getConfig(configPath)

View file

@ -10,6 +10,9 @@ const
photoRail* = api / "1.1/statuses/media_timeline.json"
timelineApi = api / "2/timeline"
favorites* = timelineApi / "favorites"
graphql = api / "graphql"
graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
@ -23,18 +26,20 @@ const
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters"
graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters"
graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers"
graphFollowing* = graphql / "JPZiqKjET7_M1r5Tlr8pyA/Following"
timelineParams* = {
"include_can_media_tag": "1",
"include_cards": "1",
"include_entities": "1",
"include_profile_interstitial_type": "0",
"include_quote_count": "1",
"include_reply_count": "1",
"include_user_entities": "1",
"include_ext_reply_count": "1",
"include_ext_is_blue_verified": "1",
# "include_ext_verified_type": "1",
"include_quote_count": "0",
"include_reply_count": "0",
"include_user_entities": "0",
"include_ext_reply_count": "0",
"include_ext_media_color": "0",
"cards_platform": "Web-13",
"tweet_mode": "extended",
@ -116,3 +121,15 @@ const
"rest_id": "$1", $2
"count": 20
}"""
reactorsVariables* = """{
"tweetId" : "$1", $2
"count" : 20,
"includePromotedContent": false
}"""
followVariables* = """{
"userId" : "$1", $2
"count" : 20,
"includePromotedContent": false
}"""

View file

@ -1,7 +1,7 @@
import options
import jsony
import user, ../types/[graphuser, graphlistmembers]
from ../../types import User, Result, Query, QueryKind
from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{':
@ -12,9 +12,10 @@ proc parseGraphUser*(json: string): User =
if raw.data.userResult.result.unavailableReason.get("") == "Suspended":
return User(suspended: true)
result = toUser raw.data.userResult.result.legacy
result = raw.data.userResult.result.legacy
result.id = raw.data.userResult.result.restId
result.verified = result.verified or raw.data.userResult.result.isBlueVerified
if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified:
result.verifiedType = blue
proc parseGraphListMembers*(json, cursor: string): Result[User] =
result = Result[User](
@ -30,7 +31,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
of TimelineTimelineItem:
let userResult = entry.content.itemContent.userResults.result
if userResult.restId.len > 0:
result.content.add toUser userResult.legacy
result.content.add userResult.legacy
of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom":
result.bottom = entry.content.value

View file

@ -1,6 +1,6 @@
import std/[options, tables, strutils, strformat, sugar]
import jsony
import ../types/unifiedcard
import user, ../types/unifiedcard
from ../../types import Card, CardKind, Video
from ../../utils import twimg, https
@ -27,6 +27,14 @@ proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card)
result.text = data.topicDetail.title
result.dest = "Topic"
proc parseJobDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
data.destination.parseDestination(card, result)
result.kind = CardKind.jobDetails
result.title = data.title
result.text = data.shortDescriptionText
result.dest = &"@{data.profileUser.username} · {data.location}"
proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
let app = card.appStoreData[data.appId][0]
@ -84,6 +92,8 @@ proc parseUnifiedCard*(json: string): Card =
component.parseMedia(card, result)
of buttonGroup:
discard
of ComponentType.jobDetails:
component.data.parseJobDetails(card, result)
of ComponentType.hidden:
result.kind = CardKind.hidden
of ComponentType.unknown:

View file

@ -56,7 +56,7 @@ proc toUser*(raw: RawUser): User =
tweets: raw.statusesCount,
likes: raw.favouritesCount,
media: raw.mediaCount,
verified: raw.verified or raw.extIsBlueVerified,
verifiedType: raw.verifiedType,
protected: raw.protected,
joinDate: parseTwitterDate(raw.createdAt),
banner: getBanner(raw),
@ -68,6 +68,11 @@ proc toUser*(raw: RawUser): User =
result.expandUserEntities(raw)
proc parseHook*(s: string; i: var int; v: var User) =
var u: RawUser
parseHook(s, i, u)
v = toUser u
proc parseUser*(json: string; username=""): User =
handleErrors:
case error.code
@ -75,7 +80,7 @@ proc parseUser*(json: string; username=""): User =
of userNotFound: return
else: echo "[error - parseUser]: ", error
result = toUser json.fromJson(RawUser)
result = json.fromJson(User)
proc parseUsers*(json: string; after=""): Result[User] =
result = Result[User](beginning: after.len == 0)

View file

@ -1,5 +1,5 @@
import options
import user
from ../../types import User
type
GraphUser* = object
@ -9,7 +9,7 @@ type
result*: UserResult
UserResult = object
legacy*: RawUser
legacy*: User
restId*: string
isBlueVerified*: bool
unavailableReason*: Option[string]

View file

@ -1,5 +1,5 @@
import std/tables
import user
from ../../types import User
type
Search* = object
@ -7,7 +7,7 @@ type
timeline*: Timeline
GlobalObjects = object
users*: Table[string, RawUser]
users*: Table[string, User]
Timeline = object
instructions*: seq[Instructions]

View file

@ -1,7 +1,10 @@
import options, tables
from ../../types import VideoType, VideoVariant
import std/[options, tables, times]
import jsony
from ../../types import VideoType, VideoVariant, User
type
Text* = distinct string
UnifiedCard* = object
componentObjects*: Table[string, Component]
destinationObjects*: Table[string, Destination]
@ -13,6 +16,7 @@ type
media
swipeableMedia
buttonGroup
jobDetails
appStoreDetails
twitterListDetails
communityDetails
@ -29,12 +33,15 @@ type
appId*: string
mediaId*: string
destination*: string
location*: string
title*: Text
subtitle*: Text
name*: Text
memberCount*: int
mediaList*: seq[MediaItem]
topicDetail*: tuple[title: Text]
profileUser*: User
shortDescriptionText*: string
MediaItem* = object
id*: string
@ -69,12 +76,9 @@ type
title*: Text
category*: Text
Text = object
content: string
TypeField = Component | Destination | MediaEntity | AppStoreData
converter fromText*(text: Text): string = text.content
converter fromText*(text: Text): string = string(text)
proc renameHook*(v: var TypeField; fieldName: var string) =
if fieldName == "type":
@ -86,6 +90,7 @@ proc enumHook*(s: string; v: var ComponentType) =
of "media": media
of "swipeable_media": swipeableMedia
of "button_group": buttonGroup
of "job_details": jobDetails
of "app_store_details": appStoreDetails
of "twitter_list_details": twitterListDetails
of "community_details": communityDetails
@ -106,3 +111,18 @@ proc enumHook*(s: string; v: var MediaType) =
of "photo": photo
of "model3d": model3d
else: echo "ERROR: Unknown enum value (MediaType): ", s; photo
proc parseHook*(s: string; i: var int; v: var DateTime) =
var str: string
parseHook(s, i, str)
v = parse(str, "yyyy-MM-dd hh:mm:ss")
proc parseHook*(s: string; i: var int; v: var Text) =
if s[i] == '"':
var str: string
parseHook(s, i, str)
v = Text(str)
else:
var t: tuple[content: string]
parseHook(s, i, t)
v = Text(t.content)

View file

@ -1,5 +1,6 @@
import options
import common
from ../../types import VerifiedType
type
RawUser* = object
@ -15,8 +16,7 @@ type
favouritesCount*: int
statusesCount*: int
mediaCount*: int
verified*: bool
extIsBlueVerified*: bool
verifiedType*: VerifiedType
protected*: bool
profileLinkColor*: string
profileBannerUrl*: string

View file

@ -1,5 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strformat, logging
import config
from net import Port
from htmlgen import a
from os import getEnv
@ -16,9 +17,6 @@ const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues"
let
configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
(cfg, fullCfg) = getConfig(configPath)
accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json")
initAccountPool(cfg, accountsPath)
@ -29,9 +27,12 @@ if not cfg.enableDebug:
setLogFilter(lvlError)
stdout.write &"Starting Nitter at {getUrlPrefix(cfg)}\n"
if cfg.readOnly:
stdout.write &"==READ ONLY INSTANCE==\n"
stdout.flushFile
updateDefaultPrefs(fullCfg)
setReadOnly(cfg)
setCacheTimes(cfg)
setHmacKey(cfg.hmacKey)
setProxyEncoding(cfg.base64Media)

View file

@ -3,6 +3,7 @@ import strutils, options, times, math
import packedjson, packedjson/deserialiser
import types, parserutils, utils
import experimental/parser/unifiedcard
import std/tables
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
@ -21,7 +22,7 @@ proc parseUser(js: JsonNode; id=""): User =
tweets: js{"statuses_count"}.getInt,
likes: js{"favourites_count"}.getInt,
media: js{"media_count"}.getInt,
verified: js{"verified"}.getBool or js{"ext_is_blue_verified"}.getBool,
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
protected: js{"protected"}.getBool,
joinDate: js{"created_at"}.getTime
)
@ -34,8 +35,8 @@ proc parseGraphUser(js: JsonNode): User =
user = ? js{"user_results", "result"}
result = parseUser(user{"legacy"})
if "is_blue_verified" in user:
result.verified = user{"is_blue_verified"}.getBool()
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
result.verifiedType = blue
proc parseGraphList*(js: JsonNode): List =
if js.isNull: return
@ -219,8 +220,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
)
)
result.expandTweetEntities(js)
# fix for pinned threads
if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId
@ -238,8 +237,11 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
# graphql
with rt, js{"retweeted_status_result", "result"}:
# needed due to weird edgecase where the actual tweet data isn't included
if "legacy" in rt:
result.retweet = some parseGraphTweet(rt)
var rt_tweet = rt
if "tweet" in rt:
rt_tweet = rt{"tweet"}
if "legacy" in rt_tweet:
result.retweet = some parseGraphTweet(rt_tweet)
return
if jsCard.kind != JNull:
@ -254,6 +256,8 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
else:
result.card = some parseCard(jsCard, js{"entities", "urls"})
result.expandTweetEntities(js)
with jsMedia, js{"extended_entities", "media"}:
for m in jsMedia:
case m{"type"}.getStr
@ -289,6 +293,121 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.text.removeSuffix(" Learn more.")
result.available = false
proc parseLegacyTweet(js: JsonNode): Tweet =
result = parseTweet(js, js{"card"})
if not result.isNil and result.available:
result.user = parseUser(js{"user"})
if result.quote.isSome:
result.quote = some parseLegacyTweet(js{"quoted_status"})
proc parseTweetSearch*(js: JsonNode; after=""): Timeline =
result.beginning = after.len == 0
if js.kind == JNull or "modules" notin js or js{"modules"}.len == 0:
return
for item in js{"modules"}:
with tweet, item{"status", "data"}:
let parsed = parseLegacyTweet(tweet)
if parsed.retweet.isSome:
parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"})
result.content.add @[parsed]
if result.content.len > 0:
result.bottom = $(result.content[^1][0].id - 1)
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
let intId = if id.len > 0: parseBiggestInt(id) else: 0
result = global.tweets.getOrDefault(id, Tweet(id: intId))
if result.quote.isSome:
let quote = get(result.quote).id
if $quote in global.tweets:
result.quote = some global.tweets[$quote]
else:
result.quote = some Tweet()
if result.retweet.isSome:
let rt = get(result.retweet).id
if $rt in global.tweets:
result.retweet = some finalizeTweet(global, $rt)
else:
result.retweet = some Tweet()
proc parsePin(js: JsonNode; global: GlobalObjects): Tweet =
let pin = js{"pinEntry", "entry", "entryId"}.getStr
if pin.len == 0: return
let id = pin.getId
if id notin global.tweets: return
global.tweets[id].pinned = true
return finalizeTweet(global, id)
proc parseGlobalObjects(js: JsonNode): GlobalObjects =
result = GlobalObjects()
let
tweets = ? js{"globalObjects", "tweets"}
users = ? js{"globalObjects", "users"}
for k, v in users:
result.users[k] = parseUser(v, k)
for k, v in tweets:
var tweet = parseTweet(v, v{"card"})
if tweet.user.id in result.users:
tweet.user = result.users[tweet.user.id]
result.tweets[k] = tweet
proc parseInstructions(res: var Profile; global: GlobalObjects; js: JsonNode) =
if js.kind != JArray or js.len == 0:
return
for i in js:
if res.tweets.beginning and i{"pinEntry"}.notNull:
with pin, parsePin(i, global):
res.pinned = some pin
with r, i{"replaceEntry", "entry"}:
if "top" in r{"entryId"}.getStr:
res.tweets.top = r.getCursor
elif "bottom" in r{"entryId"}.getStr:
res.tweets.bottom = r.getCursor
proc parseTimeline*(js: JsonNode; after=""): Profile =
result = Profile(tweets: Timeline(beginning: after.len == 0))
let global = parseGlobalObjects(? js)
let instructions = ? js{"timeline", "instructions"}
if instructions.len == 0: return
result.parseInstructions(global, instructions)
var entries: JsonNode
for i in instructions:
if "addEntries" in i:
entries = i{"addEntries", "entries"}
for e in ? entries:
let entry = e{"entryId"}.getStr
if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry:
let tweet = finalizeTweet(global, e.getEntryId)
if not tweet.available: continue
result.tweets.content.add tweet
elif "cursor-top" in entry:
result.tweets.top = e.getCursor
elif "cursor-bottom" in entry:
result.tweets.bottom = e.getCursor
elif entry.startsWith("sq-cursor"):
with cursor, e{"content", "operation", "cursor"}:
if cursor{"cursorType"}.getStr == "Bottom":
result.tweets.bottom = cursor{"value"}.getStr
else:
result.tweets.top = cursor{"value"}.getStr
proc parsePhotoRail*(js: JsonNode): PhotoRail =
with error, js{"error"}:
if error.getStr == "Not authorized.":
@ -445,6 +564,36 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
tweet.id = parseBiggestInt(entryId)
result.pinned = some tweet
proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline =
result = UsersTimeline(beginning: after.len == 0)
let instructions = ? timeline{"instructions"}
if instructions.len == 0:
return
for i in instructions:
if i{"type"}.getStr == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e{"entryId"}.getStr
if entryId.startsWith("user"):
with graphUser, e{"content", "itemContent"}:
let user = parseGraphUser(graphUser)
result.content.add user
elif entryId.startsWith("cursor-bottom"):
result.bottom = e{"content", "value"}.getStr
elif entryId.startsWith("cursor-top"):
result.top = e{"content", "value"}.getStr
proc parseGraphFavoritersTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
return parseGraphUsersTimeline(js{"data", "favoriters_timeline", "timeline"}, after)
proc parseGraphRetweetersTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
return parseGraphUsersTimeline(js{"data", "retweeters_timeline", "timeline"}, after)
proc parseGraphFollowTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
return parseGraphUsersTimeline(js{"data", "user", "result", "timeline", "timeline"}, after)
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
result = Result[T](beginning: after.len == 0)

View file

@ -1,9 +1,17 @@
# SPDX-License-Identifier: AGPL-3.0-only
import std/[strutils, times, macros, htmlgen, options, algorithm, re]
import std/[times, macros, htmlgen, options, algorithm, re]
import std/strutils except escape
import std/unicode except strip
from xmltree import escape
import packedjson
import types, utils, formatters
const
unicodeOpen = "\uFFFA"
unicodeClose = "\uFFFB"
xmlOpen = escape("<")
xmlClose = escape(">")
let
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
unReplace = "$1<a href=\"/$2\">@$2</a>"
@ -238,7 +246,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
.replacef(htRegex, htReplace)
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
replyTo=""; hasQuote=false) =
replyTo=""; hasRedundantLink=false) =
let hasCard = tweet.card.isSome
var replacements = newSeq[ReplaceSlice]()
@ -249,7 +257,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
if urlStr.len == 0 or urlStr notin text:
continue
replacements.extractUrls(u, textSlice.b, hideTwitter = hasQuote)
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
if hasCard and u{"url"}.getStr == get(tweet.card).url:
get(tweet.card).url = u{"expanded_url"}.getStr
@ -289,9 +297,10 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
let
entities = ? js{"entities"}
hasQuote = js{"is_quote_status"}.getBool
textRange = js{"display_text_range"}
textSlice = textRange{0}.getInt .. textRange{1}.getInt
hasQuote = js{"is_quote_status"}.getBool
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
var replyTo = ""
if tweet.replyId != 0:
@ -299,12 +308,14 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
replyTo = reply.getStr
tweet.reply.add replyTo
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote)
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard)
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
let
entities = ? js{"entity_set"}
text = js{"text"}.getStr
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
textSlice = 0..text.runeLen
tweet.expandTextEntities(entities, text, textSlice)
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))

View file

@ -40,6 +40,13 @@ proc getMediaQuery*(name: string): Query =
sep: "OR"
)
proc getFavoritesQuery*(name: string): Query =
Query(
kind: favorites,
fromUser: @[name]
)
proc getReplyQuery*(name: string): Query =
Query(
kind: replies,

View file

@ -12,6 +12,7 @@ var
pool: RedisPool
rssCacheTime: int
listCacheTime*: int
readOnly: bool
template dawait(future) =
discard await future
@ -29,6 +30,9 @@ proc setCacheTimes*(cfg: Config) =
rssCacheTime = cfg.rssCacheTime * 60
listCacheTime = cfg.listCacheTime * 60
proc setReadOnly*(cfg: Config) =
readOnly = cfg.readOnly
proc migrate*(key, match: string) {.async.} =
pool.withAcquire(r):
let hasKey = await r.get(key)
@ -52,6 +56,7 @@ proc initRedisPool*(cfg: Config) {.async.} =
await migrate("profileDates", "p:*")
await migrate("profileStats", "p:*")
await migrate("userType", "p:*")
await migrate("verifiedType", "p:*")
pool.withAcquire(r):
# optimize memory usage for user ID buckets
@ -66,6 +71,8 @@ template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000)
template userKey(name: string): string = "p:" & name
template listKey(l: List): string = "l:" & l.id
template tweetKey(id: int64): string = "t:" & $id
template convoKey(id: string): string = "c:" & $id
template convoKey(id: int64): string = "c:" & $id
proc get(query: string): Future[string] {.async.} =
pool.withAcquire(r):
@ -99,6 +106,11 @@ proc cache*(data: Tweet) {.async.} =
pool.withAcquire(r):
dawait r.setEx(data.id.tweetKey, baseCacheTime, compress(toFlatty(data)))
proc cache*(data: Conversation) {.async.} =
if data.isNil or data.tweet == nil or data.tweet.id == 0: return
pool.withAcquire(r):
dawait r.setEx(data.tweet.id.convoKey, baseCacheTime, compress(toFlatty(data)))
proc cacheRss*(query: string; rss: Rss) {.async.} =
let key = "rss:" & query
pool.withAcquire(r):
@ -117,7 +129,7 @@ proc getUserId*(username: string): Future[string] {.async.} =
let name = toLower(username)
pool.withAcquire(r):
result = await r.hGet(name.uidKey, name)
if result == redisNil:
if result == redisNil and not readOnly:
let user = await getGraphUser(username)
if user.suspended:
return "suspended"
@ -129,7 +141,7 @@ proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} =
let prof = await get("p:" & toLower(username))
if prof != redisNil:
prof.deserialize(User)
elif fetch:
elif fetch and not readOnly:
result = await getGraphUser(username)
await cache(result)
@ -140,29 +152,39 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
if username != redisNil:
result = username
else:
elif not readOnly:
let user = await getGraphUserById(userId)
result = user.username
await setEx(key, baseCacheTime, result)
if result.len > 0 and user.id.len > 0:
await all(cacheUserId(result, user.id), cache(user))
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
# if id == 0: return
# let tweet = await get(id.tweetKey)
# if tweet != redisNil:
# tweet.deserialize(Tweet)
# else:
# result = await getGraphTweetResult($id)
# if not result.isNil:
# await cache(result)
proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
if id == 0: return
let tweet = await get(id.tweetKey)
if tweet != redisNil:
tweet.deserialize(Tweet)
elif not readOnly:
result = await getGraphTweetResult($id)
if not result.isNil:
await cache(result)
proc getCachedConvo*(id: string; cursor=""): Future[Conversation] {.async.} =
if id == "": return
let convo = await get(id.convoKey)
if convo != redisNil:
convo.deserialize(Conversation)
elif not readOnly:
result = await getTweet(id, cursor)
if not result.isNil:
await cache(result)
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
if name.len == 0: return
let rail = await get("pr:" & toLower(name))
if rail != redisNil:
rail.deserialize(PhotoRail)
else:
elif not readOnly:
result = await getPhotoRail(name)
await cache(result, name)
@ -172,7 +194,7 @@ proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
if list != redisNil:
list.deserialize(List)
else:
elif not readOnly:
if id.len > 0:
result = await getGraphList(id)
else:

View file

@ -23,7 +23,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
names = getNames(name)
if names.len == 1:
profile = await fetchProfile(after, query, skipRail=true, skipPinned=true)
profile = await fetchProfile(after, query, cfg, skipRail=true, skipPinned=true)
else:
var q = query
q.fromUser = names
@ -102,7 +102,7 @@ proc createRssRouter*(cfg: Config) =
get "/@name/@tab/rss":
cond cfg.enableRss
cond '.' notin @"name"
cond @"tab" in ["with_replies", "media", "search"]
cond @"tab" in ["with_replies", "media", "favorites", "search"]
let
name = @"name"
tab = @"tab"
@ -110,7 +110,8 @@ proc createRssRouter*(cfg: Config) =
case tab
of "with_replies": getReplyQuery(name)
of "media": getMediaQuery(name)
# of "search": initQuery(params(request), name=name)
of "favorites": getFavoritesQuery(name)
of "search": initQuery(params(request), name=name)
else: Query(fromUser: @[name])
let searchKey = if tab != "search": ""

View file

@ -4,8 +4,8 @@ import asyncdispatch, strutils, sequtils, uri, options, sugar
import jester, karax/vdom
import router_utils
import ".."/[types, formatters, api]
import ../views/[general, status]
import ".."/[types, formatters, api, redis_cache]
import ../views/[general, status, search]
export uri, sequtils, options, sugar
export router_utils
@ -14,6 +14,29 @@ export status
proc createStatusRouter*(cfg: Config) =
router status:
get "/@name/status/@id/@reactors":
cond '.' notin @"name"
let id = @"id"
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, showError("Invalid tweet ID", cfg)
let prefs = cookiePrefs()
# used for the infinite scroll feature
if @"scroll".len > 0:
let replies = await getReplies(id, getCursor())
if replies.content.len == 0:
resp Http404, ""
resp $renderReplies(replies, prefs, getPath())
if @"reactors" == "favoriters":
resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
request, cfg, prefs)
elif @"reactors" == "retweeters":
resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
request, cfg, prefs)
get "/@name/status/@id/?":
cond '.' notin @"name"
let id = @"id"
@ -30,7 +53,7 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, ""
resp $renderReplies(replies, prefs, getPath())
let conv = await getTweet(id, getCursor())
let conv = await getCachedConvo(id, getCursor())
if conv == nil:
echo "nil conv"

View file

@ -16,6 +16,7 @@ proc getQuery*(request: Request; tab, name: string): Query =
case tab
of "with_replies": getReplyQuery(name)
of "media": getMediaQuery(name)
of "favorites": getFavoritesQuery(name)
of "search": initQuery(params(request), name=name)
else: Query(fromUser: @[name])
@ -27,7 +28,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
else:
body
proc fetchProfile*(after: string; query: Query; skipRail=false;
proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
skipPinned=false): Future[Profile] {.async.} =
let
name = query.fromUser[0]
@ -56,6 +57,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false;
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
of favorites: await getFavorites(userId, cfg, after)
else: Profile(tweets: await getGraphTweetSearch(query, after))
result.user = await user
@ -71,7 +73,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
html = renderTweetSearch(timeline, prefs, getPath())
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins)
var profile = await fetchProfile(after, query, cfg, skipPinned=prefs.hidePins)
template u: untyped = profile.user
if u.suspended:
@ -79,7 +81,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
if profile.user.id.len == 0: return
let pHtml = renderProfile(profile, prefs, getPath())
let pHtml = renderProfile(profile, cfg, prefs, getPath())
result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u),
rss=rss, images = @[u.getUserPic("_400x400")],
banner=u.banner)
@ -109,35 +111,42 @@ proc createTimelineRouter*(cfg: Config) =
get "/@name/?@tab?/?":
cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
cond @"tab" in ["with_replies", "media", "search", ""]
cond @"tab" in ["with_replies", "media", "search", "favorites", "following", "followers", ""]
let
prefs = cookiePrefs()
after = getCursor()
names = getNames(@"name")
tab = @"tab"
var query = request.getQuery(@"tab", @"name")
if names.len != 1:
query.fromUser = names
# used for the infinite scroll feature
if @"scroll".len > 0:
if query.fromUser.len != 1:
var timeline = await getGraphTweetSearch(query, after)
if timeline.content.len == 0: resp Http404
timeline.beginning = true
resp $renderTweetSearch(timeline, prefs, getPath())
case tab:
of "followers":
resp renderMain(renderUserList(await getGraphFollowers(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
of "following":
resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
else:
var profile = await fetchProfile(after, query, skipRail=true)
if profile.tweets.content.len == 0: resp Http404
profile.tweets.beginning = true
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
var query = request.getQuery(@"tab", @"name")
if names.len != 1:
query.fromUser = names
let rss =
if @"tab".len == 0:
"/$1/rss" % @"name"
elif @"tab" == "search":
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
else:
"/$1/$2/rss" % [@"name", @"tab"]
# used for the infinite scroll feature
if @"scroll".len > 0:
if query.fromUser.len != 1:
var timeline = await getGraphTweetSearch(query, after)
if timeline.content.len == 0: resp Http404
timeline.beginning = true
resp $renderTweetSearch(timeline, prefs, getPath())
else:
var profile = await fetchProfile(after, query, cfg, skipRail=true)
if profile.tweets.content.len == 0: resp Http404
profile.tweets.beginning = true
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
respTimeline(await showTimeline(request, query, cfg, prefs, rss, after))
let rss =
if @"tab".len == 0:
"/$1/rss" % @"name"
elif @"tab" == "search":
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
else:
"/$1/$2/rss" % [@"name", @"tab"]
respTimeline(await showTimeline(request, query, cfg, prefs, rss, after))

View file

@ -28,6 +28,8 @@ $more_replies_dots: #AD433B;
$error_red: #420A05;
$verified_blue: #1DA1F2;
$verified_business: #FAC82B;
$verified_government: #C1B6A4;
$icon_text: $fg_color;
$tab: $fg_color;

View file

@ -39,6 +39,8 @@ body {
--error_red: #{$error_red};
--verified_blue: #{$verified_blue};
--verified_business: #{$verified_business};
--verified_government: #{$verified_government};
--icon_text: #{$icon_text};
--tab: #{$fg_color};
@ -141,17 +143,30 @@ ul {
.verified-icon {
color: var(--icon_text);
background-color: var(--verified_blue);
border-radius: 50%;
flex-shrink: 0;
margin: 2px 0 3px 3px;
padding-top: 2px;
height: 12px;
padding-top: 3px;
height: 11px;
width: 14px;
font-size: 8px;
display: inline-block;
text-align: center;
vertical-align: middle;
&.blue {
background-color: var(--verified_blue);
}
&.business {
color: var(--bg_panel);
background-color: var(--verified_business);
}
&.government {
color: var(--bg_panel);
background-color: var(--verified_government);
}
}
@media(max-width: 600px) {

View file

@ -14,6 +14,8 @@
button {
margin: 0 2px 0 0;
height: 23px;
display: flex;
align-items: center;
}
.pref-input {

View file

@ -207,6 +207,7 @@
padding-top: 5px;
min-width: 1em;
margin-right: 0.8em;
pointer-events: all;
}
.show-thread {

View file

@ -10,9 +10,7 @@ type
BadClientError* = object of CatchableError
TimelineKind* {.pure.} = enum
tweets
replies
media
tweets, replies, media
Api* {.pure.} = enum
tweetDetail
@ -25,9 +23,14 @@ type
listTweets
userRestId
userScreenName
favorites
userTweets
userTweetsAndReplies
userMedia
favoriters
retweeters
following
followers
RateLimit* = object
remaining*: int
@ -63,6 +66,12 @@ type
tweetUnavailable = 421
tweetCensored = 422
VerifiedType* = enum
none = "None"
blue = "Blue"
business = "Business"
government = "Government"
User* = object
id*: string
username*: string
@ -78,7 +87,7 @@ type
tweets*: int
likes*: int
media*: int
verified*: bool
verifiedType*: VerifiedType
protected*: bool
suspended*: bool
joinDate*: DateTime
@ -107,7 +116,7 @@ type
variants*: seq[VideoVariant]
QueryKind* = enum
posts, replies, media, users, tweets, userList
posts, replies, media, users, tweets, userList, favorites
Query* = object
kind*: QueryKind
@ -162,6 +171,7 @@ type
imageDirectMessage = "image_direct_message"
audiospace = "audiospace"
newsletterPublication = "newsletter_publication"
jobDetails = "job_details"
hidden
unknown
@ -226,6 +236,7 @@ type
replies*: Result[Chain]
Timeline* = Result[Tweets]
UsersTimeline* = Result[User]
Profile* = object
user*: User
@ -254,6 +265,7 @@ type
title*: string
hostname*: string
staticDir*: string
readOnly*: bool
hmacKey*: string
base64Media*: bool
@ -272,6 +284,9 @@ type
redisMaxConns*: int
redisPassword*: string
cookieHeader*: string
xCsrfToken*: string
Rss* = object
feed*, cursor*: string

View file

@ -16,7 +16,8 @@ const
"twimg.com",
"abs.twimg.com",
"pbs.twimg.com",
"video.twimg.com"
"video.twimg.com",
"x.com"
]
proc setHmacKey*(key: string) =
@ -57,4 +58,4 @@ proc isTwitterUrl*(uri: Uri): bool =
uri.hostname in twitterDomains
proc isTwitterUrl*(url: string): bool =
parseUri(url).hostname in twitterDomains
isTwitterUrl(parseUri(url))

View file

@ -23,7 +23,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
buildHtml(nav):
tdiv(class="inner-nav"):
tdiv(class="nav-item"):
a(class="site-name", href="/"): text cfg.title
a(class="site-name", href="/"): text if cfg.readOnly: cfg.title & " (readonly)" else: cfg.title
a(href="/"): img(class="site-logo", src="/logo.png", alt="Logo")
@ -52,7 +52,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=18")
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
if theme.len > 0:

View file

@ -58,10 +58,14 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
tdiv(class="profile-card-extra-links"):
ul(class="profile-statlist"):
renderStat(user.tweets, "posts", text="Tweets")
renderStat(user.following, "following")
renderStat(user.followers, "followers")
renderStat(user.likes, "likes")
a(href="/" & user.username):
renderStat(user.tweets, "posts", text="Tweets")
a(href="/" & user.username & "/following"):
renderStat(user.following, "following")
a(href="/" & user.username & "/followers"):
renderStat(user.followers, "followers")
a(href="/" & user.username & "/favorites"):
renderStat(user.likes, "likes")
proc renderPhotoRail(profile: Profile): VNode =
let count = insertSep($profile.user.media, ',')
@ -99,7 +103,7 @@ proc renderProtected(username: string): VNode =
h2: text "This account's tweets are protected."
p: text &"Only confirmed followers have access to @{username}'s tweets."
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: string): VNode =
profile.tweets.query.fromUser = @[profile.user.username]
buildHtml(tdiv(class="profile-tabs")):

View file

@ -23,6 +23,13 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
if text.len > 0:
text " " & text
template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none:
let lower = ($user.verifiedType).toLowerAscii()
icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
else:
text ""
proc linkUser*(user: User, class=""): VNode =
let
isName = "username" notin class
@ -32,11 +39,11 @@ proc linkUser*(user: User, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)):
text nameText
if isName and user.verified:
icon "ok", class="verified-icon", title="Verified account"
if isName and user.protected:
text " "
icon "lock", title="Protected account"
if isName:
verifiedIcon(user)
if user.protected:
text " "
icon "lock", title="Protected account"
proc linkText*(text: string; class=""): VNode =
let url = if "http" notin text: https & text else: text

View file

@ -3,7 +3,7 @@ import strutils, strformat, sequtils, unicode, tables, options
import karax/[karaxdsl, vdom]
import renderutils, timeline
import ".."/[types, query]
import ".."/[types, query, config]
const toggles = {
"nativeretweets": "Retweets",
@ -29,7 +29,7 @@ proc renderSearch*(): VNode =
placeholder="Enter username...", dir="auto")
button(`type`="submit"): icon "search"
proc renderProfileTabs*(query: Query; username: string): VNode =
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =
let link = "/" & username
buildHtml(ul(class="tab")):
li(class=query.getTabClass(posts)):
@ -38,6 +38,9 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
a(href=(link & "/with_replies")): text "Tweets & Replies"
li(class=query.getTabClass(media)):
a(href=(link & "/media")): text "Media"
if len(cfg.xCsrfToken) != 0 and len(cfg.cookieHeader) != 0:
li(class=query.getTabClass(favorites)):
a(href=(link & "/favorites")): text "Likes"
li(class=query.getTabClass(tweets)):
a(href=(link & "/search")): text "Search"
@ -97,7 +100,7 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
text query.fromUser.join(" | ")
if query.fromUser.len > 0:
renderProfileTabs(query, query.fromUser.join(","))
renderProfileTabs(query, query.fromUser.join(","), cfg)
if query.fromUser.len == 0 or query.kind == tweets:
tdiv(class="timeline-header"):
@ -118,3 +121,8 @@ proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
renderSearchTabs(results.query)
renderTimelineUsers(results, prefs)
proc renderUserList*(results: Result[User]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header")
renderTimelineUsers(results, prefs)

View file

@ -180,14 +180,19 @@ func formatStat(stat: int): string =
if stat > 0: insertSep($stat, ',')
else: ""
proc renderStats(stats: TweetStats; views: string): VNode =
proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
buildHtml(tdiv(class="tweet-stats")):
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
if views.len > 0:
span(class="tweet-stat"): icon "play", insertSep(views, ',')
a(href=getLink(tweet)):
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
a(href=getLink(tweet, false) & "/retweeters"):
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
a(href="/search?q=quoted_tweet_id:" & $tweet.id):
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
a(href=getLink(tweet, false) & "/favoriters"):
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
a(href=getLink(tweet)):
if views.len > 0:
span(class="tweet-stat"): icon "play", insertSep(views, ',')
proc renderReply(tweet: Tweet): VNode =
buildHtml(tdiv(class="replying-to")):
@ -200,8 +205,7 @@ proc renderAttribution(user: User; prefs: Prefs): VNode =
buildHtml(a(class="attribution", href=("/" & user.username))):
renderMiniAvatar(user, prefs)
strong: text user.fullname
if user.verified:
icon "ok", class="verified-icon", title="Verified account"
verifiedIcon(user)
proc renderMediaTags(tags: seq[User]): VNode =
buildHtml(tdiv(class="media-tag-block")):
@ -346,7 +350,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderMediaTags(tweet.mediaTags)
if not prefs.hideTweetStats:
renderStats(tweet.stats, views)
renderStats(tweet.stats, views, tweet)
if showThread:
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):

View file

@ -9,7 +9,7 @@ text = [
What are we doing wrong? reuters.com/article/us-norwa"""],
['nim_lang/status/1491461266849808397#m',
'Nim language', '@nim_lang',
'Nim', '@nim_lang',
"""What's better than Nim 1.6.0?
Nim 1.6.2 :)

View file

@ -1,4 +1,4 @@
from base import BaseTestCase, Tweet, get_timeline_tweet
from base import BaseTestCase, Tweet, Conversation, get_timeline_tweet
from parameterized import parameterized
# image = tweet + 'div.attachments.media-body > div > div > a > div > img'
@ -35,7 +35,16 @@ multiline = [
CALM
AND
CLICHÉ
ON"""]
ON"""],
[1718660434457239868, 'WebDesignMuseum',
"""
Happy 32nd Birthday HTML tags!
On October 29, 1991, the internet pioneer, Tim Berners-Lee, published a document entitled HTML Tags.
The document contained a description of the first 18 HTML tags: <title>, <nextid>, <a>, <isindex>, <plaintext>, <listing>, <p>, <h1><h6>, <address>, <hp1>, <hp2>, <dl>, <dt>, <dd>, <ul>, <li>,<menu> and <dir>. The design of the first version of HTML language was influenced by the SGML universal markup language.
#WebDesignHistory"""]
]
link = [
@ -74,22 +83,18 @@ retweet = [
[3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr']
]
# reply = [
# ['mobile_test/with_replies', 15]
# ]
class TweetTest(BaseTestCase):
# @parameterized.expand(timeline)
# def test_timeline(self, index, fullname, username, date, tid, text):
# self.open_nitter(username)
# tweet = get_timeline_tweet(index)
# self.assert_exact_text(fullname, tweet.fullname)
# self.assert_exact_text('@' + username, tweet.username)
# self.assert_exact_text(date, tweet.date)
# self.assert_text(text, tweet.text)
# permalink = self.find_element(tweet.date + ' a')
# self.assertIn(tid, permalink.get_attribute('href'))
@parameterized.expand(timeline)
def test_timeline(self, index, fullname, username, date, tid, text):
self.open_nitter(username)
tweet = get_timeline_tweet(index)
self.assert_exact_text(fullname, tweet.fullname)
self.assert_exact_text('@' + username, tweet.username)
self.assert_exact_text(date, tweet.date)
self.assert_text(text, tweet.text)
permalink = self.find_element(tweet.date + ' a')
self.assertIn(tid, permalink.get_attribute('href'))
@parameterized.expand(status)
def test_status(self, tid, fullname, username, date, text):
@ -103,18 +108,18 @@ class TweetTest(BaseTestCase):
@parameterized.expand(multiline)
def test_multiline_formatting(self, tid, username, text):
self.open_nitter(f'{username}/status/{tid}')
self.assert_text(text.strip('\n'), '.main-tweet')
self.assert_text(text.strip('\n'), Conversation.main)
@parameterized.expand(emoji)
def test_emoji(self, tweet, text):
self.open_nitter(tweet)
self.assert_text(text, '.main-tweet')
self.assert_text(text, Conversation.main)
@parameterized.expand(link)
def test_link(self, tweet, links):
self.open_nitter(tweet)
for link in links:
self.assert_text(link, '.main-tweet')
self.assert_text(link, Conversation.main)
@parameterized.expand(username)
def test_username(self, tweet, usernames):
@ -123,22 +128,22 @@ class TweetTest(BaseTestCase):
link = self.find_link_text(f'@{un}')
self.assertIn(f'/{un}', link.get_property('href'))
# @parameterized.expand(retweet)
# def test_retweet(self, index, url, retweet_by, fullname, username, text):
# self.open_nitter(url)
# tweet = get_timeline_tweet(index)
# self.assert_text(f'{retweet_by} retweeted', tweet.retweet)
# self.assert_text(text, tweet.text)
# self.assert_exact_text(fullname, tweet.fullname)
# self.assert_exact_text(username, tweet.username)
@parameterized.expand(retweet)
def test_retweet(self, index, url, retweet_by, fullname, username, text):
self.open_nitter(url)
tweet = get_timeline_tweet(index)
self.assert_text(f'{retweet_by} retweeted', tweet.retweet)
self.assert_text(text, tweet.text)
self.assert_exact_text(fullname, tweet.fullname)
self.assert_exact_text(username, tweet.username)
@parameterized.expand(invalid)
def test_invalid_id(self, tweet):
self.open_nitter(tweet)
self.assert_text('Tweet not found', '.error-panel')
# @parameterized.expand(reply)
# def test_thread(self, tweet, num):
# self.open_nitter(tweet)
# thread = self.find_element(f'.timeline > div:nth-child({num})')
# self.assertIn(thread.get_attribute('class'), 'thread-line')
#@parameterized.expand(reply)
#def test_thread(self, tweet, num):
#self.open_nitter(tweet)
#thread = self.find_element(f'.timeline > div:nth-child({num})')
#self.assertIn(thread.get_attribute('class'), 'thread-line')