Compare commits
No commits in common. "ab48e5885161880438db4b0451e8bd2e568f14b0" and "5e188647fc5ddcc38084127f1db32f17f07fe727" have entirely different histories.
ab48e58851
...
5e188647fc
62
.github/workflows/build-docker.yml
vendored
Normal file
62
.github/workflows/build-docker.yml
vendored
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
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
|
60
.github/workflows/build-publish-docker.yml
vendored
60
.github/workflows/build-publish-docker.yml
vendored
|
@ -1,60 +0,0 @@
|
||||||
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
|
|
|
@ -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.14-r0" "nimble=0.13.1-r2"
|
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.16-r0" "nimble=0.13.1-r3"
|
||||||
|
|
||||||
WORKDIR /src/nitter
|
WORKDIR /src/nitter
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ version: "3"
|
||||||
services:
|
services:
|
||||||
|
|
||||||
nitter:
|
nitter:
|
||||||
image: ghcr.io/privacydevel/nitter:master
|
image: zedeus/nitter:latest
|
||||||
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
|
||||||
|
|
|
@ -6,7 +6,6 @@ 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)
|
||||||
|
@ -34,9 +33,6 @@ 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"
|
||||||
|
|
43
src/api.nim
43
src/api.nim
|
@ -69,13 +69,6 @@ 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
|
||||||
|
@ -93,42 +86,6 @@ 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
|
||||||
|
|
|
@ -3,7 +3,6 @@ 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"
|
||||||
|
@ -62,14 +61,7 @@ proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders =
|
||||||
"DNT": "1"
|
"DNT": "1"
|
||||||
})
|
})
|
||||||
|
|
||||||
template updateAccount() =
|
template fetchImpl(result, fetchBody) {.dirty.} =
|
||||||
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()
|
||||||
|
|
||||||
|
@ -80,19 +72,13 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
|
||||||
|
|
||||||
try:
|
try:
|
||||||
var resp: AsyncResponse
|
var resp: AsyncResponse
|
||||||
var headers = genHeaders($url, account.oauthToken, account.oauthSecret)
|
pool.use(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")
|
||||||
|
@ -147,16 +133,10 @@ 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; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
|
proc fetch*(url: Uri; api: Api): 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, additional_headers):
|
fetchImpl body:
|
||||||
if body.startsWith('{') or body.startsWith('['):
|
if body.startsWith('{') or body.startsWith('['):
|
||||||
result = parseJson(body)
|
result = parseJson(body)
|
||||||
else:
|
else:
|
||||||
|
@ -169,10 +149,9 @@ proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders
|
||||||
invalidate(account)
|
invalidate(account)
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
|
|
||||||
proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
|
proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} =
|
||||||
if cfg.readOnly: return
|
|
||||||
retry:
|
retry:
|
||||||
fetchImpl(result, additional_headers):
|
fetchImpl result:
|
||||||
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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
# 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)
|
||||||
|
@ -23,7 +22,6 @@ 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),
|
||||||
|
@ -42,13 +40,7 @@ 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)
|
|
||||||
|
|
|
@ -10,9 +10,6 @@ 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"
|
||||||
|
@ -26,20 +23,18 @@ 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": "0",
|
"include_quote_count": "1",
|
||||||
"include_reply_count": "0",
|
"include_reply_count": "1",
|
||||||
"include_user_entities": "0",
|
"include_user_entities": "1",
|
||||||
"include_ext_reply_count": "0",
|
"include_ext_reply_count": "1",
|
||||||
|
"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",
|
||||||
|
@ -121,15 +116,3 @@ 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
|
|
||||||
}"""
|
|
||||||
|
|
|
@ -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, VerifiedType, Result, Query, QueryKind
|
from ../../types import User, 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,10 +12,9 @@ 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 = raw.data.userResult.result.legacy
|
result = toUser raw.data.userResult.result.legacy
|
||||||
result.id = raw.data.userResult.result.restId
|
result.id = raw.data.userResult.result.restId
|
||||||
if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified:
|
result.verified = result.verified or 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](
|
||||||
|
@ -31,7 +30,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 userResult.legacy
|
result.content.add toUser 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
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import std/[options, tables, strutils, strformat, sugar]
|
import std/[options, tables, strutils, strformat, sugar]
|
||||||
import jsony
|
import jsony
|
||||||
import user, ../types/unifiedcard
|
import ../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,14 +27,6 @@ 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]
|
||||||
|
|
||||||
|
@ -92,8 +84,6 @@ 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:
|
||||||
|
|
|
@ -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,
|
||||||
verifiedType: raw.verifiedType,
|
verified: raw.verified or raw.extIsBlueVerified,
|
||||||
protected: raw.protected,
|
protected: raw.protected,
|
||||||
joinDate: parseTwitterDate(raw.createdAt),
|
joinDate: parseTwitterDate(raw.createdAt),
|
||||||
banner: getBanner(raw),
|
banner: getBanner(raw),
|
||||||
|
@ -68,11 +68,6 @@ 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
|
||||||
|
@ -80,7 +75,7 @@ proc parseUser*(json: string; username=""): User =
|
||||||
of userNotFound: return
|
of userNotFound: return
|
||||||
else: echo "[error - parseUser]: ", error
|
else: echo "[error - parseUser]: ", error
|
||||||
|
|
||||||
result = json.fromJson(User)
|
result = toUser json.fromJson(RawUser)
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import options
|
import options
|
||||||
from ../../types import User
|
import user
|
||||||
|
|
||||||
type
|
type
|
||||||
GraphUser* = object
|
GraphUser* = object
|
||||||
|
@ -9,7 +9,7 @@ type
|
||||||
result*: UserResult
|
result*: UserResult
|
||||||
|
|
||||||
UserResult = object
|
UserResult = object
|
||||||
legacy*: User
|
legacy*: RawUser
|
||||||
restId*: string
|
restId*: string
|
||||||
isBlueVerified*: bool
|
isBlueVerified*: bool
|
||||||
unavailableReason*: Option[string]
|
unavailableReason*: Option[string]
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import std/tables
|
import std/tables
|
||||||
from ../../types import User
|
import user
|
||||||
|
|
||||||
type
|
type
|
||||||
Search* = object
|
Search* = object
|
||||||
|
@ -7,7 +7,7 @@ type
|
||||||
timeline*: Timeline
|
timeline*: Timeline
|
||||||
|
|
||||||
GlobalObjects = object
|
GlobalObjects = object
|
||||||
users*: Table[string, User]
|
users*: Table[string, RawUser]
|
||||||
|
|
||||||
Timeline = object
|
Timeline = object
|
||||||
instructions*: seq[Instructions]
|
instructions*: seq[Instructions]
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import std/[options, tables, times]
|
import options, tables
|
||||||
import jsony
|
from ../../types import VideoType, VideoVariant
|
||||||
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]
|
||||||
|
@ -16,7 +13,6 @@ type
|
||||||
media
|
media
|
||||||
swipeableMedia
|
swipeableMedia
|
||||||
buttonGroup
|
buttonGroup
|
||||||
jobDetails
|
|
||||||
appStoreDetails
|
appStoreDetails
|
||||||
twitterListDetails
|
twitterListDetails
|
||||||
communityDetails
|
communityDetails
|
||||||
|
@ -33,15 +29,12 @@ 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
|
||||||
|
@ -76,9 +69,12 @@ 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 = string(text)
|
converter fromText*(text: Text): string = text.content
|
||||||
|
|
||||||
proc renameHook*(v: var TypeField; fieldName: var string) =
|
proc renameHook*(v: var TypeField; fieldName: var string) =
|
||||||
if fieldName == "type":
|
if fieldName == "type":
|
||||||
|
@ -90,7 +86,6 @@ 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
|
||||||
|
@ -111,18 +106,3 @@ 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)
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import options
|
import options
|
||||||
import common
|
import common
|
||||||
from ../../types import VerifiedType
|
|
||||||
|
|
||||||
type
|
type
|
||||||
RawUser* = object
|
RawUser* = object
|
||||||
|
@ -16,7 +15,8 @@ type
|
||||||
favouritesCount*: int
|
favouritesCount*: int
|
||||||
statusesCount*: int
|
statusesCount*: int
|
||||||
mediaCount*: int
|
mediaCount*: int
|
||||||
verifiedType*: VerifiedType
|
verified*: bool
|
||||||
|
extIsBlueVerified*: bool
|
||||||
protected*: bool
|
protected*: bool
|
||||||
profileLinkColor*: string
|
profileLinkColor*: string
|
||||||
profileBannerUrl*: string
|
profileBannerUrl*: string
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
# 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
|
||||||
|
@ -17,6 +16,9 @@ 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)
|
||||||
|
@ -27,12 +29,9 @@ 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)
|
||||||
|
|
163
src/parser.nim
163
src/parser.nim
|
@ -3,7 +3,6 @@ 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
|
||||||
|
|
||||||
|
@ -22,7 +21,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,
|
||||||
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
|
verified: js{"verified"}.getBool or js{"ext_is_blue_verified"}.getBool,
|
||||||
protected: js{"protected"}.getBool,
|
protected: js{"protected"}.getBool,
|
||||||
joinDate: js{"created_at"}.getTime
|
joinDate: js{"created_at"}.getTime
|
||||||
)
|
)
|
||||||
|
@ -35,8 +34,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 result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
|
if "is_blue_verified" in user:
|
||||||
result.verifiedType = blue
|
result.verified = user{"is_blue_verified"}.getBool()
|
||||||
|
|
||||||
proc parseGraphList*(js: JsonNode): List =
|
proc parseGraphList*(js: JsonNode): List =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
|
@ -220,6 +219,8 @@ 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
|
||||||
|
@ -237,11 +238,8 @@ 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
|
||||||
var rt_tweet = rt
|
if "legacy" in rt:
|
||||||
if "tweet" in rt:
|
result.retweet = some parseGraphTweet(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:
|
||||||
|
@ -256,8 +254,6 @@ 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
|
||||||
|
@ -293,121 +289,6 @@ 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.":
|
||||||
|
@ -564,36 +445,6 @@ 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)
|
||||||
|
|
||||||
|
|
|
@ -1,17 +1,9 @@
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import std/[times, macros, htmlgen, options, algorithm, re]
|
import std/[strutils, 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>"
|
||||||
|
@ -246,7 +238,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=""; hasRedundantLink=false) =
|
replyTo=""; hasQuote=false) =
|
||||||
let hasCard = tweet.card.isSome
|
let hasCard = tweet.card.isSome
|
||||||
|
|
||||||
var replacements = newSeq[ReplaceSlice]()
|
var replacements = newSeq[ReplaceSlice]()
|
||||||
|
@ -257,7 +249,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 = hasRedundantLink)
|
replacements.extractUrls(u, textSlice.b, hideTwitter = hasQuote)
|
||||||
|
|
||||||
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
|
||||||
|
@ -297,10 +289,9 @@ 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:
|
||||||
|
@ -308,14 +299,12 @@ 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 or hasJobCard)
|
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote)
|
||||||
|
|
||||||
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.multiReplace(("<", unicodeOpen), (">", unicodeClose))
|
text = js{"text"}.getStr
|
||||||
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))
|
|
||||||
|
|
|
@ -40,13 +40,6 @@ 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,
|
||||||
|
|
|
@ -12,7 +12,6 @@ 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
|
||||||
|
@ -30,9 +29,6 @@ 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)
|
||||||
|
@ -56,7 +52,6 @@ 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
|
||||||
|
@ -71,8 +66,6 @@ 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):
|
||||||
|
@ -106,11 +99,6 @@ 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):
|
||||||
|
@ -129,7 +117,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 and not readOnly:
|
if result == redisNil:
|
||||||
let user = await getGraphUser(username)
|
let user = await getGraphUser(username)
|
||||||
if user.suspended:
|
if user.suspended:
|
||||||
return "suspended"
|
return "suspended"
|
||||||
|
@ -141,7 +129,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 and not readOnly:
|
elif fetch:
|
||||||
result = await getGraphUser(username)
|
result = await getGraphUser(username)
|
||||||
await cache(result)
|
await cache(result)
|
||||||
|
|
||||||
|
@ -152,39 +140,29 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
||||||
|
|
||||||
if username != redisNil:
|
if username != redisNil:
|
||||||
result = username
|
result = username
|
||||||
elif not readOnly:
|
else:
|
||||||
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)
|
||||||
elif not readOnly:
|
# else:
|
||||||
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)
|
||||||
elif not readOnly:
|
else:
|
||||||
result = await getPhotoRail(name)
|
result = await getPhotoRail(name)
|
||||||
await cache(result, name)
|
await cache(result, name)
|
||||||
|
|
||||||
|
@ -194,7 +172,7 @@ proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||||
|
|
||||||
if list != redisNil:
|
if list != redisNil:
|
||||||
list.deserialize(List)
|
list.deserialize(List)
|
||||||
elif not readOnly:
|
else:
|
||||||
if id.len > 0:
|
if id.len > 0:
|
||||||
result = await getGraphList(id)
|
result = await getGraphList(id)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -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, cfg, skipRail=true, skipPinned=true)
|
profile = await fetchProfile(after, query, 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", "favorites", "search"]
|
cond @"tab" in ["with_replies", "media", "search"]
|
||||||
let
|
let
|
||||||
name = @"name"
|
name = @"name"
|
||||||
tab = @"tab"
|
tab = @"tab"
|
||||||
|
@ -110,8 +110,7 @@ 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 "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])
|
||||||
|
|
||||||
let searchKey = if tab != "search": ""
|
let searchKey = if tab != "search": ""
|
||||||
|
|
|
@ -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, redis_cache]
|
import ".."/[types, formatters, api]
|
||||||
import ../views/[general, status, search]
|
import ../views/[general, status]
|
||||||
|
|
||||||
export uri, sequtils, options, sugar
|
export uri, sequtils, options, sugar
|
||||||
export router_utils
|
export router_utils
|
||||||
|
@ -14,29 +14,6 @@ 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"
|
||||||
|
@ -53,7 +30,7 @@ proc createStatusRouter*(cfg: Config) =
|
||||||
resp Http404, ""
|
resp Http404, ""
|
||||||
resp $renderReplies(replies, prefs, getPath())
|
resp $renderReplies(replies, prefs, getPath())
|
||||||
|
|
||||||
let conv = await getCachedConvo(id, getCursor())
|
let conv = await getTweet(id, getCursor())
|
||||||
if conv == nil:
|
if conv == nil:
|
||||||
echo "nil conv"
|
echo "nil conv"
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,6 @@ 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])
|
||||||
|
|
||||||
|
@ -28,7 +27,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
||||||
else:
|
else:
|
||||||
body
|
body
|
||||||
|
|
||||||
proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
|
proc fetchProfile*(after: string; query: Query; skipRail=false;
|
||||||
skipPinned=false): Future[Profile] {.async.} =
|
skipPinned=false): Future[Profile] {.async.} =
|
||||||
let
|
let
|
||||||
name = query.fromUser[0]
|
name = query.fromUser[0]
|
||||||
|
@ -57,7 +56,6 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; 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
|
||||||
|
@ -73,7 +71,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, cfg, skipPinned=prefs.hidePins)
|
var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins)
|
||||||
template u: untyped = profile.user
|
template u: untyped = profile.user
|
||||||
|
|
||||||
if u.suspended:
|
if u.suspended:
|
||||||
|
@ -81,7 +79,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, cfg, prefs, getPath())
|
let pHtml = renderProfile(profile, 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)
|
||||||
|
@ -111,42 +109,35 @@ 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", "favorites", "following", "followers", ""]
|
cond @"tab" in ["with_replies", "media", "search", ""]
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = cookiePrefs()
|
||||||
after = getCursor()
|
after = getCursor()
|
||||||
names = getNames(@"name")
|
names = getNames(@"name")
|
||||||
tab = @"tab"
|
|
||||||
|
|
||||||
case tab:
|
var query = request.getQuery(@"tab", @"name")
|
||||||
of "followers":
|
if names.len != 1:
|
||||||
resp renderMain(renderUserList(await getGraphFollowers(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
query.fromUser = names
|
||||||
of "following":
|
|
||||||
resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
# 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:
|
else:
|
||||||
var query = request.getQuery(@"tab", @"name")
|
var profile = await fetchProfile(after, query, skipRail=true)
|
||||||
if names.len != 1:
|
if profile.tweets.content.len == 0: resp Http404
|
||||||
query.fromUser = names
|
profile.tweets.beginning = true
|
||||||
|
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
|
||||||
|
|
||||||
# used for the infinite scroll feature
|
let rss =
|
||||||
if @"scroll".len > 0:
|
if @"tab".len == 0:
|
||||||
if query.fromUser.len != 1:
|
"/$1/rss" % @"name"
|
||||||
var timeline = await getGraphTweetSearch(query, after)
|
elif @"tab" == "search":
|
||||||
if timeline.content.len == 0: resp Http404
|
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
|
||||||
timeline.beginning = true
|
else:
|
||||||
resp $renderTweetSearch(timeline, prefs, getPath())
|
"/$1/$2/rss" % [@"name", @"tab"]
|
||||||
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())
|
|
||||||
|
|
||||||
let rss =
|
respTimeline(await showTimeline(request, query, cfg, prefs, rss, after))
|
||||||
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))
|
|
||||||
|
|
|
@ -28,8 +28,6 @@ $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;
|
||||||
|
|
|
@ -39,8 +39,6 @@ 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};
|
||||||
|
@ -143,30 +141,17 @@ 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: 3px;
|
padding-top: 2px;
|
||||||
height: 11px;
|
height: 12px;
|
||||||
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) {
|
||||||
|
|
|
@ -14,8 +14,6 @@
|
||||||
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 {
|
||||||
|
|
|
@ -207,7 +207,6 @@
|
||||||
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 {
|
||||||
|
|
|
@ -10,7 +10,9 @@ type
|
||||||
BadClientError* = object of CatchableError
|
BadClientError* = object of CatchableError
|
||||||
|
|
||||||
TimelineKind* {.pure.} = enum
|
TimelineKind* {.pure.} = enum
|
||||||
tweets, replies, media
|
tweets
|
||||||
|
replies
|
||||||
|
media
|
||||||
|
|
||||||
Api* {.pure.} = enum
|
Api* {.pure.} = enum
|
||||||
tweetDetail
|
tweetDetail
|
||||||
|
@ -23,14 +25,9 @@ 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
|
||||||
|
@ -66,12 +63,6 @@ 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
|
||||||
|
@ -87,7 +78,7 @@ type
|
||||||
tweets*: int
|
tweets*: int
|
||||||
likes*: int
|
likes*: int
|
||||||
media*: int
|
media*: int
|
||||||
verifiedType*: VerifiedType
|
verified*: bool
|
||||||
protected*: bool
|
protected*: bool
|
||||||
suspended*: bool
|
suspended*: bool
|
||||||
joinDate*: DateTime
|
joinDate*: DateTime
|
||||||
|
@ -116,7 +107,7 @@ type
|
||||||
variants*: seq[VideoVariant]
|
variants*: seq[VideoVariant]
|
||||||
|
|
||||||
QueryKind* = enum
|
QueryKind* = enum
|
||||||
posts, replies, media, users, tweets, userList, favorites
|
posts, replies, media, users, tweets, userList
|
||||||
|
|
||||||
Query* = object
|
Query* = object
|
||||||
kind*: QueryKind
|
kind*: QueryKind
|
||||||
|
@ -171,7 +162,6 @@ 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
|
||||||
|
|
||||||
|
@ -236,7 +226,6 @@ type
|
||||||
replies*: Result[Chain]
|
replies*: Result[Chain]
|
||||||
|
|
||||||
Timeline* = Result[Tweets]
|
Timeline* = Result[Tweets]
|
||||||
UsersTimeline* = Result[User]
|
|
||||||
|
|
||||||
Profile* = object
|
Profile* = object
|
||||||
user*: User
|
user*: User
|
||||||
|
@ -265,7 +254,6 @@ type
|
||||||
title*: string
|
title*: string
|
||||||
hostname*: string
|
hostname*: string
|
||||||
staticDir*: string
|
staticDir*: string
|
||||||
readOnly*: bool
|
|
||||||
|
|
||||||
hmacKey*: string
|
hmacKey*: string
|
||||||
base64Media*: bool
|
base64Media*: bool
|
||||||
|
@ -284,9 +272,6 @@ type
|
||||||
redisMaxConns*: int
|
redisMaxConns*: int
|
||||||
redisPassword*: string
|
redisPassword*: string
|
||||||
|
|
||||||
cookieHeader*: string
|
|
||||||
xCsrfToken*: string
|
|
||||||
|
|
||||||
Rss* = object
|
Rss* = object
|
||||||
feed*, cursor*: string
|
feed*, cursor*: string
|
||||||
|
|
||||||
|
|
|
@ -16,8 +16,7 @@ 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) =
|
||||||
|
@ -58,4 +57,4 @@ proc isTwitterUrl*(uri: Uri): bool =
|
||||||
uri.hostname in twitterDomains
|
uri.hostname in twitterDomains
|
||||||
|
|
||||||
proc isTwitterUrl*(url: string): bool =
|
proc isTwitterUrl*(url: string): bool =
|
||||||
isTwitterUrl(parseUri(url))
|
parseUri(url).hostname in twitterDomains
|
||||||
|
|
|
@ -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 if cfg.readOnly: cfg.title & " (readonly)" else: cfg.title
|
a(class="site-name", href="/"): text 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=19")
|
link(rel="stylesheet", type="text/css", href="/css/style.css?v=18")
|
||||||
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:
|
||||||
|
|
|
@ -58,14 +58,10 @@ 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")
|
renderStat(user.following, "following")
|
||||||
a(href="/" & user.username & "/following"):
|
renderStat(user.followers, "followers")
|
||||||
renderStat(user.following, "following")
|
renderStat(user.likes, "likes")
|
||||||
a(href="/" & user.username & "/followers"):
|
|
||||||
renderStat(user.followers, "followers")
|
|
||||||
a(href="/" & user.username & "/favorites"):
|
|
||||||
renderStat(user.likes, "likes")
|
|
||||||
|
|
||||||
proc renderPhotoRail(profile: Profile): VNode =
|
proc renderPhotoRail(profile: Profile): VNode =
|
||||||
let count = insertSep($profile.user.media, ',')
|
let count = insertSep($profile.user.media, ',')
|
||||||
|
@ -103,7 +99,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; cfg: Config; prefs: Prefs; path: string): VNode =
|
proc renderProfile*(profile: var Profile; 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")):
|
||||||
|
|
|
@ -23,13 +23,6 @@ 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
|
||||||
|
@ -39,11 +32,11 @@ 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:
|
if isName and user.verified:
|
||||||
verifiedIcon(user)
|
icon "ok", class="verified-icon", title="Verified account"
|
||||||
if user.protected:
|
if isName and user.protected:
|
||||||
text " "
|
text " "
|
||||||
icon "lock", title="Protected account"
|
icon "lock", title="Protected account"
|
||||||
|
|
||||||
proc linkText*(text: string; class=""): VNode =
|
proc linkText*(text: string; class=""): VNode =
|
||||||
let url = if "http" notin text: https & text else: text
|
let url = if "http" notin text: https & text else: text
|
||||||
|
|
|
@ -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, config]
|
import ".."/[types, query]
|
||||||
|
|
||||||
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; cfg: Config): VNode =
|
proc renderProfileTabs*(query: Query; username: string): 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,9 +38,6 @@ proc renderProfileTabs*(query: Query; username: string; cfg: Config): 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"
|
||||||
|
|
||||||
|
@ -100,7 +97,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(","), cfg)
|
renderProfileTabs(query, query.fromUser.join(","))
|
||||||
|
|
||||||
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"):
|
||||||
|
@ -121,8 +118,3 @@ 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)
|
|
||||||
|
|
|
@ -180,19 +180,14 @@ func formatStat(stat: int): string =
|
||||||
if stat > 0: insertSep($stat, ',')
|
if stat > 0: insertSep($stat, ',')
|
||||||
else: ""
|
else: ""
|
||||||
|
|
||||||
proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
|
proc renderStats(stats: TweetStats; views: string): 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)
|
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
||||||
a(href=getLink(tweet, false) & "/retweeters"):
|
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
||||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||||||
a(href="/search?q=quoted_tweet_id:" & $tweet.id):
|
if views.len > 0:
|
||||||
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
||||||
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 =
|
proc renderReply(tweet: Tweet): VNode =
|
||||||
buildHtml(tdiv(class="replying-to")):
|
buildHtml(tdiv(class="replying-to")):
|
||||||
|
@ -205,7 +200,8 @@ 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
|
||||||
verifiedIcon(user)
|
if user.verified:
|
||||||
|
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")):
|
||||||
|
@ -350,7 +346,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, tweet)
|
renderStats(tweet.stats, views)
|
||||||
|
|
||||||
if showThread:
|
if showThread:
|
||||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
||||||
|
|
|
@ -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', '@nim_lang',
|
'Nim language', '@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 :)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from base import BaseTestCase, Tweet, Conversation, get_timeline_tweet
|
from base import BaseTestCase, Tweet, 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,16 +35,7 @@ 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 = [
|
||||||
|
@ -83,18 +74,22 @@ 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):
|
||||||
|
@ -108,18 +103,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'), Conversation.main)
|
self.assert_text(text.strip('\n'), '.main-tweet')
|
||||||
|
|
||||||
@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, Conversation.main)
|
self.assert_text(text, '.main-tweet')
|
||||||
|
|
||||||
@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, Conversation.main)
|
self.assert_text(link, '.main-tweet')
|
||||||
|
|
||||||
@parameterized.expand(username)
|
@parameterized.expand(username)
|
||||||
def test_username(self, tweet, usernames):
|
def test_username(self, tweet, usernames):
|
||||||
|
@ -128,22 +123,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')
|
||||||
|
|
Loading…
Reference in a new issue