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 FROM alpine:3.18 as nim
LABEL maintainer="setenforce@protonmail.com" 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 WORKDIR /src/nitter

View file

@ -3,7 +3,7 @@ version: "3"
services: services:
nitter: nitter:
image: zedeus/nitter:latest image: ghcr.io/privacydevel/nitter:master
container_name: nitter container_name: nitter
ports: ports:
- "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy - "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 https = false # disable to enable cookies when not using https
httpMaxConnections = 100 httpMaxConnections = 100
staticDir = "./public" staticDir = "./public"
readOnly = false # setting to true prevents nitter from reaching out to twitter and will only use redis data
[Cache] [Cache]
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) 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 # 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 # 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 # Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences] [Preferences]
theme = "Nitter" theme = "Nitter"

View file

@ -69,6 +69,13 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) 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.} = proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
@ -86,6 +93,42 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
js = await fetch(graphTweet ? params, Api.tweetDetail) js = await fetch(graphTweet ? params, Api.tweetDetail)
result = parseGraphConversation(js, id) 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.} = proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
result = (await getGraphTweet(id, after)).replies result = (await getGraphTweet(id, after)).replies
result.beginning = after.len == 0 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 jsony, packedjson, zippy, oauth1
import types, auth, consts, parserutils, http_pool import types, auth, consts, parserutils, http_pool
import experimental/types/common import experimental/types/common
import config
const const
rlRemaining = "x-rate-limit-remaining" rlRemaining = "x-rate-limit-remaining"
@ -61,7 +62,14 @@ proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders =
"DNT": "1" "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: once:
pool = HttpPool() pool = HttpPool()
@ -72,13 +80,19 @@ template fetchImpl(result, fetchBody) {.dirty.} =
try: try:
var resp: AsyncResponse 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 = template getContent =
resp = await c.get($url) resp = await c.get($url)
result = await resp.body result = await resp.body
getContent() getContent()
if resp.status == $Http429:
raise rateLimitError()
if resp.status == $Http503: if resp.status == $Http503:
badClient = true badClient = true
raise newException(BadClientError, "Bad client") raise newException(BadClientError, "Bad client")
@ -133,10 +147,16 @@ template retry(bod) =
echo "[accounts] Rate limited, retrying ", api, " request..." echo "[accounts] Rate limited, retrying ", api, " request..."
bod 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: retry:
var body: string var body: string
fetchImpl body: fetchImpl(body, additional_headers):
if body.startsWith('{') or body.startsWith('['): if body.startsWith('{') or body.startsWith('['):
result = parseJson(body) result = parseJson(body)
else: else:
@ -149,9 +169,10 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
invalidate(account) invalidate(account)
raise rateLimitError() 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: retry:
fetchImpl result: fetchImpl(result, additional_headers):
if not (result.startsWith('{') or result.startsWith('[')): if not (result.startsWith('{') or result.startsWith('[')):
echo resp.status, ": ", result, " --- url: ", url echo resp.status, ": ", result, " --- url: ", url
result.setLen(0) result.setLen(0)

View file

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

View file

