26 Commits

Author SHA1 Message Date
Salastil e98aa346d2 Merge branch 'master' of https://github.com/zedeus/nitter
Docker / build (linux/amd64, ubuntu-24.04) (push) Has been cancelled
Docker / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker / merge (push) Has been cancelled
Docker / tests (push) Has been cancelled
2026-06-13 11:28:36 -04:00
Zed bd9d492d36 Fix bounds checking
Fixes #1410
2026-06-11 23:39:48 +02:00
Zed 7eed720894 Fix entity expansion crash, simplify URL handling
Fixes #1409
2026-06-10 16:07:13 +02:00
Zed ac5ba9469e Fix incorrectly shown Twitter video cards
Fixes #1407
2026-06-10 14:28:01 +02:00
Zed 63891a04ff Update tests 2026-06-10 06:20:08 +02:00
Zed ac2f93b361 Add skipTid field for cookie session TID handling 2026-06-10 06:20:05 +02:00
Zed 1e33ca045d Fix restIdVars whitespace handling 2026-06-10 06:20:01 +02:00
Zed 5a4c8dd12c Improve retry failure handling
Fixes #1388
2026-06-08 23:59:50 +02:00
Zed fb9107cff6 Fix crash on malformed request paths 2026-06-08 06:14:18 +02:00
Zed 60cb10229f Fix SameSite cookie handling for HTTP 2026-06-08 06:12:58 +02:00
Zed ef1de42593 Update dependencies to version tags 2026-06-08 06:12:52 +02:00
Zed 9b9c86a15c Use Nimble local deps to fix deployment issue
Fixes #1405
2026-06-08 01:08:19 +02:00
Zed 55d067957c Fix arm32 compilation
Fixes #1389
2026-06-07 23:17:43 +02:00
Zed a8bc1bbb2d Fix nimble dependency caching in CI 2026-06-07 20:35:16 +02:00
Zed f629507537 Publish a single multi-arch Docker image
Build amd64 and arm64 natively (no emulation), push each by
digest, then merge them into one multi-arch manifest so
`zedeus/nitter:latest` and `:<sha>` resolve to the right image
on any CPU. Replaces the separate `latest-arm64` tag, which is
no longer needed. Update the README notes accordingly.
2026-06-07 01:24:03 +02:00
Zed 40d17bf042 Use Nim 2.2.6 for Docker builds, unify arm64
nimlang/nim's alpine-regular images cap at 2.2.6 and are now
multi-arch, while Alpine's apk nim is stuck at the segfaulting
2.2.0. Base both arches on 2.2.6-alpine-regular, drop the
separate Dockerfile.arm64, and build ./Dockerfile in the arm64
CI job.

