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
|
||||
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
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ version: "3"
|
|||
services:
|
||||
|
||||
nitter:
|
||||
image: ghcr.io/privacydevel/nitter:master
|
||||
image: zedeus/nitter:latest
|
||||
container_name: nitter
|
||||
ports:
|
||||
- "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
|
||||
httpMaxConnections = 100
|
||||
staticDir = "./public"
|
||||
readOnly = false # setting to true prevents nitter from reaching out to twitter and will only use redis data
|
||||
|
||||
[Cache]
|
||||
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
|
||||
|
@ -34,9 +33,6 @@ tokenCount = 10
|
|||
# always at least `tokenCount` usable tokens. only increase this if you receive
|
||||
# major bursts all the time and don't have a rate limiting setup via e.g. nginx
|
||||
|
||||
#cookieHeader = "ct0=XXXXXXXXXXXXXXXXX; auth_token=XXXXXXXXXXXXXX" # authentication cookie of a logged in account, required for the likes tab and NSFW content
|
||||
#xCsrfToken = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # required for the likes tab and NSFW content
|
||||
|
||||
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
||||
[Preferences]
|
||||
theme = "Nitter"
|
||||
|
|
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}
|
||||
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
|
||||
|
||||
proc getFavorites*(id: string; cfg: Config; after=""): Future[Profile] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
ps = genParams({"userId": id}, after)
|
||||
url = consts.favorites / (id & ".json") ? ps
|
||||
result = parseTimeline(await fetch(url, Api.favorites), after)
|
||||
|
||||
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
|
@ -93,42 +86,6 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
|||
js = await fetch(graphTweet ? params, Api.tweetDetail)
|
||||
result = parseGraphConversation(js, id)
|
||||
|
||||
proc getGraphFavoriters*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = reactorsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphFavoriters ? params, Api.favoriters)
|
||||
result = parseGraphFavoritersTimeline(js, id)
|
||||
|
||||
proc getGraphRetweeters*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = reactorsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphRetweeters ? params, Api.retweeters)
|
||||
result = parseGraphRetweetersTimeline(js, id)
|
||||
|
||||
proc getGraphFollowing*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = followVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphFollowing ? params, Api.following)
|
||||
result = parseGraphFollowTimeline(js, id)
|
||||
|
||||
proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = followVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphFollowers ? params, Api.followers)
|
||||
result = parseGraphFollowTimeline(js, id)
|
||||
|
||||
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
||||
result = (await getGraphTweet(id, after)).replies
|
||||
result.beginning = after.len == 0
|
||||
|
|
|
@ -3,7 +3,6 @@ import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
|
|||
import jsony, packedjson, zippy, oauth1
|
||||
import types, auth, consts, parserutils, http_pool
|
||||
import experimental/types/common
|
||||
import config
|
||||
|
||||
const
|
||||
rlRemaining = "x-rate-limit-remaining"
|
||||
|
@ -62,14 +61,7 @@ proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders =
|
|||
"DNT": "1"
|
||||
})
|
||||
|
||||
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.} =
|
||||
template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
once:
|
||||
pool = HttpPool()
|
||||
|
||||
|
@ -80,19 +72,13 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
|
|||
|
||||
try:
|
||||
var resp: AsyncResponse
|
||||
var headers = genHeaders($url, account.oauthToken, account.oauthSecret)
|
||||
for key, value in additional_headers.pairs():
|
||||
headers.add(key, value)
|
||||
pool.use(headers):
|
||||
pool.use(genHeaders($url, account.oauthToken, account.oauthSecret)):
|
||||
template getContent =
|
||||
resp = await c.get($url)
|
||||
result = await resp.body
|
||||
|
||||
getContent()
|
||||
|
||||
if resp.status == $Http429:
|
||||
raise rateLimitError()
|
||||
|
||||
if resp.status == $Http503:
|
||||
badClient = true
|
||||
raise newException(BadClientError, "Bad client")
|
||||
|
@ -147,16 +133,10 @@ template retry(bod) =
|
|||
echo "[accounts] Rate limited, retrying ", api, " request..."
|
||||
bod
|
||||
|
||||
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)
|
||||
|
||||
proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
|
||||
retry:
|
||||
var body: string
|
||||
fetchImpl(body, additional_headers):
|
||||
fetchImpl body:
|
||||
if body.startsWith('{') or body.startsWith('['):
|
||||
result = parseJson(body)
|
||||
else:
|
||||
|
@ -169,10 +149,9 @@ proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders
|
|||
invalidate(account)
|
||||
raise rateLimitError()
|
||||
|
||||
proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
|
||||
if cfg.readOnly: return
|
||||
proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} =
|
||||
retry:
|
||||
fetchImpl(result, additional_headers):
|
||||
fetchImpl result:
|
||||
if not (result.startsWith('{') or result.startsWith('[')):
|
||||
echo resp.status, ": ", result, " --- url: ", url
|
||||
result.setLen(0)
|
||||
|
|
|
@ -202,7 +202,7 @@ proc initAccountPool*(cfg: Config; path: string) =
|
|||
quit 1
|
||||
|
||||
let accountsPrePurge = accountPool.len
|
||||
#accountPool.keepItIf(not it.hasExpired)
|
||||
accountPool.keepItIf(not it.hasExpired)
|
||||
|
||||
log "Successfully added ", accountPool.len, " valid accounts."
|
||||
if accountsPrePurge > accountPool.len:
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import parsecfg except Config
|
||||
import types, strutils
|
||||
from os import getEnv
|
||||
|
||||
proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
|
||||
let val = config.getSectionValue(section, key)
|
||||
|
@ -23,7 +22,6 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
|||
staticDir: cfg.get("Server", "staticDir", "./public"),
|
||||
title: cfg.get("Server", "title", "Nitter"),
|
||||
hostname: cfg.get("Server", "hostname", "nitter.net"),
|
||||
readOnly: cfg.get("Server", "readOnly", false),
|
||||
|
||||
# Cache
|
||||
listCacheTime: cfg.get("Cache", "listMinutes", 120),
|
||||
|
@ -42,13 +40,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
|||
enableRss: cfg.get("Config", "enableRSS", true),
|
||||
enableDebug: cfg.get("Config", "enableDebug", false),
|
||||
proxy: cfg.get("Config", "proxy", ""),
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||
cookieHeader: cfg.get("Config", "cookieHeader", ""),
|
||||
xCsrfToken: cfg.get("Config", "xCsrfToken", "")
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", "")
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
timelineApi = api / "2/timeline"
|
||||
favorites* = timelineApi / "favorites"
|
||||
|
||||
graphql = api / "graphql"
|
||||
graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
|
||||
graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
|
||||
|
@ -26,20 +23,18 @@ const
|
|||
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
|
||||
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
|
||||
graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
|
||||
graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters"
|
||||
graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters"
|
||||
graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers"
|
||||
graphFollowing* = graphql / "JPZiqKjET7_M1r5Tlr8pyA/Following"
|
||||
|
||||
timelineParams* = {
|
||||
"include_can_media_tag": "1",
|
||||
"include_cards": "1",
|
||||
"include_entities": "1",
|
||||
"include_profile_interstitial_type": "0",
|
||||
"include_quote_count": "0",
|
||||
"include_reply_count": "0",
|
||||
"include_user_entities": "0",
|
||||
"include_ext_reply_count": "0",
|
||||
"include_quote_count": "1",
|
||||
"include_reply_count": "1",
|
||||
"include_user_entities": "1",
|
||||
"include_ext_reply_count": "1",
|
||||
"include_ext_is_blue_verified": "1",
|
||||
# "include_ext_verified_type": "1",
|
||||
"include_ext_media_color": "0",
|
||||
"cards_platform": "Web-13",
|
||||
"tweet_mode": "extended",
|
||||
|
@ -121,15 +116,3 @@ const
|
|||
"rest_id": "$1", $2
|
||||
"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 jsony
|
||||
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 =
|
||||
if json.len == 0 or json[0] != '{':
|
||||
|
@ -12,10 +12,9 @@ proc parseGraphUser*(json: string): User =
|
|||
if raw.data.userResult.result.unavailableReason.get("") == "Suspended":
|
||||
return User(suspended: true)
|
||||
|
||||
result = raw.data.userResult.result.legacy
|
||||
result = toUser raw.data.userResult.result.legacy
|
||||
result.id = raw.data.userResult.result.restId
|
||||
if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified:
|
||||
result.verifiedType = blue
|
||||
result.verified = result.verified or raw.data.userResult.result.isBlueVerified
|
||||
|
||||
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||
result = Result[User](
|
||||
|
@ -31,7 +30,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
|||
of TimelineTimelineItem:
|
||||
let userResult = entry.content.itemContent.userResults.result
|
||||
if userResult.restId.len > 0:
|
||||
result.content.add userResult.legacy
|
||||
result.content.add toUser userResult.legacy
|
||||
of TimelineTimelineCursor:
|
||||
if entry.content.cursorType == "Bottom":
|
||||
result.bottom = entry.content.value
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import std/[options, tables, strutils, strformat, sugar]
|
||||
import jsony
|
||||
import user, ../types/unifiedcard
|
||||
import ../types/unifiedcard
|
||||
from ../../types import Card, CardKind, Video
|
||||
from ../../utils import twimg, https
|
||||
|
||||
|
@ -27,14 +27,6 @@ proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card)
|
|||
result.text = data.topicDetail.title
|
||||
result.dest = "Topic"
|
||||
|
||||
proc parseJobDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||
data.destination.parseDestination(card, result)
|
||||
|
||||
result.kind = CardKind.jobDetails
|
||||
result.title = data.title
|
||||
result.text = data.shortDescriptionText
|
||||
result.dest = &"@{data.profileUser.username} · {data.location}"
|
||||
|
||||
proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||
let app = card.appStoreData[data.appId][0]
|
||||
|
||||
|
@ -92,8 +84,6 @@ proc parseUnifiedCard*(json: string): Card =
|
|||
component.parseMedia(card, result)
|
||||
of buttonGroup:
|
||||
discard
|
||||
of ComponentType.jobDetails:
|
||||
component.data.parseJobDetails(card, result)
|
||||
of ComponentType.hidden:
|
||||
result.kind = CardKind.hidden
|
||||
of ComponentType.unknown:
|
||||
|
|
|
@ -56,7 +56,7 @@ proc toUser*(raw: RawUser): User =
|
|||
tweets: raw.statusesCount,
|
||||
likes: raw.favouritesCount,
|
||||
media: raw.mediaCount,
|
||||
verifiedType: raw.verifiedType,
|
||||
verified: raw.verified or raw.extIsBlueVerified,
|
||||
protected: raw.protected,
|
||||
joinDate: parseTwitterDate(raw.createdAt),
|
||||
banner: getBanner(raw),
|
||||
|
@ -68,11 +68,6 @@ proc toUser*(raw: RawUser): User =
|
|||
|
||||
result.expandUserEntities(raw)
|
||||
|
||||
proc parseHook*(s: string; i: var int; v: var User) =
|
||||
var u: RawUser
|
||||
parseHook(s, i, u)
|
||||
v = toUser u
|
||||
|
||||
proc parseUser*(json: string; username=""): User =
|
||||
handleErrors:
|
||||
case error.code
|
||||
|
@ -80,7 +75,7 @@ proc parseUser*(json: string; username=""): User =
|
|||
of userNotFound: return
|
||||
else: echo "[error - parseUser]: ", error
|
||||
|
||||
result = json.fromJson(User)
|
||||
result = toUser json.fromJson(RawUser)
|
||||
|
||||
proc parseUsers*(json: string; after=""): Result[User] =
|
||||
result = Result[User](beginning: after.len == 0)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import options
|
||||
from ../../types import User
|
||||
import user
|
||||
|
||||
type
|
||||
GraphUser* = object
|
||||
|
@ -9,7 +9,7 @@ type
|
|||
result*: UserResult
|
||||
|
||||
UserResult = object
|
||||
legacy*: User
|
||||
legacy*: RawUser
|
||||
restId*: string
|
||||
isBlueVerified*: bool
|
||||
unavailableReason*: Option[string]
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import std/tables
|
||||
from ../../types import User
|
||||
import user
|
||||
|
||||
type
|
||||
Search* = object
|
||||
|
@ -7,7 +7,7 @@ type
|
|||
timeline*: Timeline
|
||||
|
||||
GlobalObjects = object
|
||||
users*: Table[string, User]
|
||||
users*: Table[string, RawUser]
|
||||
|
||||
Timeline = object
|
||||
instructions*: seq[Instructions]
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import std/[options, tables, times]
|
||||
import jsony
|
||||
from ../../types import VideoType, VideoVariant, User
|
||||
import options, tables
|
||||
from ../../types import VideoType, VideoVariant
|
||||
|
||||
type
|
||||
Text* = distinct string
|
||||
|
||||
UnifiedCard* = object
|
||||
componentObjects*: Table[string, Component]
|
||||
destinationObjects*: Table[string, Destination]
|
||||
|
@ -16,7 +13,6 @@ type
|
|||
media
|
||||
swipeableMedia
|
||||
buttonGroup
|
||||
jobDetails
|
||||
appStoreDetails
|
||||
twitterListDetails
|
||||
communityDetails
|
||||
|
@ -33,15 +29,12 @@ type
|
|||
appId*: string
|
||||
mediaId*: string
|
||||
destination*: string
|
||||
location*: string
|
||||
title*: Text
|
||||
subtitle*: Text
|
||||
name*: Text
|
||||
memberCount*: int
|
||||
mediaList*: seq[MediaItem]
|
||||
topicDetail*: tuple[title: Text]
|
||||
profileUser*: User
|
||||
shortDescriptionText*: string
|
||||
|
||||
MediaItem* = object
|
||||
id*: string
|
||||
|
@ -76,9 +69,12 @@ type
|
|||
title*: Text
|
||||
category*: Text
|
||||
|
||||
Text = object
|
||||
content: string
|
||||
|
||||
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) =
|
||||
if fieldName == "type":
|
||||
|
@ -90,7 +86,6 @@ proc enumHook*(s: string; v: var ComponentType) =
|
|||
of "media": media
|
||||
of "swipeable_media": swipeableMedia
|
||||
of "button_group": buttonGroup
|
||||
of "job_details": jobDetails
|
||||
of "app_store_details": appStoreDetails
|
||||
of "twitter_list_details": twitterListDetails
|
||||
of "community_details": communityDetails
|
||||
|
@ -111,18 +106,3 @@ proc enumHook*(s: string; v: var MediaType) =
|
|||
of "photo": photo
|
||||
of "model3d": model3d
|
||||
else: echo "ERROR: Unknown enum value (MediaType): ", s; photo
|
||||
|
||||
proc parseHook*(s: string; i: var int; v: var DateTime) =
|
||||
var str: string
|
||||
parseHook(s, i, str)
|
||||
v = parse(str, "yyyy-MM-dd hh:mm:ss")
|
||||
|
||||
proc parseHook*(s: string; i: var int; v: var Text) =
|
||||
if s[i] == '"':
|
||||
var str: string
|
||||
parseHook(s, i, str)
|
||||
v = Text(str)
|
||||
else:
|
||||
var t: tuple[content: string]
|
||||
parseHook(s, i, t)
|
||||
v = Text(t.content)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import options
|
||||
import common
|
||||
from ../../types import VerifiedType
|
||||
|
||||
type
|
||||
RawUser* = object
|
||||
|
@ -16,7 +15,8 @@ type
|
|||
favouritesCount*: int
|
||||
statusesCount*: int
|
||||
mediaCount*: int
|
||||
verifiedType*: VerifiedType
|
||||
verified*: bool
|
||||
extIsBlueVerified*: bool
|
||||
protected*: bool
|
||||
profileLinkColor*: string
|
||||
profileBannerUrl*: string
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, strformat, logging
|
||||
import config
|
||||
from net import Port
|
||||
from htmlgen import a
|
||||
from os import getEnv
|
||||
|
@ -17,6 +16,9 @@ const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
|||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||
|
||||
let
|
||||
configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
||||
(cfg, fullCfg) = getConfig(configPath)
|
||||
|
||||
accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json")
|
||||
|
||||
initAccountPool(cfg, accountsPath)
|
||||
|
@ -27,12 +29,9 @@ if not cfg.enableDebug:
|
|||
setLogFilter(lvlError)
|
||||
|
||||
stdout.write &"Starting Nitter at {getUrlPrefix(cfg)}\n"
|
||||
if cfg.readOnly:
|
||||
stdout.write &"==READ ONLY INSTANCE==\n"
|
||||
stdout.flushFile
|
||||
|
||||
updateDefaultPrefs(fullCfg)
|
||||
setReadOnly(cfg)
|
||||
setCacheTimes(cfg)
|
||||
setHmacKey(cfg.hmacKey)
|
||||
setProxyEncoding(cfg.base64Media)
|
||||
|
|
163
src/parser.nim
163
src/parser.nim
|
@ -3,7 +3,6 @@ import strutils, options, times, math
|
|||
import packedjson, packedjson/deserialiser
|
||||
import types, parserutils, utils
|
||||
import experimental/parser/unifiedcard
|
||||
import std/tables
|
||||
|
||||
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
|
||||
|
||||
|
@ -22,7 +21,7 @@ proc parseUser(js: JsonNode; id=""): User =
|
|||
tweets: js{"statuses_count"}.getInt,
|
||||
likes: js{"favourites_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,
|
||||
joinDate: js{"created_at"}.getTime
|
||||
)
|
||||
|
@ -35,8 +34,8 @@ proc parseGraphUser(js: JsonNode): User =
|
|||
user = ? js{"user_results", "result"}
|
||||
result = parseUser(user{"legacy"})
|
||||
|
||||
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
|
||||
result.verifiedType = blue
|
||||
if "is_blue_verified" in user:
|
||||
result.verified = user{"is_blue_verified"}.getBool()
|
||||
|
||||
proc parseGraphList*(js: JsonNode): List =
|
||||
if js.isNull: return
|
||||
|
@ -220,6 +219,8 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
)
|
||||
)
|
||||
|
||||
result.expandTweetEntities(js)
|
||||
|
||||
# fix for pinned threads
|
||||
if result.hasThread and result.threadId == 0:
|
||||
result.threadId = js{"self_thread", "id_str"}.getId
|
||||
|
@ -237,11 +238,8 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
# graphql
|
||||
with rt, js{"retweeted_status_result", "result"}:
|
||||
# needed due to weird edgecase where the actual tweet data isn't included
|
||||
var rt_tweet = rt
|
||||
if "tweet" in rt:
|
||||
rt_tweet = rt{"tweet"}
|
||||
if "legacy" in rt_tweet:
|
||||
result.retweet = some parseGraphTweet(rt_tweet)
|
||||
if "legacy" in rt:
|
||||
result.retweet = some parseGraphTweet(rt)
|
||||
return
|
||||
|
||||
if jsCard.kind != JNull:
|
||||
|
@ -256,8 +254,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
else:
|
||||
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
||||
|
||||
result.expandTweetEntities(js)
|
||||
|
||||
with jsMedia, js{"extended_entities", "media"}:
|
||||
for m in jsMedia:
|
||||
case m{"type"}.getStr
|
||||
|
@ -293,121 +289,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||
result.text.removeSuffix(" Learn more.")
|
||||
result.available = false
|
||||
|
||||
proc parseLegacyTweet(js: JsonNode): Tweet =
|
||||
result = parseTweet(js, js{"card"})
|
||||
if not result.isNil and result.available:
|
||||
result.user = parseUser(js{"user"})
|
||||
|
||||
if result.quote.isSome:
|
||||
result.quote = some parseLegacyTweet(js{"quoted_status"})
|
||||
|
||||
proc parseTweetSearch*(js: JsonNode; after=""): Timeline =
|
||||
result.beginning = after.len == 0
|
||||
|
||||
if js.kind == JNull or "modules" notin js or js{"modules"}.len == 0:
|
||||
return
|
||||
|
||||
for item in js{"modules"}:
|
||||
with tweet, item{"status", "data"}:
|
||||
let parsed = parseLegacyTweet(tweet)
|
||||
|
||||
if parsed.retweet.isSome:
|
||||
parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"})
|
||||
|
||||
result.content.add @[parsed]
|
||||
|
||||
if result.content.len > 0:
|
||||
result.bottom = $(result.content[^1][0].id - 1)
|
||||
|
||||
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
|
||||
let intId = if id.len > 0: parseBiggestInt(id) else: 0
|
||||
result = global.tweets.getOrDefault(id, Tweet(id: intId))
|
||||
|
||||
if result.quote.isSome:
|
||||
let quote = get(result.quote).id
|
||||
if $quote in global.tweets:
|
||||
result.quote = some global.tweets[$quote]
|
||||
else:
|
||||
result.quote = some Tweet()
|
||||
|
||||
if result.retweet.isSome:
|
||||
let rt = get(result.retweet).id
|
||||
if $rt in global.tweets:
|
||||
result.retweet = some finalizeTweet(global, $rt)
|
||||
else:
|
||||
result.retweet = some Tweet()
|
||||
|
||||
proc parsePin(js: JsonNode; global: GlobalObjects): Tweet =
|
||||
let pin = js{"pinEntry", "entry", "entryId"}.getStr
|
||||
if pin.len == 0: return
|
||||
|
||||
let id = pin.getId
|
||||
if id notin global.tweets: return
|
||||
|
||||
global.tweets[id].pinned = true
|
||||
return finalizeTweet(global, id)
|
||||
|
||||
proc parseGlobalObjects(js: JsonNode): GlobalObjects =
|
||||
result = GlobalObjects()
|
||||
let
|
||||
tweets = ? js{"globalObjects", "tweets"}
|
||||
users = ? js{"globalObjects", "users"}
|
||||
|
||||
for k, v in users:
|
||||
result.users[k] = parseUser(v, k)
|
||||
|
||||
for k, v in tweets:
|
||||
var tweet = parseTweet(v, v{"card"})
|
||||
if tweet.user.id in result.users:
|
||||
tweet.user = result.users[tweet.user.id]
|
||||
result.tweets[k] = tweet
|
||||
|
||||
proc parseInstructions(res: var Profile; global: GlobalObjects; js: JsonNode) =
|
||||
if js.kind != JArray or js.len == 0:
|
||||
return
|
||||
|
||||
for i in js:
|
||||
if res.tweets.beginning and i{"pinEntry"}.notNull:
|
||||
with pin, parsePin(i, global):
|
||||
res.pinned = some pin
|
||||
|
||||
with r, i{"replaceEntry", "entry"}:
|
||||
if "top" in r{"entryId"}.getStr:
|
||||
res.tweets.top = r.getCursor
|
||||
elif "bottom" in r{"entryId"}.getStr:
|
||||
res.tweets.bottom = r.getCursor
|
||||
|
||||
proc parseTimeline*(js: JsonNode; after=""): Profile =
|
||||
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||
let global = parseGlobalObjects(? js)
|
||||
|
||||
let instructions = ? js{"timeline", "instructions"}
|
||||
if instructions.len == 0: return
|
||||
|
||||
result.parseInstructions(global, instructions)
|
||||
|
||||
var entries: JsonNode
|
||||
for i in instructions:
|
||||
if "addEntries" in i:
|
||||
entries = i{"addEntries", "entries"}
|
||||
|
||||
for e in ? entries:
|
||||
let entry = e{"entryId"}.getStr
|
||||
if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry:
|
||||
let tweet = finalizeTweet(global, e.getEntryId)
|
||||
if not tweet.available: continue
|
||||
result.tweets.content.add tweet
|
||||
elif "cursor-top" in entry:
|
||||
result.tweets.top = e.getCursor
|
||||
elif "cursor-bottom" in entry:
|
||||
result.tweets.bottom = e.getCursor
|
||||
elif entry.startsWith("sq-cursor"):
|
||||
with cursor, e{"content", "operation", "cursor"}:
|
||||
if cursor{"cursorType"}.getStr == "Bottom":
|
||||
result.tweets.bottom = cursor{"value"}.getStr
|
||||
else:
|
||||
result.tweets.top = cursor{"value"}.getStr
|
||||
|
||||
proc parsePhotoRail*(js: JsonNode): PhotoRail =
|
||||
with error, js{"error"}:
|
||||
if error.getStr == "Not authorized.":
|
||||
|
@ -564,36 +445,6 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
|||
tweet.id = parseBiggestInt(entryId)
|
||||
result.pinned = some tweet
|
||||
|
||||
proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline =
|
||||
result = UsersTimeline(beginning: after.len == 0)
|
||||
|
||||
let instructions = ? timeline{"instructions"}
|
||||
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i{"type"}.getStr == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("user"):
|
||||
with graphUser, e{"content", "itemContent"}:
|
||||
let user = parseGraphUser(graphUser)
|
||||
result.content.add user
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.bottom = e{"content", "value"}.getStr
|
||||
elif entryId.startsWith("cursor-top"):
|
||||
result.top = e{"content", "value"}.getStr
|
||||
|
||||
proc parseGraphFavoritersTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
|
||||
return parseGraphUsersTimeline(js{"data", "favoriters_timeline", "timeline"}, after)
|
||||
|
||||
proc parseGraphRetweetersTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
|
||||
return parseGraphUsersTimeline(js{"data", "retweeters_timeline", "timeline"}, after)
|
||||
|
||||
proc parseGraphFollowTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
|
||||
return parseGraphUsersTimeline(js{"data", "user", "result", "timeline", "timeline"}, after)
|
||||
|
||||
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
||||
result = Result[T](beginning: after.len == 0)
|
||||
|
||||
|
|
|
@ -1,17 +1,9 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import std/[times, macros, htmlgen, options, algorithm, re]
|
||||
import std/strutils except escape
|
||||
import std/[strutils, times, macros, htmlgen, options, algorithm, re]
|
||||
import std/unicode except strip
|
||||
from xmltree import escape
|
||||
import packedjson
|
||||
import types, utils, formatters
|
||||
|
||||
const
|
||||
unicodeOpen = "\uFFFA"
|
||||
unicodeClose = "\uFFFB"
|
||||
xmlOpen = escape("<")
|
||||
xmlClose = escape(">")
|
||||
|
||||
let
|
||||
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
|
||||
unReplace = "$1<a href=\"/$2\">@$2</a>"
|
||||
|
@ -246,7 +238,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
|||
.replacef(htRegex, htReplace)
|
||||
|
||||
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
|
||||
replyTo=""; hasRedundantLink=false) =
|
||||
replyTo=""; hasQuote=false) =
|
||||
let hasCard = tweet.card.isSome
|
||||
|
||||
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:
|
||||
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:
|
||||
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) =
|
||||
let
|
||||
entities = ? js{"entities"}
|
||||
hasQuote = js{"is_quote_status"}.getBool
|
||||
textRange = js{"display_text_range"}
|
||||
textSlice = textRange{0}.getInt .. textRange{1}.getInt
|
||||
hasQuote = js{"is_quote_status"}.getBool
|
||||
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
|
||||
|
||||
var replyTo = ""
|
||||
if tweet.replyId != 0:
|
||||
|
@ -308,14 +299,12 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
|||
replyTo = reply.getStr
|
||||
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) =
|
||||
let
|
||||
entities = ? js{"entity_set"}
|
||||
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
|
||||
text = js{"text"}.getStr
|
||||
textSlice = 0..text.runeLen
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
proc getFavoritesQuery*(name: string): Query =
|
||||
Query(
|
||||
kind: favorites,
|
||||
fromUser: @[name]
|
||||
)
|
||||
|
||||
proc getReplyQuery*(name: string): Query =
|
||||
Query(
|
||||
kind: replies,
|
||||
|
|
|
@ -12,7 +12,6 @@ var
|
|||
pool: RedisPool
|
||||
rssCacheTime: int
|
||||
listCacheTime*: int
|
||||
readOnly: bool
|
||||
|
||||
template dawait(future) =
|
||||
discard await future
|
||||
|
@ -30,9 +29,6 @@ proc setCacheTimes*(cfg: Config) =
|
|||
rssCacheTime = cfg.rssCacheTime * 60
|
||||
listCacheTime = cfg.listCacheTime * 60
|
||||
|
||||
proc setReadOnly*(cfg: Config) =
|
||||
readOnly = cfg.readOnly
|
||||
|
||||
proc migrate*(key, match: string) {.async.} =
|
||||
pool.withAcquire(r):
|
||||
let hasKey = await r.get(key)
|
||||
|
@ -56,7 +52,6 @@ proc initRedisPool*(cfg: Config) {.async.} =
|
|||
await migrate("profileDates", "p:*")
|
||||
await migrate("profileStats", "p:*")
|
||||
await migrate("userType", "p:*")
|
||||
await migrate("verifiedType", "p:*")
|
||||
|
||||
pool.withAcquire(r):
|
||||
# optimize memory usage for user ID buckets
|
||||
|
@ -71,8 +66,6 @@ template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000)
|
|||
template userKey(name: string): string = "p:" & name
|
||||
template listKey(l: List): string = "l:" & l.id
|
||||
template tweetKey(id: int64): string = "t:" & $id
|
||||
template convoKey(id: string): string = "c:" & $id
|
||||
template convoKey(id: int64): string = "c:" & $id
|
||||
|
||||
proc get(query: string): Future[string] {.async.} =
|
||||
pool.withAcquire(r):
|
||||
|
@ -106,11 +99,6 @@ proc cache*(data: Tweet) {.async.} =
|
|||
pool.withAcquire(r):
|
||||
dawait r.setEx(data.id.tweetKey, baseCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc cache*(data: Conversation) {.async.} =
|
||||
if data.isNil or data.tweet == nil or data.tweet.id == 0: return
|
||||
pool.withAcquire(r):
|
||||
dawait r.setEx(data.tweet.id.convoKey, baseCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc cacheRss*(query: string; rss: Rss) {.async.} =
|
||||
let key = "rss:" & query
|
||||
pool.withAcquire(r):
|
||||
|
@ -129,7 +117,7 @@ proc getUserId*(username: string): Future[string] {.async.} =
|
|||
let name = toLower(username)
|
||||
pool.withAcquire(r):
|
||||
result = await r.hGet(name.uidKey, name)
|
||||
if result == redisNil and not readOnly:
|
||||
if result == redisNil:
|
||||
let user = await getGraphUser(username)
|
||||
if user.suspended:
|
||||
return "suspended"
|
||||
|
@ -141,7 +129,7 @@ proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} =
|
|||
let prof = await get("p:" & toLower(username))
|
||||
if prof != redisNil:
|
||||
prof.deserialize(User)
|
||||
elif fetch and not readOnly:
|
||||
elif fetch:
|
||||
result = await getGraphUser(username)
|
||||
await cache(result)
|
||||
|
||||
|
@ -152,39 +140,29 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
|||
|
||||
if username != redisNil:
|
||||
result = username
|
||||
elif not readOnly:
|
||||
else:
|
||||
let user = await getGraphUserById(userId)
|
||||
result = user.username
|
||||
await setEx(key, baseCacheTime, result)
|
||||
if result.len > 0 and user.id.len > 0:
|
||||
await all(cacheUserId(result, user.id), cache(user))
|
||||
|
||||
proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
||||
if id == 0: return
|
||||
let tweet = await get(id.tweetKey)
|
||||
if tweet != redisNil:
|
||||
tweet.deserialize(Tweet)
|
||||
elif not readOnly:
|
||||
result = await getGraphTweetResult($id)
|
||||
if not result.isNil:
|
||||
await cache(result)
|
||||
|
||||
proc getCachedConvo*(id: string; cursor=""): Future[Conversation] {.async.} =
|
||||
if id == "": return
|
||||
let convo = await get(id.convoKey)
|
||||
if convo != redisNil:
|
||||
convo.deserialize(Conversation)
|
||||
elif not readOnly:
|
||||
result = await getTweet(id, cursor)
|
||||
if not result.isNil:
|
||||
await cache(result)
|
||||
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
||||
# if id == 0: return
|
||||
# let tweet = await get(id.tweetKey)
|
||||
# if tweet != redisNil:
|
||||
# tweet.deserialize(Tweet)
|
||||
# else:
|
||||
# result = await getGraphTweetResult($id)
|
||||
# if not result.isNil:
|
||||
# await cache(result)
|
||||
|
||||
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
||||
if name.len == 0: return
|
||||
let rail = await get("pr:" & toLower(name))
|
||||
if rail != redisNil:
|
||||
rail.deserialize(PhotoRail)
|
||||
elif not readOnly:
|
||||
else:
|
||||
result = await getPhotoRail(name)
|
||||
await cache(result, name)
|
||||
|
||||
|
@ -194,7 +172,7 @@ proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
|||
|
||||
if list != redisNil:
|
||||
list.deserialize(List)
|
||||
elif not readOnly:
|
||||
else:
|
||||
if id.len > 0:
|
||||
result = await getGraphList(id)
|
||||
else:
|
||||
|
|
|
@ -23,7 +23,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
|
|||
names = getNames(name)
|
||||
|
||||
if names.len == 1:
|
||||
profile = await fetchProfile(after, query, cfg, skipRail=true, skipPinned=true)
|
||||
profile = await fetchProfile(after, query, skipRail=true, skipPinned=true)
|
||||
else:
|
||||
var q = query
|
||||
q.fromUser = names
|
||||
|
@ -102,7 +102,7 @@ proc createRssRouter*(cfg: Config) =
|
|||
get "/@name/@tab/rss":
|
||||
cond cfg.enableRss
|
||||
cond '.' notin @"name"
|
||||
cond @"tab" in ["with_replies", "media", "favorites", "search"]
|
||||
cond @"tab" in ["with_replies", "media", "search"]
|
||||
let
|
||||
name = @"name"
|
||||
tab = @"tab"
|
||||
|
@ -110,8 +110,7 @@ proc createRssRouter*(cfg: Config) =
|
|||
case tab
|
||||
of "with_replies": getReplyQuery(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])
|
||||
|
||||
let searchKey = if tab != "search": ""
|
||||
|
|
|
@ -4,8 +4,8 @@ import asyncdispatch, strutils, sequtils, uri, options, sugar
|
|||
import jester, karax/vdom
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, formatters, api, redis_cache]
|
||||
import ../views/[general, status, search]
|
||||
import ".."/[types, formatters, api]
|
||||
import ../views/[general, status]
|
||||
|
||||
export uri, sequtils, options, sugar
|
||||
export router_utils
|
||||
|
@ -14,29 +14,6 @@ export status
|
|||
|
||||
proc createStatusRouter*(cfg: Config) =
|
||||
router status:
|
||||
get "/@name/status/@id/@reactors":
|
||||
cond '.' notin @"name"
|
||||
let id = @"id"
|
||||
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, showError("Invalid tweet ID", cfg)
|
||||
|
||||
let prefs = cookiePrefs()
|
||||
|
||||
# used for the infinite scroll feature
|
||||
if @"scroll".len > 0:
|
||||
let replies = await getReplies(id, getCursor())
|
||||
if replies.content.len == 0:
|
||||
resp Http404, ""
|
||||
resp $renderReplies(replies, prefs, getPath())
|
||||
|
||||
if @"reactors" == "favoriters":
|
||||
resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
|
||||
request, cfg, prefs)
|
||||
elif @"reactors" == "retweeters":
|
||||
resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
|
||||
request, cfg, prefs)
|
||||
|
||||
get "/@name/status/@id/?":
|
||||
cond '.' notin @"name"
|
||||
let id = @"id"
|
||||
|
@ -53,7 +30,7 @@ proc createStatusRouter*(cfg: Config) =
|
|||
resp Http404, ""
|
||||
resp $renderReplies(replies, prefs, getPath())
|
||||
|
||||
let conv = await getCachedConvo(id, getCursor())
|
||||
let conv = await getTweet(id, getCursor())
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
|
||||
|
|
|
@ -16,7 +16,6 @@ proc getQuery*(request: Request; tab, name: string): Query =
|
|||
case tab
|
||||
of "with_replies": getReplyQuery(name)
|
||||
of "media": getMediaQuery(name)
|
||||
of "favorites": getFavoritesQuery(name)
|
||||
of "search": initQuery(params(request), name=name)
|
||||
else: Query(fromUser: @[name])
|
||||
|
||||
|
@ -28,7 +27,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
|||
else:
|
||||
body
|
||||
|
||||
proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
|
||||
proc fetchProfile*(after: string; query: Query; skipRail=false;
|
||||
skipPinned=false): Future[Profile] {.async.} =
|
||||
let
|
||||
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 replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
|
||||
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
|
||||
of favorites: await getFavorites(userId, cfg, after)
|
||||
else: Profile(tweets: await getGraphTweetSearch(query, after))
|
||||
|
||||
result.user = await user
|
||||
|
@ -73,7 +71,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
|||
html = renderTweetSearch(timeline, prefs, getPath())
|
||||
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
|
||||
|
||||
var profile = await fetchProfile(after, query, cfg, skipPinned=prefs.hidePins)
|
||||
var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins)
|
||||
template u: untyped = profile.user
|
||||
|
||||
if u.suspended:
|
||||
|
@ -81,7 +79,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
|||
|
||||
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),
|
||||
rss=rss, images = @[u.getUserPic("_400x400")],
|
||||
banner=u.banner)
|
||||
|
@ -111,19 +109,12 @@ proc createTimelineRouter*(cfg: Config) =
|
|||
get "/@name/?@tab?/?":
|
||||
cond '.' notin @"name"
|
||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||
cond @"tab" in ["with_replies", "media", "search", "favorites", "following", "followers", ""]
|
||||
cond @"tab" in ["with_replies", "media", "search", ""]
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
after = getCursor()
|
||||
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")
|
||||
if names.len != 1:
|
||||
query.fromUser = names
|
||||
|
@ -136,7 +127,7 @@ proc createTimelineRouter*(cfg: Config) =
|
|||
timeline.beginning = true
|
||||
resp $renderTweetSearch(timeline, prefs, getPath())
|
||||
else:
|
||||
var profile = await fetchProfile(after, query, cfg, skipRail=true)
|
||||
var profile = await fetchProfile(after, query, skipRail=true)
|
||||
if profile.tweets.content.len == 0: resp Http404
|
||||
profile.tweets.beginning = true
|
||||
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
|
||||
|
|
|
@ -28,8 +28,6 @@ $more_replies_dots: #AD433B;
|
|||
$error_red: #420A05;
|
||||
|
||||
$verified_blue: #1DA1F2;
|
||||
$verified_business: #FAC82B;
|
||||
$verified_government: #C1B6A4;
|
||||
$icon_text: $fg_color;
|
||||
|
||||
$tab: $fg_color;
|
||||
|
|
|
@ -39,8 +39,6 @@ body {
|
|||
--error_red: #{$error_red};
|
||||
|
||||
--verified_blue: #{$verified_blue};
|
||||
--verified_business: #{$verified_business};
|
||||
--verified_government: #{$verified_government};
|
||||
--icon_text: #{$icon_text};
|
||||
|
||||
--tab: #{$fg_color};
|
||||
|
@ -143,30 +141,17 @@ ul {
|
|||
|
||||
.verified-icon {
|
||||
color: var(--icon_text);
|
||||
background-color: var(--verified_blue);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin: 2px 0 3px 3px;
|
||||
padding-top: 3px;
|
||||
height: 11px;
|
||||
padding-top: 2px;
|
||||
height: 12px;
|
||||
width: 14px;
|
||||
font-size: 8px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
&.blue {
|
||||
background-color: var(--verified_blue);
|
||||
}
|
||||
|
||||
&.business {
|
||||
color: var(--bg_panel);
|
||||
background-color: var(--verified_business);
|
||||
}
|
||||
|
||||
&.government {
|
||||
color: var(--bg_panel);
|
||||
background-color: var(--verified_government);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 600px) {
|
||||
|
|
|
@ -14,8 +14,6 @@
|
|||
button {
|
||||
margin: 0 2px 0 0;
|
||||
height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
|
|
|
@ -207,7 +207,6 @@
|
|||
padding-top: 5px;
|
||||
min-width: 1em;
|
||||
margin-right: 0.8em;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.show-thread {
|
||||
|
|
|
@ -10,7 +10,9 @@ type
|
|||
BadClientError* = object of CatchableError
|
||||
|
||||
TimelineKind* {.pure.} = enum
|
||||
tweets, replies, media
|
||||
tweets
|
||||
replies
|
||||
media
|
||||
|
||||
Api* {.pure.} = enum
|
||||
tweetDetail
|
||||
|
@ -23,14 +25,9 @@ type
|
|||
listTweets
|
||||
userRestId
|
||||
userScreenName
|
||||
favorites
|
||||
userTweets
|
||||
userTweetsAndReplies
|
||||
userMedia
|
||||
favoriters
|
||||
retweeters
|
||||
following
|
||||
followers
|
||||
|
||||
RateLimit* = object
|
||||
remaining*: int
|
||||
|
@ -66,12 +63,6 @@ type
|
|||
tweetUnavailable = 421
|
||||
tweetCensored = 422
|
||||
|
||||
VerifiedType* = enum
|
||||
none = "None"
|
||||
blue = "Blue"
|
||||
business = "Business"
|
||||
government = "Government"
|
||||
|
||||
User* = object
|
||||
id*: string
|
||||
username*: string
|
||||
|
@ -87,7 +78,7 @@ type
|
|||
tweets*: int
|
||||
likes*: int
|
||||
media*: int
|
||||
verifiedType*: VerifiedType
|
||||
verified*: bool
|
||||
protected*: bool
|
||||
suspended*: bool
|
||||
joinDate*: DateTime
|
||||
|
@ -116,7 +107,7 @@ type
|
|||
variants*: seq[VideoVariant]
|
||||
|
||||
QueryKind* = enum
|
||||
posts, replies, media, users, tweets, userList, favorites
|
||||
posts, replies, media, users, tweets, userList
|
||||
|
||||
Query* = object
|
||||
kind*: QueryKind
|
||||
|
@ -171,7 +162,6 @@ type
|
|||
imageDirectMessage = "image_direct_message"
|
||||
audiospace = "audiospace"
|
||||
newsletterPublication = "newsletter_publication"
|
||||
jobDetails = "job_details"
|
||||
hidden
|
||||
unknown
|
||||
|
||||
|
@ -236,7 +226,6 @@ type
|
|||
replies*: Result[Chain]
|
||||
|
||||
Timeline* = Result[Tweets]
|
||||
UsersTimeline* = Result[User]
|
||||
|
||||
Profile* = object
|
||||
user*: User
|
||||
|
@ -265,7 +254,6 @@ type
|
|||
title*: string
|
||||
hostname*: string
|
||||
staticDir*: string
|
||||
readOnly*: bool
|
||||
|
||||
hmacKey*: string
|
||||
base64Media*: bool
|
||||
|
@ -284,9 +272,6 @@ type
|
|||
redisMaxConns*: int
|
||||
redisPassword*: string
|
||||
|
||||
cookieHeader*: string
|
||||
xCsrfToken*: string
|
||||
|
||||
Rss* = object
|
||||
feed*, cursor*: string
|
||||
|
||||
|
|
|
@ -16,8 +16,7 @@ const
|
|||
"twimg.com",
|
||||
"abs.twimg.com",
|
||||
"pbs.twimg.com",
|
||||
"video.twimg.com",
|
||||
"x.com"
|
||||
"video.twimg.com"
|
||||
]
|
||||
|
||||
proc setHmacKey*(key: string) =
|
||||
|
@ -58,4 +57,4 @@ proc isTwitterUrl*(uri: Uri): bool =
|
|||
uri.hostname in twitterDomains
|
||||
|
||||
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):
|
||||
tdiv(class="inner-nav"):
|
||||
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")
|
||||
|
||||
|
@ -52,7 +52,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||
|
||||
buildHtml(head):
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=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")
|
||||
|
||||
if theme.len > 0:
|
||||
|
|
|
@ -58,13 +58,9 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
|||
|
||||
tdiv(class="profile-card-extra-links"):
|
||||
ul(class="profile-statlist"):
|
||||
a(href="/" & user.username):
|
||||
renderStat(user.tweets, "posts", text="Tweets")
|
||||
a(href="/" & user.username & "/following"):
|
||||
renderStat(user.following, "following")
|
||||
a(href="/" & user.username & "/followers"):
|
||||
renderStat(user.followers, "followers")
|
||||
a(href="/" & user.username & "/favorites"):
|
||||
renderStat(user.likes, "likes")
|
||||
|
||||
proc renderPhotoRail(profile: Profile): VNode =
|
||||
|
@ -103,7 +99,7 @@ proc renderProtected(username: string): VNode =
|
|||
h2: text "This account's tweets are protected."
|
||||
p: text &"Only confirmed followers have access to @{username}'s tweets."
|
||||
|
||||
proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: string): VNode =
|
||||
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
||||
profile.tweets.query.fromUser = @[profile.user.username]
|
||||
|
||||
buildHtml(tdiv(class="profile-tabs")):
|
||||
|
|
|
@ -23,13 +23,6 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
|||
if text.len > 0:
|
||||
text " " & text
|
||||
|
||||
template verifiedIcon*(user: User): untyped {.dirty.} =
|
||||
if user.verifiedType != VerifiedType.none:
|
||||
let lower = ($user.verifiedType).toLowerAscii()
|
||||
icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
|
||||
else:
|
||||
text ""
|
||||
|
||||
proc linkUser*(user: User, class=""): VNode =
|
||||
let
|
||||
isName = "username" notin class
|
||||
|
@ -39,9 +32,9 @@ proc linkUser*(user: User, class=""): VNode =
|
|||
|
||||
buildHtml(a(href=href, class=class, title=nameText)):
|
||||
text nameText
|
||||
if isName:
|
||||
verifiedIcon(user)
|
||||
if user.protected:
|
||||
if isName and user.verified:
|
||||
icon "ok", class="verified-icon", title="Verified account"
|
||||
if isName and user.protected:
|
||||
text " "
|
||||
icon "lock", title="Protected account"
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import strutils, strformat, sequtils, unicode, tables, options
|
|||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils, timeline
|
||||
import ".."/[types, query, config]
|
||||
import ".."/[types, query]
|
||||
|
||||
const toggles = {
|
||||
"nativeretweets": "Retweets",
|
||||
|
@ -29,7 +29,7 @@ proc renderSearch*(): VNode =
|
|||
placeholder="Enter username...", dir="auto")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =
|
||||
proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||
let link = "/" & username
|
||||
buildHtml(ul(class="tab")):
|
||||
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"
|
||||
li(class=query.getTabClass(media)):
|
||||
a(href=(link & "/media")): text "Media"
|
||||
if len(cfg.xCsrfToken) != 0 and len(cfg.cookieHeader) != 0:
|
||||
li(class=query.getTabClass(favorites)):
|
||||
a(href=(link & "/favorites")): text "Likes"
|
||||
li(class=query.getTabClass(tweets)):
|
||||
a(href=(link & "/search")): text "Search"
|
||||
|
||||
|
@ -100,7 +97,7 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
|||
text query.fromUser.join(" | ")
|
||||
|
||||
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:
|
||||
tdiv(class="timeline-header"):
|
||||
|
@ -121,8 +118,3 @@ proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
|
|||
|
||||
renderSearchTabs(results.query)
|
||||
renderTimelineUsers(results, prefs)
|
||||
|
||||
proc renderUserList*(results: Result[User]; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
tdiv(class="timeline-header")
|
||||
renderTimelineUsers(results, prefs)
|
||||
|
|
|
@ -180,17 +180,12 @@ func formatStat(stat: int): string =
|
|||
if stat > 0: insertSep($stat, ',')
|
||||
else: ""
|
||||
|
||||
proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
|
||||
proc renderStats(stats: TweetStats; views: string): VNode =
|
||||
buildHtml(tdiv(class="tweet-stats")):
|
||||
a(href=getLink(tweet)):
|
||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||||
a(href=getLink(tweet, false) & "/retweeters"):
|
||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
||||
a(href="/search?q=quoted_tweet_id:" & $tweet.id):
|
||||
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
||||
a(href=getLink(tweet, false) & "/favoriters"):
|
||||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||||
a(href=getLink(tweet)):
|
||||
if views.len > 0:
|
||||
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
||||
|
||||
|
@ -205,7 +200,8 @@ proc renderAttribution(user: User; prefs: Prefs): VNode =
|
|||
buildHtml(a(class="attribution", href=("/" & user.username))):
|
||||
renderMiniAvatar(user, prefs)
|
||||
strong: text user.fullname
|
||||
verifiedIcon(user)
|
||||
if user.verified:
|
||||
icon "ok", class="verified-icon", title="Verified account"
|
||||
|
||||
proc renderMediaTags(tags: seq[User]): VNode =
|
||||
buildHtml(tdiv(class="media-tag-block")):
|
||||
|
@ -350,7 +346,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||
renderMediaTags(tweet.mediaTags)
|
||||
|
||||
if not prefs.hideTweetStats:
|
||||
renderStats(tweet.stats, views, tweet)
|
||||
renderStats(tweet.stats, views)
|
||||
|
||||
if showThread:
|
||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
||||
|
|
|
@ -9,7 +9,7 @@ text = [
|
|||
What are we doing wrong? reuters.com/article/us-norwa…"""],
|
||||
|
||||
['nim_lang/status/1491461266849808397#m',
|
||||
'Nim', '@nim_lang',
|
||||
'Nim language', '@nim_lang',
|
||||
"""What's better than Nim 1.6.0?
|
||||
|
||||
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
|
||||
|
||||
# image = tweet + 'div.attachments.media-body > div > div > a > div > img'
|
||||
|
@ -35,16 +35,7 @@ multiline = [
|
|||
CALM
|
||||
AND
|
||||
CLICHÉ
|
||||
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"""]
|
||||
ON"""]
|
||||
]
|
||||
|
||||
link = [
|
||||
|
@ -83,18 +74,22 @@ retweet = [
|
|||
[3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr']
|
||||
]
|
||||
|
||||
# reply = [
|
||||
# ['mobile_test/with_replies', 15]
|
||||
# ]
|
||||
|
||||
|
||||
class TweetTest(BaseTestCase):
|
||||
@parameterized.expand(timeline)
|
||||
def test_timeline(self, index, fullname, username, date, tid, text):
|
||||
self.open_nitter(username)
|
||||
tweet = get_timeline_tweet(index)
|
||||
self.assert_exact_text(fullname, tweet.fullname)
|
||||
self.assert_exact_text('@' + username, tweet.username)
|
||||
self.assert_exact_text(date, tweet.date)
|
||||
self.assert_text(text, tweet.text)
|
||||
permalink = self.find_element(tweet.date + ' a')
|
||||
self.assertIn(tid, permalink.get_attribute('href'))
|
||||
# @parameterized.expand(timeline)
|
||||
# def test_timeline(self, index, fullname, username, date, tid, text):
|
||||
# self.open_nitter(username)
|
||||
# tweet = get_timeline_tweet(index)
|
||||
# self.assert_exact_text(fullname, tweet.fullname)
|
||||
# self.assert_exact_text('@' + username, tweet.username)
|
||||
# self.assert_exact_text(date, tweet.date)
|
||||
# self.assert_text(text, tweet.text)
|
||||
# permalink = self.find_element(tweet.date + ' a')
|
||||
# self.assertIn(tid, permalink.get_attribute('href'))
|
||||
|
||||
@parameterized.expand(status)
|
||||
def test_status(self, tid, fullname, username, date, text):
|
||||
|
@ -108,18 +103,18 @@ class TweetTest(BaseTestCase):
|
|||
@parameterized.expand(multiline)
|
||||
def test_multiline_formatting(self, tid, username, text):
|
||||
self.open_nitter(f'{username}/status/{tid}')
|
||||
self.assert_text(text.strip('\n'), Conversation.main)
|
||||
self.assert_text(text.strip('\n'), '.main-tweet')
|
||||
|
||||
@parameterized.expand(emoji)
|
||||
def test_emoji(self, tweet, text):
|
||||
self.open_nitter(tweet)
|
||||
self.assert_text(text, Conversation.main)
|
||||
self.assert_text(text, '.main-tweet')
|
||||
|
||||
@parameterized.expand(link)
|
||||
def test_link(self, tweet, links):
|
||||
self.open_nitter(tweet)
|
||||
for link in links:
|
||||
self.assert_text(link, Conversation.main)
|
||||
self.assert_text(link, '.main-tweet')
|
||||
|
||||
@parameterized.expand(username)
|
||||
def test_username(self, tweet, usernames):
|
||||
|
@ -128,14 +123,14 @@ class TweetTest(BaseTestCase):
|
|||
link = self.find_link_text(f'@{un}')
|
||||
self.assertIn(f'/{un}', link.get_property('href'))
|
||||
|
||||
@parameterized.expand(retweet)
|
||||
def test_retweet(self, index, url, retweet_by, fullname, username, text):
|
||||
self.open_nitter(url)
|
||||
tweet = get_timeline_tweet(index)
|
||||
self.assert_text(f'{retweet_by} retweeted', tweet.retweet)
|
||||
self.assert_text(text, tweet.text)
|
||||
self.assert_exact_text(fullname, tweet.fullname)
|
||||
self.assert_exact_text(username, tweet.username)
|
||||
# @parameterized.expand(retweet)
|
||||
# def test_retweet(self, index, url, retweet_by, fullname, username, text):
|
||||
# self.open_nitter(url)
|
||||
# tweet = get_timeline_tweet(index)
|
||||
# self.assert_text(f'{retweet_by} retweeted', tweet.retweet)
|
||||
# self.assert_text(text, tweet.text)
|
||||
# self.assert_exact_text(fullname, tweet.fullname)
|
||||
# self.assert_exact_text(username, tweet.username)
|
||||
|
||||
@parameterized.expand(invalid)
|
||||
def test_invalid_id(self, tweet):
|
||||
|
|
Loading…
Reference in a new issue