@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import parsecfg except Config import parsecfg except Config
import types, strutils import types, strutils
from os import getEnv
proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
let val = config.getSectionValue(section, key) let val = config.getSectionValue(section, key)
@ -22,6 +23,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
staticDir: cfg.get("Server", "staticDir", "./public"), staticDir: cfg.get("Server", "staticDir", "./public"),
title: cfg.get("Server", "title", "Nitter"), title: cfg.get("Server", "title", "Nitter"),
hostname: cfg.get("Server", "hostname", "nitter.net"), hostname: cfg.get("Server", "hostname", "nitter.net"),
readOnly: cfg.get("Server", "readOnly", false),
# Cache # Cache
listCacheTime: cfg.get("Cache", "listMinutes", 120), listCacheTime: cfg.get("Cache", "listMinutes", 120),
@ -40,7 +42,13 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
enableRss: cfg.get("Config", "enableRSS", true), enableRss: cfg.get("Config", "enableRSS", true),
enableDebug: cfg.get("Config", "enableDebug", false), enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""), 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) 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" photoRail* = api / "1.1/statuses/media_timeline.json"
timelineApi = api / "2/timeline"
favorites* = timelineApi / "favorites"
graphql = api / "graphql" graphql = api / "graphql"
graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
@ -23,18 +26,20 @@ const
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters"
graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters"
graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers"
graphFollowing* = graphql / "JPZiqKjET7_M1r5Tlr8pyA/Following"
timelineParams* = { timelineParams* = {
"include_can_media_tag": "1", "include_can_media_tag": "1",
"include_cards": "1", "include_cards": "1",
"include_entities": "1", "include_entities": "1",
"include_profile_interstitial_type": "0", "include_profile_interstitial_type": "0",
"include_quote_count": "1", "include_quote_count": "0",
"include_reply_count": "1", "include_reply_count": "0",
"include_user_entities": "1", "include_user_entities": "0",
"include_ext_reply_count": "1", "include_ext_reply_count": "0",
"include_ext_is_blue_verified": "1",
# "include_ext_verified_type": "1",
"include_ext_media_color": "0", "include_ext_media_color": "0",
"cards_platform": "Web-13", "cards_platform": "Web-13",
"tweet_mode": "extended", "tweet_mode": "extended",
@ -116,3 +121,15 @@ const
"rest_id": "$1", $2 "rest_id": "$1", $2
"count": 20 "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 options
import jsony import jsony
import user, ../types/[graphuser, graphlistmembers] 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 = proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{': if json.len == 0 or json[0] != '{':
@ -12,9 +12,10 @@ proc parseGraphUser*(json: string): User =
if raw.data.userResult.result.unavailableReason.get("") == "Suspended": if raw.data.userResult.result.unavailableReason.get("") == "Suspended":
return User(suspended: true) 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.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] = proc parseGraphListMembers*(json, cursor: string): Result[User] =
result = Result[User]( result = Result[User](
@ -30,7 +31,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
of TimelineTimelineItem: of TimelineTimelineItem:
let userResult = entry.content.itemContent.userResults.result let userResult = entry.content.itemContent.userResults.result
if userResult.restId.len > 0: if userResult.restId.len > 0:
result.content.add toUser userResult.legacy result.content.add userResult.legacy
of TimelineTimelineCursor: of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom": if entry.content.cursorType == "Bottom":
result.bottom = entry.content.value result.bottom = entry.content.value

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,10 @@
import options, tables import std/[options, tables, times]
from ../../types import VideoType, VideoVariant import jsony
from ../../types import VideoType, VideoVariant, User
type type
Text* = distinct string
UnifiedCard* = object UnifiedCard* = object
componentObjects*: Table[string, Component] componentObjects*: Table[string, Component]
destinationObjects*: Table[string, Destination] destinationObjects*: Table[string, Destination]
@ -13,6 +16,7 @@ type
media media
swipeableMedia swipeableMedia
buttonGroup buttonGroup
jobDetails
appStoreDetails appStoreDetails
twitterListDetails twitterListDetails
communityDetails communityDetails
@ -29,12 +33,15 @@ type
appId*: string appId*: string
mediaId*: string mediaId*: string
destination*: string destination*: string
location*: string
title*: Text title*: Text
subtitle*: Text subtitle*: Text
name*: Text name*: Text
memberCount*: int memberCount*: int
mediaList*: seq[MediaItem] mediaList*: seq[MediaItem]
topicDetail*: tuple[title: Text] topicDetail*: tuple[title: Text]
profileUser*: User
shortDescriptionText*: string
MediaItem* = object MediaItem* = object
id*: string id*: string
@ -69,12 +76,9 @@ type
title*: Text title*: Text
category*: Text category*: Text
Text = object
content: string
TypeField = Component | Destination | MediaEntity | AppStoreData 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) = proc renameHook*(v: var TypeField; fieldName: var string) =
if fieldName == "type": if fieldName == "type":
@ -86,6 +90,7 @@ proc enumHook*(s: string; v: var ComponentType) =
of "media": media of "media": media
of "swipeable_media": swipeableMedia of "swipeable_media": swipeableMedia
of "button_group": buttonGroup of "button_group": buttonGroup
of "job_details": jobDetails
of "app_store_details": appStoreDetails of "app_store_details": appStoreDetails
of "twitter_list_details": twitterListDetails of "twitter_list_details": twitterListDetails
of "community_details": communityDetails of "community_details": communityDetails
@ -106,3 +111,18 @@ proc enumHook*(s: string; v: var MediaType) =
of "photo": photo of "photo": photo
of "model3d": model3d of "model3d": model3d
else: echo "ERROR: Unknown enum value (MediaType): ", s; photo 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 options
import common import common
from ../../types import VerifiedType
type type
RawUser* = object RawUser* = object
@ -15,8 +16,7 @@ type
favouritesCount*: int favouritesCount*: int
statusesCount*: int statusesCount*: int
mediaCount*: int mediaCount*: int
verified*: bool verifiedType*: VerifiedType
extIsBlueVerified*: bool
protected*: bool protected*: bool
profileLinkColor*: string profileLinkColor*: string
profileBannerUrl*: string profileBannerUrl*: string

View file

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

View file

@ -3,6 +3,7 @@ import strutils, options, times, math
import packedjson, packedjson/deserialiser import packedjson, packedjson/deserialiser
import types, parserutils, utils import types, parserutils, utils
import experimental/parser/unifiedcard import experimental/parser/unifiedcard
import std/tables
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
@ -21,7 +22,7 @@ proc parseUser(js: JsonNode; id=""): User =
tweets: js{"statuses_count"}.getInt, tweets: js{"statuses_count"}.getInt,
likes: js{"favourites_count"}.getInt, likes: js{"favourites_count"}.getInt,
media: js{"media_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, protected: js{"protected"}.getBool,
joinDate: js{"created_at"}.getTime joinDate: js{"created_at"}.getTime
) )
@ -34,8 +35,8 @@ proc parseGraphUser(js: JsonNode): User =
user = ? js{"user_results", "result"} user = ? js{"user_results", "result"}
result = parseUser(user{"legacy"}) result = parseUser(user{"legacy"})
if "is_blue_verified" in user: if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
result.verified = user{"is_blue_verified"}.getBool() result.verifiedType = blue
proc parseGraphList*(js: JsonNode): List = proc parseGraphList*(js: JsonNode): List =
if js.isNull: return if js.isNull: return
@ -219,8 +220,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
) )
) )
result.expandTweetEntities(js)
# fix for pinned threads # fix for pinned threads
if result.hasThread and result.threadId == 0: if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId result.threadId = js{"self_thread", "id_str"}.getId
@ -238,8 +237,11 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
# graphql # graphql
with rt, js{"retweeted_status_result", "result"}: with rt, js{"retweeted_status_result", "result"}:
# needed due to weird edgecase where the actual tweet data isn't included # needed due to weird edgecase where the actual tweet data isn't included
if "legacy" in rt: var rt_tweet = rt
result.retweet = some parseGraphTweet(rt) if "tweet" in rt:
rt_tweet = rt{"tweet"}
if "legacy" in rt_tweet:
result.retweet = some parseGraphTweet(rt_tweet)
return return
if jsCard.kind != JNull: if jsCard.kind != JNull:
@ -254,6 +256,8 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
else: else:
result.card = some parseCard(jsCard, js{"entities", "urls"}) result.card = some parseCard(jsCard, js{"entities", "urls"})
result.expandTweetEntities(js)
with jsMedia, js{"extended_entities", "media"}: with jsMedia, js{"extended_entities", "media"}:
for m in jsMedia: for m in jsMedia:
case m{"type"}.getStr case m{"type"}.getStr
@ -289,6 +293,121 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.text.removeSuffix(" Learn more.") result.text.removeSuffix(" Learn more.")
result.available = false 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 = proc parsePhotoRail*(js: JsonNode): PhotoRail =
with error, js{"error"}: with error, js{"error"}:
if error.getStr == "Not authorized.": if error.getStr == "Not authorized.":
@ -445,6 +564,36 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
tweet.id = parseBiggestInt(entryId) tweet.id = parseBiggestInt(entryId)
result.pinned = some tweet 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] = proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
result = Result[T](beginning: after.len == 0) result = Result[T](beginning: after.len == 0)