Fixes #1404
2026-06-07 00:43:29 +02:00
Zed 6ab2143df0 Add note about creating nitter.conf for Docker
Fixes #1392
2026-06-07 00:14:43 +02:00
Salastil 3562b0708d Add cashtag filter option
Docker / tests (push) Has been cancelled
Docker / build-docker-amd64 (push) Has been cancelled
Docker / build-docker-arm64 (push) Has been cancelled
2026-06-05 21:19:17 -04:00
Zed a86be15f85 Fix broken 2.2.x build 2026-06-06 00:14:28 +02:00
Zed c956f7c373 Add /<acc>/about tests 2026-06-05 23:54:39 +02:00
Zed e4e6dd13e6 Update API endpoints 2026-06-05 22:26:46 +02:00
Zed 083d65a8cf Update tests 2026-06-05 22:26:41 +02:00
Zed 1d57f1f432 Add video attribution link support 2026-06-05 22:26:38 +02:00
Zed d5ff410c5d Add same-origin referrer policy
Fixes #1346
2026-06-02 23:59:00 +02:00
Ian Brown 5a4faa0367 Fix OpenSearch response crash (#1400) 2026-06-02 22:31:40 +02:00
Zed 82099de55b Include session.kind in all debug output
Fixes #1330
2026-06-02 17:21:20 +02:00
31 changed files with 540 additions and 308 deletions
+71 -21
View File
@@ -7,55 +7,105 @@ on:
branches: branches:
- master - master
concurrency:
group: docker-publish-${{ github.ref }}
cancel-in-progress: true
env:
IMAGE: zedeus/nitter
jobs: jobs:
tests: tests:
uses: ./.github/workflows/run-tests.yml uses: ./.github/workflows/run-tests.yml
secrets: inherit secrets: inherit
build-docker-amd64: build:
needs: [tests] needs: [tests]
runs-on: ubuntu-24.04 strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
platform: linux/amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
runs-on: ${{ matrix.runner }}
steps: steps:
- name: Prepare platform name
run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
env:
platform: ${{ matrix.platform }}
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with: with:
version: latest version: latest
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push AMD64 Docker image
uses: docker/build-push-action@v3 - name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64 platforms: ${{ matrix.platform }}
push: true outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }} provenance: false
sbom: false
build-docker-arm64: - name: Export digest
needs: [tests] run: |
runs-on: ubuntu-24.04-arm mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Combine the per-arch digests into one multi-arch manifest so that
# `docker pull zedeus/nitter:latest` serves the right image on any CPU.
merge:
needs: [build]
runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v6 - name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with: with:
version: latest version: latest
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push ARM64 Docker image
uses: docker/build-push-action@v3 - name: Create manifest list and push
with: working-directory: ${{ runner.temp }}/digests
context: . run: |
file: ./Dockerfile.arm64 docker buildx imagetools create \
platforms: linux/arm64 -t ${{ env.IMAGE }}:latest \
push: true -t ${{ env.IMAGE }}:latest-arm64 \
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64 -t ${{ env.IMAGE }}:${{ github.sha }} \
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.sha }}
+15 -10
View File
@@ -32,10 +32,12 @@ jobs:
id: cache-nimble id: cache-nimble
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: ~/.nimble path: |
key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }} ~/.nimble/pkgcache
~/.nimble/packages_official.json
key: ${{ matrix.nim }}-nimble-v6-${{ hashFiles('*.nimble') }}
restore-keys: | restore-keys: |
${{ matrix.nim }}-nimble-v2- ${{ matrix.nim }}-nimble-v6-
- name: Setup Nim - name: Setup Nim
uses: jiro4989/setup-nim-action@v2 uses: jiro4989/setup-nim-action@v2
@@ -103,10 +105,12 @@ jobs:
- name: Cache Nimble Dependencies - name: Cache Nimble Dependencies
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: ~/.nimble path: |
key: 2.2.x-nimble-v2-${{ hashFiles('*.nimble') }} ~/.nimble/pkgcache
~/.nimble/packages_official.json
key: 2.2.x-nimble-v6-${{ hashFiles('*.nimble') }}
restore-keys: | restore-keys: |
2.2.x-nimble-v2- 2.2.x-nimble-v6-
- name: Setup Nim - name: Setup Nim
uses: jiro4989/setup-nim-action@v2 uses: jiro4989/setup-nim-action@v2
@@ -115,6 +119,9 @@ jobs:
use-nightlies: true use-nightlies: true
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Nimble dependencies
run: nimble install -y --depsOnly
- name: Download 2.2.x build artifact - name: Download 2.2.x build artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@@ -130,10 +137,8 @@ jobs:
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf
# Run both Nimble tasks concurrently nim r tools/rendermd.nim
nim r tools/rendermd.nim & nim r tools/gencss.nim
nim r tools/gencss.nim &
wait
echo '${{ secrets.SESSIONS }}' | head -n1 echo '${{ secrets.SESSIONS }}' | head -n1
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
+3
View File
@@ -15,3 +15,6 @@ sessions.json*
dump.rdb dump.rdb
*.bak *.bak
/tools/*.json* /tools/*.json*
nimbledeps/
nimble.paths
nimble.develop
+2 -2
View File
@@ -1,4 +1,4 @@
FROM nimlang/nim:2.2.0-alpine-regular as nim FROM nimlang/nim:2.2.6-alpine-regular as nim
LABEL maintainer="setenforce@protonmail.com" LABEL maintainer="setenforce@protonmail.com"
RUN apk --no-cache add libsass-dev pcre RUN apk --no-cache add libsass-dev pcre
@@ -15,7 +15,7 @@ RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
FROM alpine:latest FROM alpine:latest
WORKDIR /src/ WORKDIR /src/
RUN apk --no-cache add pcre ca-certificates RUN apk --no-cache add pcre ca-certificates openssl
COPY --from=nim /src/nitter/nitter ./ COPY --from=nim /src/nitter/nitter ./
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
COPY --from=nim /src/nitter/public ./public COPY --from=nim /src/nitter/public ./public
-25
View File
@@ -1,25 +0,0 @@
FROM alpine:3.20.6 as nim
LABEL maintainer="setenforce@protonmail.com"
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble
WORKDIR /src/nitter
COPY nitter.nimble .
RUN nimble install -y --depsOnly
COPY . .
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
&& nimble scss \
&& nimble md
FROM alpine:3.20.6
WORKDIR /src/
RUN apk --no-cache add pcre ca-certificates openssl
COPY --from=nim /src/nitter/nitter ./
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
COPY --from=nim /src/nitter/public ./public
EXPOSE 8080
RUN adduser -h /src/ -D -s /bin/sh nitter
USER nitter
CMD ./nitter
+20 -8
View File
@@ -104,9 +104,9 @@ along with the scss and md files.
# su nitter # su nitter
$ git clone https://github.com/zedeus/nitter $ git clone https://github.com/zedeus/nitter
$ cd nitter $ cd nitter
$ nimble build -d:danger --mm:refc $ nimble -l build -d:danger --mm:refc
$ nimble scss $ nimble -l scss
$ nimble md $ nimble -l md
$ cp nitter.example.conf nitter.conf $ cp nitter.example.conf nitter.conf
``` ```
@@ -123,12 +123,23 @@ performance reasons.
Page for the Docker image: https://hub.docker.com/r/zedeus/nitter Page for the Docker image: https://hub.docker.com/r/zedeus/nitter
#### NOTE: For ARM64 support, please use the separate ARM64 docker image: [`zedeus/nitter:latest-arm64`](https://hub.docker.com/r/zedeus/nitter/tags). #### NOTE: The published image is multi-arch — `zedeus/nitter:latest` runs natively on both `amd64` and `arm64`.
To run Nitter with Docker, you'll need to install and run Redis separately To run Nitter with Docker, you'll need to install and run Redis separately
before you can run the container. See below for how to also run Redis using before you can run the container. See below for how to also run Redis using
Docker. Docker.
First create your config file. The Docker commands mount it into the container,
so it has to exist on the host beforehand. If you've cloned the repo:
```bash
cp nitter.example.conf nitter.conf
```
If you're using the prebuilt image without a local clone, download
[`nitter.example.conf`](https://raw.githubusercontent.com/zedeus/nitter/master/nitter.example.conf)
and save it as `nitter.conf` instead.
To build and run Nitter in Docker: To build and run Nitter in Docker:
```bash ```bash
@@ -136,8 +147,6 @@ docker build -t nitter:latest .
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest
``` ```
Note: For ARM64, use this Dockerfile: [`Dockerfile.arm64`](https://github.com/zedeus/nitter/blob/master/Dockerfile.arm64).
A prebuilt Docker image is provided as well: A prebuilt Docker image is provided as well:
```bash ```bash
@@ -151,8 +160,11 @@ Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run
docker-compose up -d docker-compose up -d
``` ```
Note the Docker commands expect a `nitter.conf` file in the directory you run Note the Docker commands mount `nitter.conf` (and `sessions.jsonl` for
them. docker-compose) from the directory you run them in. If a mounted file doesn't
exist, Docker silently creates a directory in its place and the container fails
with `not a directory: Are you trying to mount a directory onto a file`. Remove
that directory and create the file as shown above.
### systemd ### systemd
+4
View File
@@ -11,3 +11,7 @@ warning("HoleEnumConv", off)
hint("XDeclaredButNotUsed", off) hint("XDeclaredButNotUsed", off)
hint("XCannotRaiseY", off) hint("XCannotRaiseY", off)
hint("User", off) hint("User", off)
# begin Nimble config (version 2)
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config
+11 -12
View File
@@ -11,19 +11,18 @@ bin = @["nitter"]
# Dependencies # Dependencies
requires "nim >= 2.0.0" requires "nim >= 2.0.0"
requires "jester#baca3f" requires "jester == 0.6.0"
requires "karax#5cf360c" requires "karax == 1.5.0"
requires "sass#7dfdd03" requires "sass == 0.2.0"
requires "nimcrypto#a079df9" requires "nimcrypto == 0.7.3"
requires "markdown#158efe3" requires "markdown == 0.8.8"
requires "packedjson#9e6fbb6" requires "packedjson#9e6fbb6"
requires "supersnappy#6c94198" requires "supersnappy == 2.1.4"
requires "redpool#8b7c1db" requires "redpool == 0.2.2"
requires "https://github.com/zedeus/redis#d0a0e6f" requires "zippy == 0.10.19"
requires "zippy#ca5989a" requires "flatty == 0.4.0"
requires "flatty#e668085" requires "jsony == 1.1.6"
requires "jsony#1de1f08" requires "oauth == 0.11"
requires "oauth#b8c163b"
# Tasks # Tasks
+21 -27
View File
@@ -11,11 +11,11 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
if fieldToggles.len > 0: if fieldToggles.len > 0:
result.add ("fieldToggles", fieldToggles) result.add ("fieldToggles", fieldToggles)
proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl = proc apiUrl(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiUrl =
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles)) return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles), skipTid: skipTid)
proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq = proc apiReq(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiReq =
let url = apiUrl(endpoint, variables, fieldToggles) let url = apiUrl(endpoint, variables, fieldToggles, skipTid)
return ApiReq(cookie: url, oauth: url) return ApiReq(cookie: url, oauth: url)
proc mediaUrl(id, cursor: string; count=20): ApiReq = proc mediaUrl(id, cursor: string; count=20): ApiReq =
@@ -25,27 +25,22 @@ proc mediaUrl(id, cursor: string; count=20): ApiReq =
) )
proc userTweetsUrl(id: string; cursor: string): ApiReq = proc userTweetsUrl(id: string; cursor: string): ApiReq =
result = ApiReq( return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
# result = ApiReq(
# cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles), # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"]) # oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
) # )
# might change this in the future pending testing
result.cookie = result.oauth
proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
let cookieVars = userTweetsAndRepliesVars % [id, cursor] return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], skipTid=true)
result = ApiReq(
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"])
)
proc tweetDetailUrl(id: string; cursor: string): ApiReq = proc tweetDetailUrl(id: string; cursor: string): ApiReq =
let cookieVars = tweetDetailVars % [id, cursor] return apiReq(graphTweet, tweetVars % [id, cursor])
result = ApiReq( # let cookieVars = tweetDetailVars % [id, cursor]
# result = ApiReq(
# cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
cookie: apiUrl(graphTweet, tweetVars % [id, cursor]), # oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
oauth: apiUrl(graphTweet, tweetVars % [id, cursor]) # )
)
proc userUrl(username: string): ApiReq = proc userUrl(username: string): ApiReq =
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
@@ -184,13 +179,13 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
var var
variables = %*{ variables = %*{
"rawQuery": q, "rawQuery": q,
"query_source": "typedQuery",
"count": 20, "count": 20,
"querySource": "typed_query",
"product": "Latest", "product": "Latest",
"withDownvotePerspective": false, "withGrokTranslatedBio":true,
"withReactionsMetadata": false, "withQuickPromoteEligibilityTweetFields":false
"withReactionsPerspective": false
} }
if after.len > 0 and maxId.len == 0: if after.len > 0 and maxId.len == 0:
variables["cursor"] = % after variables["cursor"] = % after
let let
@@ -212,12 +207,11 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
var var
variables = %*{ variables = %*{
"rawQuery": query.text, "rawQuery": query.text,
"query_source": "typedQuery",
"count": 20, "count": 20,
"querySource": "typed_query",
"product": "People", "product": "People",
"withDownvotePerspective": false, "withGrokTranslatedBio":true,
"withReactionsMetadata": false, "withQuickPromoteEligibilityTweetFields":false
"withReactionsPerspective": false
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
+34 -16
View File
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
import jsony, packedjson, zippy, oauth1 import jsony, packedjson, zippy, oauth/oauth1
import types, auth, consts, parserutils, http_pool, tid import types, auth, consts, parserutils, http_pool, tid
import experimental/types/common import experimental/types/common
@@ -63,7 +63,7 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
proc getCookieHeader(authToken, ct0: string): string = proc getCookieHeader(authToken, ct0: string): string =
"auth_token=" & authToken & "; ct0=" & ct0 "auth_token=" & authToken & "; ct0=" & ct0
proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = proc genHeaders*(session: Session, url: Uri, skipTid: bool): Future[HttpHeaders] {.async.} =
result = newHttpHeaders({ result = newHttpHeaders({
"accept": "*/*", "accept": "*/*",
"accept-encoding": "gzip", "accept-encoding": "gzip",
@@ -84,13 +84,14 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
result["x-twitter-auth-type"] = "OAuth2Session" result["x-twitter-auth-type"] = "OAuth2Session"
result["x-csrf-token"] = session.ct0 result["x-csrf-token"] = session.ct0
result["cookie"] = getCookieHeader(session.authToken, session.ct0) result["cookie"] = getCookieHeader(session.authToken, session.ct0)
result["referer"] = "https://x.com/"
result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24"""" result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24""""
result["sec-ch-ua-mobile"] = "?0" result["sec-ch-ua-mobile"] = "?0"
result["sec-ch-ua-platform"] = "Windows" result["sec-ch-ua-platform"] = "Windows"
result["sec-fetch-dest"] = "empty" result["sec-fetch-dest"] = "empty"
result["sec-fetch-mode"] = "cors" result["sec-fetch-mode"] = "cors"
result["sec-fetch-site"] = "same-site" result["sec-fetch-site"] = "same-origin"
if disableTid or "/1.1/" in url.path: if disableTid or skipTid or "/1.1/" in url.path:
result["authorization"] = bearerToken2 result["authorization"] = bearerToken2
else: else:
result["authorization"] = bearerToken result["authorization"] = bearerToken
@@ -114,7 +115,12 @@ template fetchImpl(result, fetchBody) {.dirty.} =
try: try:
var resp: AsyncResponse var resp: AsyncResponse
pool.use(await genHeaders(session, url)): let skipTid = case session.kind
of oauth: req.oauth.skipTid
of cookie: req.cookie.skipTid
let headers = await genHeaders(session, url, skipTid)
pool.use(headers):
template getContent = template getContent =
# TODO: this is a temporary simple implementation # TODO: this is a temporary simple implementation
if apiProxy.len > 0 and "/1.1/" notin url.path: if apiProxy.len > 0 and "/1.1/" notin url.path:
@@ -130,7 +136,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
raise newException(BadClientError, "Bad client") raise newException(BadClientError, "Bad client")
if resp.status == $Http404 and result.len == 0: if resp.status == $Http404 and result.len == 0:
echo "[sessions] transient 404 (empty body), retrying: ", url.path echo "[sessions] transient 404 (empty body), retrying: ", url.path, ", session: ", session.pretty
raise rateLimitError() raise rateLimitError()
if resp.headers.hasKey(rlRemaining): if resp.headers.hasKey(rlRemaining):
@@ -147,7 +153,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
if result.startsWith("{\"errors"): if result.startsWith("{\"errors"):
let errors = result.fromJson(Errors) let errors = result.fromJson(Errors)
if errors notin errorsToSkip: if errors notin errorsToSkip:
echo "Fetch error, API: ", url.path, ", errors: ", errors echo "Fetch error, API: ", url.path, ", errors: ", errors, ", session: ", session.pretty
if errors in {expiredToken, badToken, locked}: if errors in {expiredToken, badToken, locked}:
invalidate(session) invalidate(session)
raise rateLimitError() raise rateLimitError()
@@ -162,7 +168,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
fetchBody fetchBody
if resp.status == $Http400: if resp.status == $Http400:
echo "ERROR 400, ", url.path, ": ", result echo "ERROR 400, ", url.path, ": ", result, ", session: ", session.pretty
raise newException(InternalError, $url) raise newException(InternalError, $url)
except InternalError as e: except InternalError as e:
raise e raise e
@@ -177,21 +183,33 @@ template fetchImpl(result, fetchBody) {.dirty.} =
finally: finally:
release(session) release(session)
template retry(bod) = template retry(bod) {.dirty.} =
var session: Session
var retrySuccess = false
for i in 0 ..< maxRetries: for i in 0 ..< maxRetries:
try: try:
session = nil
bod bod
retrySuccess = true
break break
except RateLimitError: except RateLimitError:
echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, let api = if session.isNil: req.cookie.endpoint
else: req.endpoint(session)
if session.isNil:
echo "[sessions] Rate limited, retrying ", api,
" request (", i, "/", maxRetries, ")..." " request (", i, "/", maxRetries, ")..."
else:
echo "[sessions] Rate limited, retrying ", api,
" request (", i, "/", maxRetries, ")..., session: ", session.pretty
session = nil
if retryDelayMs > 0: if retryDelayMs > 0:
await sleepAsync(retryDelayMs) await sleepAsync(retryDelayMs)
if not retrySuccess:
raise rateLimitError()
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
retry: retry:
var var body: string
body: string
session = await getAndValidateSession(req) session = await getAndValidateSession(req)
let url = req.toUrl(session.kind) let url = req.toUrl(session.kind)
@@ -200,22 +218,22 @@ proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
if body.startsWith('{') or body.startsWith('['): if body.startsWith('{') or body.startsWith('['):
result = parseJson(body) result = parseJson(body)
else: else:
echo resp.status, ": ", body, " --- url: ", url echo resp.status, ": ", body, " --- url: ", url, ", session: ", session.pretty
result = newJNull() result = newJNull()
let error = result.getError let error = result.getError
if error != null and error notin errorsToSkip: if error != null and error notin errorsToSkip:
echo "Fetch error, API: ", url.path, ", error: ", error echo "Fetch error, API: ", url.path, ", error: ", error, ", session: ", session.pretty
if error in {expiredToken, badToken, locked}: if error in {expiredToken, badToken, locked}:
invalidate(session) invalidate(session)
raise rateLimitError() raise rateLimitError()
proc fetchRaw*(req: ApiReq): Future[string] {.async.} = proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
retry: retry:
var session = await getAndValidateSession(req) session = await getAndValidateSession(req)
let url = req.toUrl(session.kind) let url = req.toUrl(session.kind)
fetchImpl result: 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, ", session: ", session.pretty
result.setLen(0) result.setLen(0)
+16 -1
View File
@@ -18,7 +18,7 @@ proc setMaxConcurrentReqs*(reqs: int) =
template log(str: varargs[string, `$`]) = template log(str: varargs[string, `$`]) =
echo "[sessions] ", str.join("") echo "[sessions] ", str.join("")
proc endpoint(req: ApiReq; session: Session): string = proc endpoint*(req: ApiReq; session: Session): string =
case session.kind case session.kind
of oauth: req.oauth.endpoint of oauth: req.oauth.endpoint
of cookie: req.cookie.endpoint of cookie: req.cookie.endpoint
@@ -50,6 +50,8 @@ proc getSessionPoolHealth*(): JsonNode =
oldest = now.int64 oldest = now.int64
newest = 0'i64 newest = 0'i64
average = 0'i64 average = 0'i64
oauthTotal, cookieTotal = 0
oauthLimited, cookieLimited = 0
for session in sessionPool: for session in sessionPool:
let created = snowflakeToEpoch(session.id) let created = snowflakeToEpoch(session.id)
@@ -59,8 +61,15 @@ proc getSessionPoolHealth*(): JsonNode =
oldest = created oldest = created
average += created average += created
case session.kind
of oauth: inc oauthTotal
of cookie: inc cookieTotal
if session.limited: if session.limited:
limited.incl session.id limited.incl session.id
case session.kind
of oauth: inc oauthLimited
of cookie: inc cookieLimited
for api in session.apis.keys: for api in session.apis.keys:
let let
@@ -84,6 +93,8 @@ proc getSessionPoolHealth*(): JsonNode =
"sessions": %*{ "sessions": %*{
"total": sessionPool.len, "total": sessionPool.len,
"limited": limited.card, "limited": limited.card,
"oauth": %*{"total": oauthTotal, "limited": oauthLimited},
"cookie": %*{"total": cookieTotal, "limited": cookieLimited},
"oldest": $fromUnix(oldest), "oldest": $fromUnix(oldest),
"newest": $fromUnix(newest), "newest": $fromUnix(newest),
"average": $fromUnix(average) "average": $fromUnix(average)
@@ -100,6 +111,7 @@ proc getSessionPoolDebug*(): JsonNode =
for session in sessionPool: for session in sessionPool:
let sessionJson = %*{ let sessionJson = %*{
"kind": $session.kind,
"apis": newJObject(), "apis": newJObject(),
"pending": session.pending, "pending": session.pending,
} }
@@ -173,7 +185,10 @@ proc getSession*(req: ApiReq): Future[Session] {.async.} =
if not result.isNil and result.isReady(req): if not result.isNil and result.isReady(req):
inc result.pending inc result.pending
else: else:
if result.isNil:
log "no sessions available for API: ", req.cookie.endpoint log "no sessions available for API: ", req.cookie.endpoint
else:
log "no sessions available for API: ", req.endpoint(result), ", last tried: ", result.pretty
raise noSessionsError() raise noSessionsError()
proc setLimited*(session: Session; req: ApiReq) = proc setLimited*(session: Session; req: ApiReq) =
+54 -93
View File
@@ -7,109 +7,70 @@ const
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName" graphUser* = "IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName"
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery"
graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" graphUserById* = "-DAaa9jPxPswYeI2fZ9rug/UserResultByIdQuery"
graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" graphUserTweetsV2* = "PHTSTXqZYuHIeK4B1HQprQ/UserWithProfileTweetsQueryV2"
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2"
graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets" graphUserTweets* = "PNd0vlufvrcIwrAnBYKE9g/UserTweets"
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" graphUserTweetsAndReplies* = "EqtpEwt0CoQXmDfq5DKH0A/UserTweetsAndReplies"
graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia" graphUserMedia* = "g_rGPF0fLON-M9cyVjXuzA/UserMedia"
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2"
graphTweet* = "b4pV7sWOe97RncwHcGESUA/ConversationTimeline" graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline"
graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphTweetDetail* = "6uCvnic3m5reVuehkvHa3w/TweetDetail"
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery"
graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory" graphTweetEditHistory* = "MGElmrYILE8wUfI8GorUYA/TweetEditHistory"
graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" graphSearchTimeline* = "-TFXKoMnMTKdEXcCn-eahw/SearchTimeline"
graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery"
graphBroadcast* = "0nMmbMh-_JwwRRFNXkyH3Q/BroadcastQuery" graphListById* = "t9AbdyHaJVfjL9jsODwgpQ/ListByRestId"
graphListBySlug* = "LDQpQ89B5ipR8izCKrWU0g/ListBySlug"
graphListMembers* = "EM7YRaM3gCnzDESmchA7RA/ListMembers"
graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery"
graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery"
restLiveStream* = "1.1/live_video_stream/status/" restLiveStream* = "1.1/live_video_stream/status/"
gqlFeatures* = """{ gqlFeatures* = """{
"android_ad_formats_media_component_render_overlay_enabled": false, "rweb_video_screen_enabled": false,
"android_graphql_skip_api_media_color_palette": false, "rweb_cashtags_enabled": true,
"android_professional_link_spotlight_display_enabled": false,
"articles_api_enabled": false,
"articles_preview_enabled": true,
"blue_business_profile_image_shape_enabled": false,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"commerce_android_shop_module_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
"creator_subscriptions_quote_tweet_preview_enabled": false,
"creator_subscriptions_subscription_count_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true,
"freedom_of_speech_not_reach_fetch_enabled": true,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
"grok_android_analyze_trend_fetch_enabled": false,
"grok_translations_community_note_auto_translation_is_enabled": false,
"grok_translations_community_note_translation_is_enabled": false,
"grok_translations_post_auto_translation_is_enabled": false,
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false,
"hidden_profile_likes_enabled": false,
"highlights_tweets_tab_ui_enabled": false,
"immersive_video_status_linkable_timestamps": false,
"interactive_text_enabled": false,
"longform_notetweets_consumption_enabled": true,
"longform_notetweets_inline_media_enabled": true,
"longform_notetweets_richtext_consumption_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"mobile_app_spotlight_module_enabled": false,
"payments_enabled": false,
"post_ctas_fetch_enabled": true,
"premium_content_api_read_enabled": false,
"profile_label_improvements_pcf_label_in_post_enabled": true, "profile_label_improvements_pcf_label_in_post_enabled": true,
"profile_label_improvements_pcf_label_in_profile_enabled": false, "responsive_web_profile_redirect_enabled": false,
"responsive_web_edit_tweet_api_enabled": true, "rweb_tipjar_consumption_enabled": false,
"responsive_web_enhance_cards_enabled": false, "verified_phone_label_enabled": false,
"responsive_web_graphql_exclude_directive_enabled": true, "creator_subscriptions_tweet_preview_api_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"responsive_web_graphql_timeline_navigation_enabled": true, "responsive_web_graphql_timeline_navigation_enabled": true,
"responsive_web_grok_analysis_button_from_backend": true, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false, "responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analyze_post_followups_enabled": true, "responsive_web_grok_analyze_post_followups_enabled": true,
"rweb_cashtags_composer_attachment_enabled": true,
"responsive_web_jetfuel_frame": true,
"responsive_web_grok_share_attachment_enabled": true,
"responsive_web_grok_annotations_enabled": true, "responsive_web_grok_annotations_enabled": true,
"responsive_web_grok_community_note_auto_translation_is_enabled": false, "articles_preview_enabled": true,
"responsive_web_edit_tweet_api_enabled": true,
"rweb_conversational_replies_downvote_enabled": false,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
"view_counts_everywhere_api_enabled": true,
"longform_notetweets_consumption_enabled": true,
"responsive_web_twitter_article_tweet_consumption_enabled": true,
"content_disclosure_indicator_enabled": true,
"content_disclosure_ai_generated_indicator_enabled": true,
"responsive_web_grok_show_grok_translated_post": true,
"responsive_web_grok_analysis_button_from_backend": true,
"post_ctas_fetch_enabled": true,
"freedom_of_speech_not_reach_fetch_enabled": true,
"standardized_nudges_misinfo": true,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"longform_notetweets_inline_media_enabled": false,
"responsive_web_grok_image_annotation_enabled": true, "responsive_web_grok_image_annotation_enabled": true,
"responsive_web_grok_imagine_annotation_enabled": true, "responsive_web_grok_imagine_annotation_enabled": true,
"responsive_web_grok_share_attachment_enabled": true, "responsive_web_grok_community_note_auto_translation_is_enabled": true,
"responsive_web_grok_show_grok_translated_post": false, "responsive_web_enhance_cards_enabled": false
"responsive_web_jetfuel_frame": true,
"responsive_web_media_download_video_enabled": false,
"responsive_web_profile_redirect_enabled": false,
"responsive_web_text_conversations_enabled": false,
"responsive_web_twitter_article_notes_tab_enabled": false,
"responsive_web_twitter_article_tweet_consumption_enabled": true,
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
"rweb_lists_timeline_redesign_enabled": true,
"rweb_tipjar_consumption_enabled": true,
"rweb_video_screen_enabled": false,
"rweb_video_timestamps_enabled": false,
"spaces_2022_h2_clipping": true,
"spaces_2022_h2_spaces_communities": true,
"standardized_nudges_misinfo": true,
"subscriptions_feature_can_gift_premium": false,
"subscriptions_verification_info_enabled": true,
"subscriptions_verification_info_is_identity_verified_enabled": false,
"subscriptions_verification_info_reason_enabled": true,
"subscriptions_verification_info_verified_since_enabled": true,
"super_follow_badge_privacy_enabled": false,
"super_follow_exclusive_tweet_notifications_enabled": false,
"super_follow_tweet_api_enabled": false,
"super_follow_user_api_enabled": false,
"tweet_awards_web_tipping_enabled": false,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
"tweetypie_unmention_optimization_enabled": false,
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
"unified_cards_destination_url_params_enabled": false,
"verified_phone_label_enabled": false,
"vibe_api_enabled": false,
"view_counts_everywhere_api_enabled": true,
"hidden_profile_subscriptions_enabled": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetVars* = """{ tweetVars* = """{
@@ -143,7 +104,7 @@ const
restIdVars* = """{ restIdVars* = """{
"rest_id": "$1", $2 "rest_id": "$1", $2
"count": $3 "count": $3
}""" }""".replace(" ", "").replace("\n", "")
userMediaVars* = """{ userMediaVars* = """{
"userId": "$1", $2 "userId": "$1", $2
+13 -5
View File
@@ -140,25 +140,30 @@ proc pageDesc*(user: User): string =
"The latest tweets from " & user.fullname "The latest tweets from " & user.fullname
proc getJoinDate*(user: User): string = proc getJoinDate*(user: User): string =
if user.joinDate.year == 0: return ""
user.joinDate.format("'Joined' MMMM YYYY") user.joinDate.format("'Joined' MMMM YYYY")
proc getJoinDateFull*(user: User): string = proc getJoinDateFull*(user: User): string =
if user.joinDate.year == 0: return ""
user.joinDate.format("h:mm tt - d MMM YYYY") user.joinDate.format("h:mm tt - d MMM YYYY")
proc getTime*(tweet: Tweet): string = proc getTime*(tweet: Tweet): string =
if tweet.time.year == 0: return ""
tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'") tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'")
proc getRfc822Time*(tweet: Tweet): string = proc getRfc822Time*(tweet: Tweet): string =
if tweet.time.year == 0: return ""
tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'") tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")
proc getShortTime*(tweet: Tweet): string = proc getShortTime*(time: DateTime): string =
if time.year == 0: return ""
let now = now() let now = now()
let since = now - tweet.time let since = now - time
if now.year != tweet.time.year: if now.year != time.year:
result = tweet.time.format("d MMM yyyy") result = time.format("d MMM yyyy")
elif since.inDays >= 1: elif since.inDays >= 1:
result = tweet.time.format("MMM d") result = time.format("MMM d")
elif since.inHours >= 1: elif since.inHours >= 1:
result = $since.inHours & "h" result = $since.inHours & "h"
elif since.inMinutes >= 1: elif since.inMinutes >= 1:
@@ -168,6 +173,9 @@ proc getShortTime*(tweet: Tweet): string =
else: else:
result = "now" result = "now"
proc getShortTime*(tweet: Tweet): string =
getShortTime(tweet.time)
proc getDuration*(ms: int): string = proc getDuration*(ms: int): string =
let let
sec = int(round(ms / 1000)) sec = int(round(ms / 1000))
+7 -2
View File
@@ -2,7 +2,7 @@
import asyncdispatch, strformat, logging import asyncdispatch, strformat, logging
from net import Port from net import Port
from htmlgen import a from htmlgen import a
from os import getEnv from os import getEnv, normalizedPath
import jester import jester
@@ -63,12 +63,17 @@ createDebugRouter(cfg)
settings: settings:
port = Port(cfg.port) port = Port(cfg.port)
staticDir = cfg.staticDir staticDir = normalizedPath(cfg.staticDir)
bindAddr = cfg.address bindAddr = cfg.address
reusePort = true reusePort = true
maxBody = 64 * 1024
routes: routes:
before: before:
# Reject malformed paths
if request.path.len == 0 or request.path[0] != '/':
halt Http400
# skip all file URLs # skip all file URLs
cond "." notin request.path cond "." notin request.path
applyUrlPrefs() applyUrlPrefs()
+56 -29
View File
@@ -1,10 +1,10 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, options, times, math, tables import strutils, options, times, math, tables, uri
import packedjson, packedjson/deserialiser import packedjson, packedjson/deserialiser
import types, parserutils, utils import types, parserutils, utils
import experimental/parser/unifiedcard import experimental/parser/unifiedcard
proc parseGraphTweet(js: JsonNode): Tweet proc parseGraphTweet*(js: JsonNode): Tweet
proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType = proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType =
try: parseEnum[VerifiedType](s) try: parseEnum[VerifiedType](s)
@@ -46,10 +46,10 @@ proc parseUser(js: JsonNode; id=""): User =
proc parseGraphUser(js: JsonNode): User = proc parseGraphUser(js: JsonNode): User =
var user = js{"user_result", "result"} var user = js{"user_result", "result"}
if user.isNull: if user.isNull:
user = ? js{"user_results", "result"} user = js{"user_results", "result"}
if user.isNull: if user.isNull:
if js{"core"}.notNull and js{"legacy"}.notNull: if js{"core"}.notNull:
user = js user = js
else: else:
return return
@@ -61,6 +61,7 @@ proc parseGraphUser(js: JsonNode): User =
# fallback to support UserMedia/recent GraphQL updates # fallback to support UserMedia/recent GraphQL updates
if result.username.len == 0: if result.username.len == 0:
result.id = user{"rest_id"}.getStr
result.username = user{"core", "screen_name"}.getStr result.username = user{"core", "screen_name"}.getStr
result.fullname = user{"core", "name"}.getStr result.fullname = user{"core", "name"}.getStr
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
@@ -228,6 +229,10 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
result.attribution = some(parseUser(user)) result.attribution = some(parseUser(user))
else: else:
result.attribution = some(parseGraphUser(user)) result.attribution = some(parseGraphUser(user))
# Set attribution link from expanded_url (strip /video/N suffix)
let expanded = m{"expanded_url"}.getStr
if expanded.len > 0:
result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
of "animated_gif": of "animated_gif":
result.media.addMedia(Gif( result.media.addMedia(Gif(
url: m{"video_info", "variants"}[0]{"url"}.getImageStr, url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
@@ -236,11 +241,6 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
)) ))
else: discard else: discard
with url, m{"url"}:
if result.text.endsWith(url.getStr):
result.text.removeSuffix(url.getStr)
result.text = result.text.strip()
proc parseMediaEntities(js: JsonNode; result: var Tweet) = proc parseMediaEntities(js: JsonNode; result: var Tweet) =
with mediaEntities, js{"media_entities"}: with mediaEntities, js{"media_entities"}:
var parsedMedia: MediaEntities var parsedMedia: MediaEntities
@@ -261,6 +261,18 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) =
durationMs: mediaInfo{"duration_millis"}.getInt, durationMs: mediaInfo{"duration_millis"}.getInt,
variants: parseVideoVariants(mediaInfo{"variants"}) variants: parseVideoVariants(mediaInfo{"variants"})
)) ))
# Parse source user for video attribution
with sourceUser, mediaEntity{"source_user_results", "result"}:
if result.attribution.isNone:
let expanded = mediaEntity{"expanded_url"}.getStr
if expanded.len > 0:
result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
result.attribution = some(User(
id: sourceUser{"rest_id"}.getStr,
fullname: sourceUser{"core", "name"}.getStr,
userPic: sourceUser{"avatar", "image_url"}.getImageStr.replace("_normal", "")
))
of "ApiGif": of "ApiGif":
parsedMedia.addMedia(Gif( parsedMedia.addMedia(Gif(
url: mediaInfo{"variants"}[0]{"url"}.getImageStr, url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
@@ -269,23 +281,9 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) =
)) ))
else: discard else: discard
if "expanded_url" in mediaEntity:
let expandedUrl = js.getExpandedUrl
if result.text.endsWith(expandedUrl):
result.text.removeSuffix(expandedUrl)
result.text = result.text.strip()
if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len: if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
result.media = parsedMedia result.media = parsedMedia
# Remove media URLs from text
with mediaList, js{"legacy", "entities", "media"}:
for url in mediaList:
let expandedUrl = url.getExpandedUrl
if result.text.endsWith(expandedUrl):
result.text.removeSuffix(expandedUrl)
result.text = result.text.strip()
proc parsePromoVideo(js: JsonNode): Video = proc parsePromoVideo(js: JsonNode): Video =
result = Video( result = Video(
thumb: js{"player_image_large"}.getImageVal, thumb: js{"player_image_large"}.getImageVal,
@@ -428,13 +426,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
# graphql # graphql
with rt, js{"retweeted_status_result", "result"}: with rt, js{"retweeted_status_result", "result"}:
# needed due to weird edgecase where the actual tweet data isn't included # needed due to weird edgecase where the actual tweet data isn't included
if "legacy" in rt: if "legacy" in rt or "rest_id" in rt:
result.retweet = some parseGraphTweet(rt) result.retweet = some parseGraphTweet(rt)
return return
with reposts, js{"repostedStatusResults"}: with reposts, js{"repostedStatusResults"}:
with rt, reposts{"result"}: with rt, reposts{"result"}:
if "legacy" in rt: if "legacy" in rt or "rest_id" in rt:
result.retweet = some parseGraphTweet(rt) result.retweet = some parseGraphTweet(rt)
return return
@@ -449,7 +447,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
result.poll = some parsePoll(jsCard) result.poll = some parsePoll(jsCard)
elif name == "amplify": elif name == "amplify":
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
else: elif name.len > 0 and jsCard{"binding_values"}.notNull:
result.card = some parseCard(jsCard, js{"entities", "urls"}) result.card = some parseCard(jsCard, js{"entities", "urls"})
result.expandTweetEntities(js) result.expandTweetEntities(js)
@@ -469,7 +467,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
result.text.removeSuffix(" Learn more.") result.text.removeSuffix(" Learn more.")
result.available = false result.available = false
proc parseGraphTweet(js: JsonNode): Tweet = proc parseGraphTweet*(js: JsonNode): Tweet =
if js.kind == JNull: if js.kind == JNull:
return Tweet() return Tweet()
@@ -506,7 +504,7 @@ proc parseGraphTweet(js: JsonNode): Tweet =
"binding_values": %bindingObj "binding_values": %bindingObj
} }
var replyId = 0 var replyId: int64 = 0
with restId, js{"reply_to_results", "rest_id"}: with restId, js{"reply_to_results", "rest_id"}:
replyId = restId.getId replyId = restId.getId
@@ -537,10 +535,25 @@ proc parseGraphTweet(js: JsonNode): Tweet =
result.poll = some parsePoll(jsCard) result.poll = some parsePoll(jsCard)
elif name == "amplify": elif name == "amplify":
result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
else: elif name.len > 0 and jsCard{"binding_values"}.notNull:
result.card = some parseCard(jsCard, js{"url_entities"}) result.card = some parseCard(jsCard, js{"url_entities"})
parseMediaEntities(js, result)
if result.attribution.isNone:
parseLegacyMediaEntities(js{"legacy"}, result)
result.expandTweetEntitiesV2(js) result.expandTweetEntitiesV2(js)
# Strip video source URL from text (for videos from other tweets)
with mediaEntities, js{"media_entities"}:
for m in mediaEntities:
if "source_status_id_str" in m:
let mediaUrl = m{"url"}.getStr
if mediaUrl.len > 0:
let idx = result.text.rfind(mediaUrl)
if idx >= 0:
result.text = result.text[0 ..< idx].strip()
break
else: else:
result = parseTweet(js{"legacy"}, jsCard, replyId) result = parseTweet(js{"legacy"}, jsCard, replyId)
result.id = js{"rest_id"}.getId result.id = js{"rest_id"}.getId
@@ -559,6 +572,20 @@ proc parseGraphTweet(js: JsonNode): Tweet =
parseMediaEntities(js, result) parseMediaEntities(js, result)
# Hide card if it's redundant with attribution (same video shown via embed)
if result.attribution.isSome and result.card.isSome:
let cardUri = get(result.card).url.parseUri
if cardUri.isTwitterUrl:
let cardPath = cardUri.path.replace("/video/1", "")
if cardPath.len > 0 and cardPath == result.attributionLink:
get(result.card).kind = hidden
# Handle retweets - check both legacy and top-level paths
with reposts, js{"legacy", "repostedStatusResults"}:
with rt, reposts{"result"}:
if "legacy" in rt or "rest_id" in rt:
result.retweet = some parseGraphTweet(rt)
with quoted, js{"quoted_status_result", "result"}: with quoted, js{"quoted_status_result", "result"}:
result.quote = some(parseGraphTweet(quoted)) result.quote = some(parseGraphTweet(quoted))
+25 -8
View File
@@ -202,15 +202,28 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
textSlice: Slice[int]): string = textSlice: Slice[int]): string =
let
runeLen = runes.len
safeStart = max(0, textSlice.a)
safeEnd = min(runeLen, textSlice.b)
var validRepls: seq[ReplaceSlice]
for rep in repls:
if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b:
validRepls.add rep
template extractLowerBound(i: int; idx): int = template extractLowerBound(i: int; idx): int =
if i > 0: repls[idx].slice.b.succ else: textSlice.a if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart
result = newStringOfCap(runes.len) result = newStringOfCap(runes.len)
for i, rep in repls: for i, rep in validRepls:
result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a] let lower = extractLowerBound(i, i - 1)
if lower < rep.slice.a:
result.add $runes[lower ..< rep.slice.a]
case rep.kind case rep.kind
of rkHashtag: of rkHashtag:
if rep.slice.a.succ <= rep.slice.b:
let let
name = $runes[rep.slice.a.succ .. rep.slice.b] name = $runes[rep.slice.a.succ .. rep.slice.b]
symbol = $runes[rep.slice.a] symbol = $runes[rep.slice.a]
@@ -222,8 +235,8 @@ proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
of rkRemove: of rkRemove:
discard discard
let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd
if rest.a <= rest.b: if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen:
result.add $runes[rest] result.add $runes[rest]
proc deduplicate(s: var seq[ReplaceSlice]) = proc deduplicate(s: var seq[ReplaceSlice]) =
@@ -326,7 +339,8 @@ 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 or hasJobCard)
proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
hasRedundantLink=false) = hasRedundantLink=false) =
@@ -377,16 +391,19 @@ proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) =
textSlice = textRange{0}.getInt .. textRange{1}.getInt textSlice = textRange{0}.getInt .. textRange{1}.getInt
hasQuote = "quoted_tweet_results" in js hasQuote = "quoted_tweet_results" in js
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
hasAttribution = tweet.attribution.isSome
tweet.expandTextEntitiesV2(js, tweet.text, textSlice, hasQuote or hasJobCard) tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
hasQuote or hasJobCard or hasAttribution)
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.multiReplace(("<", unicodeOpen), (">", unicodeClose))
textSlice = 0..text.runeLen textSlice = 0..text.runeLen
hasAttribution = tweet.attribution.isSome
tweet.expandTextEntities(entities, text, textSlice) tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution)
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose)) tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
+1 -1
View File
@@ -8,7 +8,7 @@ const
"media", "images", "twimg", "videos", "media", "images", "twimg", "videos",
"native_video", "consumer_video", "spaces", "native_video", "consumer_video", "spaces",
"links", "news", "quote", "mentions", "links", "news", "quote", "mentions",
"replies", "retweets", "nativeretweets" "replies", "retweets", "nativeretweets", "cashtags"
] ]
emptyQuery* = "include:nativeretweets" emptyQuery* = "include:nativeretweets"
+2 -1
View File
@@ -144,8 +144,9 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
else: else:
let user = await getGraphUserById(userId) let user = await getGraphUserById(userId)
result = user.username result = user.username
if result.len > 0:
await setEx(key, baseCacheTime, result) await setEx(key, baseCacheTime, result)
if result.len > 0 and user.id.len > 0: if 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.} =
+2 -1
View File
@@ -8,8 +8,9 @@ export utils, prefs, types, uri
template savePref*(pref, value: string; req: Request; expire=false) = template savePref*(pref, value: string; req: Request; expire=false) =
if not expire or pref in cookies(req): if not expire or pref in cookies(req):
let sameSite = if cfg.useHttps: None else: Lax
setCookie(pref, value, daysForward(when expire: -10 else: 360), setCookie(pref, value, daysForward(when expire: -10 else: 360),
httpOnly=true, secure=cfg.useHttps, sameSite=None, path="/") httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/")
template requestPrefs*(): untyped {.dirty.} = template requestPrefs*(): untyped {.dirty.} =
getPrefs(cookies(request), params(request)) getPrefs(cookies(request), params(request))
+4 -3
View File
@@ -46,6 +46,7 @@ proc createSearchRouter*(cfg: Config) =
redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash")) redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash"))
get "/opensearch": get "/opensearch":
let url = getUrlPrefix(cfg) & "/search?f=tweets&q=" let
resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, url = getUrlPrefix(cfg) & "/search?f=tweets&q="
generateOpenSearchXML(cfg.title, cfg.hostname, url) headers = {"Content-Type": "application/opensearchdescription+xml"}
resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url)
+2
View File
@@ -16,6 +16,7 @@ type
ApiUrl* = object ApiUrl* = object
endpoint*: string endpoint*: string
params*: seq[(string, string)] params*: seq[(string, string)]
skipTid*: bool
ApiReq* = object ApiReq* = object
oauth*: ApiUrl oauth*: ApiUrl
@@ -264,6 +265,7 @@ type
stats*: TweetStats stats*: TweetStats
retweet*: Option[Tweet] retweet*: Option[Tweet]
attribution*: Option[User] attribution*: Option[User]
attributionLink*: string
mediaTags*: seq[User] mediaTags*: seq[User]
quote*: Option[Tweet] quote*: Option[Tweet]
card*: Option[Card] card*: Option[Card]
+1
View File
@@ -84,6 +84,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
text cfg.title text cfg.title
meta(name="viewport", content="width=device-width, initial-scale=1.0") meta(name="viewport", content="width=device-width, initial-scale=1.0")
meta(name="referrer", content="same-origin")
meta(name="theme-color", content="#1F1F1F") meta(name="theme-color", content="#1F1F1F")
meta(property="og:type", content=ogType) meta(property="og:type", content=ogType)
meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText)) meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText))
+2 -1
View File
@@ -15,7 +15,8 @@ const toggles = {
"links": "Links", "links": "Links",
"images": "Images", "images": "Images",
"quote": "Quotes", "quote": "Quotes",
"spaces": "Spaces" "spaces": "Spaces",
"cashtags": "Cashtags"
}.toOrderedTable }.toOrderedTable
proc renderSearch*(): VNode = proc renderSearch*(): VNode =
+4 -3
View File
@@ -239,8 +239,9 @@ proc renderReply(tweet: Tweet): VNode =
if i > 0: text " " if i > 0: text " "
a(href=("/" & u)): text "@" & u a(href=("/" & u)): text "@" & u
proc renderAttribution(user: User; prefs: Prefs): VNode = proc renderAttribution(user: User; prefs: Prefs; link = ""): VNode =
buildHtml(a(class="attribution", href=("/" & user.username))): let href = if link.len > 0: link else: "/" & user.username
buildHtml(a(class="attribution", href=href)):
renderMiniAvatar(user, prefs) renderMiniAvatar(user, prefs)
strong: text user.fullname strong: text user.fullname
verifiedIcon(user) verifiedIcon(user)
@@ -386,7 +387,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet) verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet)
if tweet.attribution.isSome: if tweet.attribution.isSome:
renderAttribution(tweet.attribution.get(), prefs) renderAttribution(tweet.attribution.get(), prefs, tweet.attributionLink)
if tweet.card.isSome and tweet.card.get().kind != hidden: if tweet.card.isSome and tweet.card.get().kind != hidden:
renderCard(tweet.card.get(), prefs, path) renderCard(tweet.card.get(), prefs, path)
View File
+76
View File
@@ -0,0 +1,76 @@
from base import BaseTestCase, Profile
from parameterized import parameterized
class AboutAccount(object):
header = '.about-account-header'
name = '.about-account-name'
body = '.about-account-body'
row = '.about-account-row'
label = '.about-account-label'
value = '.about-account-value'
# (username, expected_labels)
# Each label is checked for presence in the page text
about_data = [
['jack', ['Date joined', 'Account based in', 'Connected via']],
['NASA', ['Date joined']],
['elonmusk', ['Date joined']],
]
about_verified = [
['jack', 'Verified', 'Since '],
]
about_affiliate = [
['jack', 'An affiliate of', 'Square'],
['elonmusk', 'An affiliate of', 'X'],
]
class AboutAccountTest(BaseTestCase):
@parameterized.expand(about_data)
def test_about_page_has_labels(self, username, expected_labels):
"""About page shows expected info labels"""
self.open_nitter(f'{username}/about')
self.assert_element_visible(AboutAccount.header)
self.assert_element_visible(AboutAccount.body)
for label in expected_labels:
self.assert_text(label, AboutAccount.body)
@parameterized.expand(about_verified)
def test_about_verified(self, username, label, value_prefix):
"""About page shows verification info for verified accounts"""
self.open_nitter(f'{username}/about')
self.assert_text(label, AboutAccount.body)
self.assert_text(value_prefix, AboutAccount.body)
@parameterized.expand(about_affiliate)
def test_about_affiliate(self, username, label, affiliate):
"""About page shows affiliate info"""
self.open_nitter(f'{username}/about')
self.assert_text(label, AboutAccount.body)
self.assert_text(f'@{affiliate}', AboutAccount.body)
def test_about_page_title(self):
"""Title contains account name"""
self.open_nitter('jack/about')
self.assert_text('jack', AboutAccount.name)
def test_about_join_date(self):
"""About page always shows join date"""
self.open_nitter('jack/about')
self.assert_text('Date joined', AboutAccount.body)
self.assert_text('March 2006', AboutAccount.body)
def test_about_invalid_user(self):
"""About page for non-existent user shows error"""
self.open_nitter('thisprofiledoesntexist/about')
self.assert_text('User "thisprofiledoesntexist" not found')
def test_joindate_links_to_about(self):
"""Join date on profile page links to about page"""
self.open_nitter('jack')
link = self.find_element(Profile.joinDate + ' a')
self.assertIn('/jack/about', link.get_attribute('href'))
+12 -17
View File
@@ -11,18 +11,18 @@ card = [
['voidtarget/status/1094632512926605312', ['voidtarget/status/1094632512926605312',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
'gist.github.com', True] 'gist.github.com', True],
['NASA/status/2061872347477418301',
'Nancy Grace Roman Space Telescope - NASA Science',
'The Nancy Grace Roman Space Telescope will settle essential questions in the areas of dark energy, exoplanets, and astrophysics.',
'science.nasa.gov', True]
] ]
no_thumb = [ no_thumb = [
['FluentAI/status/1116417904831029248',
'LinkedIn',
'This link will take you to a page thats not on LinkedIn',
'lnkd.in'],
['Thom_Wolf/status/1122466524860702729', ['Thom_Wolf/status/1122466524860702729',
'GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in', 'GitHub - facebookresearch/XLM: PyTorch original implementation of Cross-lingual Language Model',
'', 'PyTorch original implementation of Cross-lingual Language Model Pretraining.',
'github.com'], 'github.com'],
['brent_p/status/1088857328680488961', ['brent_p/status/1088857328680488961',
@@ -37,14 +37,9 @@ no_thumb = [
] ]
playable = [ playable = [
['nim_lang/status/1118234460904919042', ['NASA/status/2047048645845897398',
'Nim development blog 2019-03', 'NASA\'s Artemis II News Conference with Moon Astronauts',
'Arne (aka Krux02)* debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g* bugs: * forwarding of .pure. * sizeof union* fe...', 'Live from NASA\'s Johnson Space Center in Houston',
'youtube.com'],
['nim_lang/status/1121090879823986688',
'Nim - First natively compiled language w/ hot code-reloading at...',
'#nim #c++ #ACCUConfNim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming cap...',
'youtube.com'] 'youtube.com']
] ]
@@ -72,7 +67,7 @@ class CardTest(BaseTestCase):
if len(description) > 0: if len(description) > 0:
self.assert_text(description, c.description) self.assert_text(description, c.description)
@parameterized.expand(playable) @parameterized.expand(playable, skip_on_empty=True)
def test_card_playable(self, tweet, title, description, destination): def test_card_playable(self, tweet, title, description, destination):
self.open_nitter(tweet) self.open_nitter(tweet)
c = Card(Conversation.main + " ") c = Card(Conversation.main + " ")
+60
View File
@@ -0,0 +1,60 @@
import subprocess
from parameterized import parameterized
BASE_URL = 'http://localhost:8080'
def curl_status(url):
"""Get HTTP status code using curl to avoid URL normalization by Python libs."""
result = subprocess.run(
['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', url],
capture_output=True, text=True, timeout=10
)
return int(result.stdout)
class TestMalformedPaths:
"""Test that malformed paths don't crash the server.
URLs like //foo are parsed as having 'foo' as the authority (host),
resulting in an empty path. Empty paths previously crashed jester's
static file handler. Now they return 400.
URLs like //foo/bar are parsed as authority='foo', path='/bar',
so they route normally (not empty path).
"""
@parameterized.expand([
# These parse to empty paths -> 400
('//lefty_rae', 400),
('//test', 400),
('//anyuser', 400),
])
def test_empty_path_returns_400(self, path, expected_status):
"""URLs that parse to empty paths should return 400, not crash."""
status = curl_status(f'{BASE_URL}{path}')
assert status == expected_status, \
f'Expected {expected_status} for {path}, got {status}'
@parameterized.expand([
('/jack', 200),
('/about', 200),
('/', 200),
])
def test_normal_paths_work(self, path, expected_status):
"""Normal paths should still work."""
status = curl_status(f'{BASE_URL}{path}')
assert status == expected_status, \
f'Expected {expected_status} for {path}, got {status}'
def test_server_survives_malformed_requests(self):
"""Server should handle malformed requests without crashing."""
# These all parse to empty paths
malformed_paths = ['//a', '//b', '//c', '//user', '//test']
for path in malformed_paths:
status = curl_status(f'{BASE_URL}{path}')
assert status == 400, f'Expected 400 for {path}, got {status}'
# Verify server is still responding after malformed requests
status = curl_status(f'{BASE_URL}/')
assert status == 200, 'Server should still be alive'
+1 -1
View File
@@ -8,7 +8,7 @@ thread = [
[], [],
"Based", "Based",
["Crystal", "Julia"], ["Crystal", "Julia"],
[["yeah,"]], [["For", "Then"], ["yeah,"]],
], ],
["octonion/status/975254452625002496", ["Based"], "Crystal", ["Julia"], []], ["octonion/status/975254452625002496", ["Based"], "Crystal", ["Julia"], []],
["octonion/status/975256058384887808", ["Based", "Crystal"], "Julia", [], []], ["octonion/status/975256058384887808", ["Based", "Crystal"], "Julia", [], []],
+3 -3
View File
@@ -71,8 +71,8 @@ emoji = [
] ]
retweet = [ retweet = [
[7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', '1234'], [7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test',
[3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr'] 'Testing. 1234.']
] ]
@@ -120,7 +120,7 @@ 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, skip_on_empty=True)
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)
+7 -7
View File
@@ -28,14 +28,14 @@ video_m3u8 = [
] ]
gallery = [ gallery = [
# ['mobile_test/status/451108446603980803', [ ['mobile_test/status/451108446603980803', [
# ['BkKovdrCUAAEz79', 'BkKovdcCEAAfoBO'] ['BkKovdrCUAAEz79', 'BkKovdcCEAAfoBO']
# ]], ]],
# ['mobile_test/status/471539824713691137', [ ['mobile_test/status/471539824713691137', [
# ['Bos--KNIQAAA7Li', 'Bos--FAIAAAWpah'], ['Bos--KNIQAAA7Li', 'Bos--FAIAAAWpah'],
# ['Bos--IqIQAAav23'] ['Bos--IqIQAAav23']
# ]], ]],
['mobile_test/status/469530783384743936', [ ['mobile_test/status/469530783384743936', [
['BoQbwJAIUAA0QCY', 'BoQbwN1IMAAuTiP'], ['BoQbwJAIUAA0QCY', 'BoQbwN1IMAAuTiP'],