View file

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

View file

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

View file

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

View file

@ -4,8 +4,8 @@ import asyncdispatch, strutils, sequtils, uri, options, sugar
import jester, karax/vdom import jester, karax/vdom
import router_utils import router_utils
import ".."/[types, formatters, api] import ".."/[types, formatters, api, redis_cache]
import ../views/[general, status] import ../views/[general, status, search]
export uri, sequtils, options, sugar export uri, sequtils, options, sugar
export router_utils export router_utils
@ -14,6 +14,29 @@ export status
proc createStatusRouter*(cfg: Config) = proc createStatusRouter*(cfg: Config) =
router status: 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/?": get "/@name/status/@id/?":
cond '.' notin @"name" cond '.' notin @"name"
let id = @"id" let id = @"id"
@ -30,7 +53,7 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, "" resp Http404, ""
resp $renderReplies(replies, prefs, getPath()) resp $renderReplies(replies, prefs, getPath())
let conv = await getTweet(id, getCursor()) let conv = await getCachedConvo(id, getCursor())
if conv == nil: if conv == nil:
echo "nil conv" echo "nil conv"

View file

@ -16,6 +16,7 @@ proc getQuery*(request: Request; tab, name: string): Query =
case tab case tab
of "with_replies": getReplyQuery(name) of "with_replies": getReplyQuery(name)
of "media": getMediaQuery(name) of "media": getMediaQuery(name)
of "favorites": getFavoritesQuery(name)
of "search": initQuery(params(request), name=name) of "search": initQuery(params(request), name=name)
else: Query(fromUser: @[name]) else: Query(fromUser: @[name])
@ -27,7 +28,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
else: else:
body body
proc fetchProfile*(after: string; query: Query; skipRail=false; proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
skipPinned=false): Future[Profile] {.async.} = skipPinned=false): Future[Profile] {.async.} =
let let
name = query.fromUser[0] 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 posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
of media: await getGraphUserTweets(userId, TimelineKind.media, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after)
of favorites: await getFavorites(userId, cfg, after)
else: Profile(tweets: await getGraphTweetSearch(query, after)) else: Profile(tweets: await getGraphTweetSearch(query, after))
result.user = await user result.user = await user
@ -71,7 +73,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
html = renderTweetSearch(timeline, prefs, getPath()) html = renderTweetSearch(timeline, prefs, getPath())
return renderMain(html, request, cfg, prefs, "Multi", rss=rss) 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 template u: untyped = profile.user
if u.suspended: if u.suspended:
@ -79,7 +81,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
if profile.user.id.len == 0: return 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), result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u),
rss=rss, images = @[u.getUserPic("_400x400")], rss=rss, images = @[u.getUserPic("_400x400")],
banner=u.banner) banner=u.banner)
@ -109,12 +111,19 @@ proc createTimelineRouter*(cfg: Config) =
get "/@name/?@tab?/?": get "/@name/?@tab?/?":
cond '.' notin @"name" cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] 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 let
prefs = cookiePrefs() prefs = cookiePrefs()
after = getCursor() after = getCursor()
names = getNames(@"name") names = getNames(@"name")
tab = @"tab"
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 query = request.getQuery(@"tab", @"name") var query = request.getQuery(@"tab", @"name")
if names.len != 1: if names.len != 1:
query.fromUser = names query.fromUser = names
@ -127,7 +136,7 @@ proc createTimelineRouter*(cfg: Config) =
timeline.beginning = true timeline.beginning = true
resp $renderTweetSearch(timeline, prefs, getPath()) resp $renderTweetSearch(timeline, prefs, getPath())
else: else:
var profile = await fetchProfile(after, query, skipRail=true) var profile = await fetchProfile(after, query, cfg, skipRail=true)
if profile.tweets.content.len == 0: resp Http404 if profile.tweets.content.len == 0: resp Http404
profile.tweets.beginning = true profile.tweets.beginning = true
resp $renderTimelineTweets(profile.tweets, prefs, getPath()) resp $renderTimelineTweets(profile.tweets, prefs, getPath())

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,8 @@ const
"twimg.com", "twimg.com",
"abs.twimg.com", "abs.twimg.com",
"pbs.twimg.com", "pbs.twimg.com",
"video.twimg.com" "video.twimg.com",
"x.com"
] ]
proc setHmacKey*(key: string) = proc setHmacKey*(key: string) =
@ -57,4 +58,4 @@ proc isTwitterUrl*(uri: Uri): bool =
uri.hostname in twitterDomains uri.hostname in twitterDomains
proc isTwitterUrl*(url: string): bool = 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): buildHtml(nav):
tdiv(class="inner-nav"): tdiv(class="inner-nav"):
tdiv(class="nav-item"): 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") 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" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): 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") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
if theme.len > 0: if theme.len > 0:

View file

@ -58,9 +58,13 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
tdiv(class="profile-card-extra-links"): tdiv(class="profile-card-extra-links"):
ul(class="profile-statlist"): ul(class="profile-statlist"):
a(href="/" & user.username):
renderStat(user.tweets, "posts", text="Tweets") renderStat(user.tweets, "posts", text="Tweets")
a(href="/" & user.username & "/following"):
renderStat(user.following, "following") renderStat(user.following, "following")
a(href="/" & user.username & "/followers"):
renderStat(user.followers, "followers") renderStat(user.followers, "followers")
a(href="/" & user.username & "/favorites"):
renderStat(user.likes, "likes") renderStat(user.likes, "likes")
proc renderPhotoRail(profile: Profile): VNode = proc renderPhotoRail(profile: Profile): VNode =
@ -99,7 +103,7 @@ proc renderProtected(username: string): VNode =
h2: text "This account's tweets are protected." h2: text "This account's tweets are protected."
p: text &"Only confirmed followers have access to @{username}'s tweets." 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] profile.tweets.query.fromUser = @[profile.user.username]
buildHtml(tdiv(class="profile-tabs")): buildHtml(tdiv(class="profile-tabs")):

View file

@ -23,6 +23,13 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
if text.len > 0: if text.len > 0:
text " " & text 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 = proc linkUser*(user: User, class=""): VNode =
let let
isName = "username" notin class isName = "username" notin class
@ -32,9 +39,9 @@ proc linkUser*(user: User, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)): buildHtml(a(href=href, class=class, title=nameText)):
text nameText text nameText
if isName and user.verified: if isName:
icon "ok", class="verified-icon", title="Verified account" verifiedIcon(user)
if isName and user.protected: if user.protected:
text " " text " "
icon "lock", title="Protected account" icon "lock", title="Protected account"

View file

@ -3,7 +3,7 @@ import strutils, strformat, sequtils, unicode, tables, options
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import renderutils, timeline import renderutils, timeline
import ".."/[types, query] import ".."/[types, query, config]
const toggles = { const toggles = {
"nativeretweets": "Retweets", "nativeretweets": "Retweets",
@ -29,7 +29,7 @@ proc renderSearch*(): VNode =
placeholder="Enter username...", dir="auto") placeholder="Enter username...", dir="auto")
button(`type`="submit"): icon "search" button(`type`="submit"): icon "search"
proc renderProfileTabs*(query: Query; username: string): VNode = proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =
let link = "/" & username let link = "/" & username
buildHtml(ul(class="tab")): buildHtml(ul(class="tab")):
li(class=query.getTabClass(posts)): li(class=query.getTabClass(posts)):
@ -38,6 +38,9 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
a(href=(link & "/with_replies")): text "Tweets & Replies" a(href=(link & "/with_replies")): text "Tweets & Replies"
li(class=query.getTabClass(media)): li(class=query.getTabClass(media)):
a(href=(link & "/media")): text "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)): li(class=query.getTabClass(tweets)):
a(href=(link & "/search")): text "Search" a(href=(link & "/search")): text "Search"
@ -97,7 +100,7 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
text query.fromUser.join(" | ") text query.fromUser.join(" | ")
if query.fromUser.len > 0: 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: if query.fromUser.len == 0 or query.kind == tweets:
tdiv(class="timeline-header"): tdiv(class="timeline-header"):
@ -118,3 +121,8 @@ proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
renderSearchTabs(results.query) renderSearchTabs(results.query)
renderTimelineUsers(results, prefs) 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,12 +180,17 @@ func formatStat(stat: int): string =
if stat > 0: insertSep($stat, ',') if stat > 0: insertSep($stat, ',')
else: "" else: ""
proc renderStats(stats: TweetStats; views: string): VNode = proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
buildHtml(tdiv(class="tweet-stats")): buildHtml(tdiv(class="tweet-stats")):
a(href=getLink(tweet)):
span(class="tweet-stat"): icon "comment", formatStat(stats.replies) span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
a(href=getLink(tweet, false) & "/retweeters"):
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) 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) span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
a(href=getLink(tweet, false) & "/favoriters"):
span(class="tweet-stat"): icon "heart", formatStat(stats.likes) span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
a(href=getLink(tweet)):
if views.len > 0: if views.len > 0:
span(class="tweet-stat"): icon "play", insertSep(views, ',') span(class="tweet-stat"): icon "play", insertSep(views, ',')
@ -200,8 +205,7 @@ proc renderAttribution(user: User; prefs: Prefs): VNode =
buildHtml(a(class="attribution", href=("/" & user.username))): buildHtml(a(class="attribution", href=("/" & user.username))):
renderMiniAvatar(user, prefs) renderMiniAvatar(user, prefs)
strong: text user.fullname strong: text user.fullname
if user.verified: verifiedIcon(user)
icon "ok", class="verified-icon", title="Verified account"
proc renderMediaTags(tags: seq[User]): VNode = proc renderMediaTags(tags: seq[User]): VNode =
buildHtml(tdiv(class="media-tag-block")): buildHtml(tdiv(class="media-tag-block")):
@ -346,7 +350,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderMediaTags(tweet.mediaTags) renderMediaTags(tweet.mediaTags)
if not prefs.hideTweetStats: if not prefs.hideTweetStats:
renderStats(tweet.stats, views) renderStats(tweet.stats, views, tweet)
if showThread: if showThread:
a(class="show-thread", href=("/i/status/" & $tweet.threadId)): 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"""], What are we doing wrong? reuters.com/article/us-norwa"""],
['nim_lang/status/1491461266849808397#m', ['nim_lang/status/1491461266849808397#m',
'Nim language', '@nim_lang', 'Nim', '@nim_lang',
"""What's better than Nim 1.6.0? """What's better than Nim 1.6.0?
Nim 1.6.2 :) 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 from parameterized import parameterized
# image = tweet + 'div.attachments.media-body > div > div > a > div > img' # image = tweet + 'div.attachments.media-body > div > div > a > div > img'
@ -35,7 +35,16 @@ multiline = [
CALM CALM
AND AND
CLICHÉ 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 = [ link = [
@ -74,22 +83,18 @@ retweet = [
[3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr'] [3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr']
] ]
# reply = [
# ['mobile_test/with_replies', 15]
# ]
class TweetTest(BaseTestCase): class TweetTest(BaseTestCase):
# @parameterized.expand(timeline) @parameterized.expand(timeline)
# def test_timeline(self, index, fullname, username, date, tid, text): def test_timeline(self, index, fullname, username, date, tid, text):
# self.open_nitter(username) self.open_nitter(username)
# tweet = get_timeline_tweet(index) tweet = get_timeline_tweet(index)
# self.assert_exact_text(fullname, tweet.fullname) self.assert_exact_text(fullname, tweet.fullname)
# self.assert_exact_text('@' + username, tweet.username) self.assert_exact_text('@' + username, tweet.username)
# self.assert_exact_text(date, tweet.date) self.assert_exact_text(date, tweet.date)
# self.assert_text(text, tweet.text) self.assert_text(text, tweet.text)
# permalink = self.find_element(tweet.date + ' a') permalink = self.find_element(tweet.date + ' a')
# self.assertIn(tid, permalink.get_attribute('href')) self.assertIn(tid, permalink.get_attribute('href'))
@parameterized.expand(status) @parameterized.expand(status)
def test_status(self, tid, fullname, username, date, text): def test_status(self, tid, fullname, username, date, text):
@ -103,18 +108,18 @@ class TweetTest(BaseTestCase):
@parameterized.expand(multiline) @parameterized.expand(multiline)
def test_multiline_formatting(self, tid, username, text): def test_multiline_formatting(self, tid, username, text):
self.open_nitter(f'{username}/status/{tid}') 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) @parameterized.expand(emoji)
def test_emoji(self, tweet, text): def test_emoji(self, tweet, text):
self.open_nitter(tweet) self.open_nitter(tweet)
self.assert_text(text, '.main-tweet') self.assert_text(text, Conversation.main)
@parameterized.expand(link) @parameterized.expand(link)
def test_link(self, tweet, links): def test_link(self, tweet, links):
self.open_nitter(tweet) self.open_nitter(tweet)
for link in links: for link in links:
self.assert_text(link, '.main-tweet') self.assert_text(link, Conversation.main)
@parameterized.expand(username) @parameterized.expand(username)
def test_username(self, tweet, usernames): def test_username(self, tweet, usernames):
@ -123,22 +128,22 @@ class TweetTest(BaseTestCase):
link = self.find_link_text(f'@{un}') link = self.find_link_text(f'@{un}')
self.assertIn(f'/{un}', link.get_property('href')) self.assertIn(f'/{un}', link.get_property('href'))
# @parameterized.expand(retweet) @parameterized.expand(retweet)
# def test_retweet(self, index, url, retweet_by, fullname, username, text): def test_retweet(self, index, url, retweet_by, fullname, username, text):
# self.open_nitter(url) self.open_nitter(url)
# tweet = get_timeline_tweet(index) tweet = get_timeline_tweet(index)
# self.assert_text(f'{retweet_by} retweeted', tweet.retweet) self.assert_text(f'{retweet_by} retweeted', tweet.retweet)
# self.assert_text(text, tweet.text) self.assert_text(text, tweet.text)
# self.assert_exact_text(fullname, tweet.fullname) self.assert_exact_text(fullname, tweet.fullname)
# self.assert_exact_text(username, tweet.username) self.assert_exact_text(username, tweet.username)
@parameterized.expand(invalid) @parameterized.expand(invalid)
def test_invalid_id(self, tweet): def test_invalid_id(self, tweet):
self.open_nitter(tweet) self.open_nitter(tweet)
self.assert_text('Tweet not found', '.error-panel') self.assert_text('Tweet not found', '.error-panel')
# @parameterized.expand(reply) #@parameterized.expand(reply)
# def test_thread(self, tweet, num): #def test_thread(self, tweet, num):
# self.open_nitter(tweet) #self.open_nitter(tweet)
# thread = self.find_element(f'.timeline > div:nth-child({num})') #thread = self.find_element(f'.timeline > div:nth-child({num})')
# self.assertIn(thread.get_attribute('class'), 'thread-line') #self.assertIn(thread.get_attribute('class'), 'thread-line')