Compare commits

8 Commits

Author SHA1 Message Date
Zed
401aa26464 Improve proxied mp4 caching 2023-05-21 00:47:09 +02:00
Zed
93208908e6 Merge branch 'master' into feature/mp4-streaming 2023-05-20 22:23:12 +02:00
Zed
6e490c2dd9 Improve gif html 2022-06-11 23:27:11 +02:00
Zed
00daab1f15 Disable mp4 preloading 2022-06-11 20:18:45 +02:00
Zed
608c3ca8df Lazy load images 2022-06-11 17:41:59 +02:00
Zed
0610f7b890 Fix image route order 2022-06-08 22:20:28 +02:00
Zed
1dab9c9c61 Update mp4Playback preference text 2022-06-06 22:56:48 +02:00
Zed
651941acd1 Implement experimental mp4 streaming 2022-06-06 22:50:28 +02:00
70 changed files with 1042 additions and 1621 deletions

View File

@@ -10,7 +10,6 @@ on:
jobs: jobs:
tests: tests:
uses: ./.github/workflows/run-tests.yml uses: ./.github/workflows/run-tests.yml
secrets: inherit
build-docker-amd64: build-docker-amd64:
needs: [tests] needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204 runs-on: buildjet-2vcpu-ubuntu-2204

View File

@@ -8,101 +8,38 @@ on:
- master - master
workflow_call: workflow_call:
# Ensure that multiple runs on the same branch do not overlap.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs: jobs:
build-test: test:
name: Build and test runs-on: ubuntu-latest
runs-on: buildjet-2vcpu-ubuntu-2204
strategy:
matrix:
nim: ["1.6.x", "2.0.x", "2.2.x", "devel"]
steps: steps:
- name: Checkout Code - uses: actions/checkout@v3
uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Cache nimble
- name: Cache Nimble Dependencies
id: cache-nimble id: cache-nimble
uses: buildjet/cache@v4 uses: actions/cache@v3
with: with:
path: ~/.nimble path: ~/.nimble
key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }} key: nimble-${{ hashFiles('*.nimble') }}
restore-keys: | restore-keys: "nimble-"
${{ matrix.nim }}-nimble-v2- - uses: actions/setup-python@v4
- name: Setup Nim
uses: jiro4989/setup-nim-action@v2
with:
nim-version: ${{ matrix.nim }}
use-nightlies: true
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Project
run: nimble build -d:release -Y
integration-test:
needs: [build-test]
name: Integration test
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache Nimble Dependencies
id: cache-nimble
uses: buildjet/cache@v4
with:
path: ~/.nimble
key: devel-nimble-v2-${{ hashFiles('*.nimble') }}
restore-keys: |
devel-nimble-v2-
- name: Setup Python (3.10) with pip cache
uses: buildjet/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.10"
cache: pip cache: "pip"
- uses: jiro4989/setup-nim-action@v1
- name: Setup Nim
uses: jiro4989/setup-nim-action@v2
with: with:
nim-version: devel nim-version: "1.x"
use-nightlies: true - run: nimble build -d:release -Y
repo-token: ${{ secrets.GITHUB_TOKEN }} - run: pip install seleniumbase
- run: seleniumbase install chromedriver
- name: Build Project - uses: supercharge/redis-github-action@1.5.0
run: nimble build -d:release -Y - name: Prepare Nitter
- name: Install SeleniumBase and Chromedriver
run: | run: |
pip install seleniumbase sudo apt install libsass-dev -y
seleniumbase install chromedriver
- name: Start Redis Service
uses: supercharge/redis-github-action@1.5.0
- name: Prepare Nitter Environment
run: |
sudo apt-get update && sudo apt-get install -y libsass-dev
cp nitter.example.conf nitter.conf cp nitter.example.conf nitter.conf
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
nimble md nimble md
nimble scss nimble scss
echo '${{ secrets.SESSIONS }}' | head -n1 - name: Run tests
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
- name: Run Tests
run: | run: |
./nitter & ./nitter &
pytest -n1 tests pytest -n4 tests

2
.gitignore vendored
View File

@@ -10,6 +10,4 @@ nitter
/public/css/style.css /public/css/style.css
/public/md/*.html /public/md/*.html
nitter.conf nitter.conf
guest_accounts.json*
sessions.json*
dump.rdb dump.rdb

View File

@@ -1,4 +1,4 @@
FROM nimlang/nim:2.2.0-alpine-regular as nim FROM nimlang/nim:1.6.10-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
@@ -9,7 +9,7 @@ COPY nitter.nimble .
RUN nimble install -y --depsOnly RUN nimble install -y --depsOnly
COPY . . COPY . .
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \ RUN nimble build -d:danger -d:lto -d:strip \
&& nimble scss \ && nimble scss \
&& nimble md && nimble md

View File

@@ -1,7 +1,7 @@
FROM alpine:3.20.6 as nim FROM alpine:3.17 as nim
LABEL maintainer="setenforce@protonmail.com" LABEL maintainer="setenforce@protonmail.com"
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble RUN apk --no-cache add gcc git libc-dev libsass-dev "nim=1.6.8-r0" nimble pcre
WORKDIR /src/nitter WORKDIR /src/nitter
@@ -9,17 +9,15 @@ COPY nitter.nimble .
RUN nimble install -y --depsOnly RUN nimble install -y --depsOnly
COPY . . COPY . .
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \ RUN nimble build -d:danger -d:lto -d:strip \
&& nimble scss \ && nimble scss \
&& nimble md && nimble md
FROM alpine:3.20.6 FROM alpine:3.17
WORKDIR /src/ WORKDIR /src/
RUN apk --no-cache add pcre ca-certificates openssl RUN apk --no-cache add ca-certificates pcre openssl1.1-compat
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
EXPOSE 8080 EXPOSE 8080
RUN adduser -h /src/ -D -s /bin/sh nitter
USER nitter
CMD ./nitter CMD ./nitter

View File

@@ -4,35 +4,27 @@
[![Test Matrix](https://github.com/zedeus/nitter/workflows/Docker/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml) [![Test Matrix](https://github.com/zedeus/nitter/workflows/Docker/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml)
[![License](https://img.shields.io/github/license/zedeus/nitter?style=flat)](#license) [![License](https://img.shields.io/github/license/zedeus/nitter?style=flat)](#license)
> [!NOTE]
> Running a Nitter instance now requires real accounts, since Twitter removed the previous methods. \
> This does not affect users. \
> For instructions on how to obtain session tokens, see [Creating session tokens](https://github.com/zedeus/nitter/wiki/Creating-session-tokens).
A free and open source alternative Twitter front-end focused on privacy and A free and open source alternative Twitter front-end focused on privacy and
performance. \ performance. \
Inspired by the [Invidious](https://github.com/iv-org/invidious) project. Inspired by the [Invidious](https://github.com/iv-org/invidious)
project.
- No JavaScript or ads - No JavaScript or ads
- All requests go through the backend, client never talks to Twitter - All requests go through the backend, client never talks to Twitter
- Prevents Twitter from tracking your IP or JavaScript fingerprint - Prevents Twitter from tracking your IP or JavaScript fingerprint
- Uses Twitter's unofficial API (no developer account required) - Uses Twitter's unofficial API (no rate limits or developer account required)
- Lightweight (for [@nim_lang](https://nitter.net/nim_lang), 60KB vs 784KB from twitter.com) - Lightweight (for [@nim_lang](https://nitter.net/nim_lang), 60KB vs 784KB from twitter.com)
- RSS feeds - RSS feeds
- Themes - Themes
- Mobile support (responsive design) - Mobile support (responsive design)
- AGPLv3 licensed, no proprietary instances permitted - AGPLv3 licensed, no proprietary instances permitted
<details> Liberapay: https://liberapay.com/zedeus \
<summary>Donations</summary> Patreon: https://patreon.com/nitter \
Liberapay: https://liberapay.com/zedeus<br> BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
Patreon: https://patreon.com/nitter<br> ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55<br> LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460<br> XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL<br>
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW<br>
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
</details>
## Roadmap ## Roadmap
@@ -50,13 +42,12 @@ maintained by the community.
## Why? ## Why?
It's impossible to use Twitter without JavaScript enabled, and as of 2024 you It's impossible to use Twitter without JavaScript enabled. For privacy-minded
need to sign up. For privacy-minded folks, preventing JavaScript analytics and folks, preventing JavaScript analytics and IP-based tracking is important, but
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix, apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
it's impossible. Despite being behind a VPN and using heavy-duty adblockers, a VPN and using heavy-duty adblockers, you can get accurately tracked with your
you can get accurately tracked with your [browser's [browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no [no JavaScript required](https://noscriptfingerprint.com/). This all became
JavaScript required](https://noscriptfingerprint.com/). This all became
particularly important after Twitter [removed the particularly important after Twitter [removed the
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws) ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
for users to control whether their data gets sent to advertisers. for users to control whether their data gets sent to advertisers.
@@ -80,21 +71,19 @@ Twitter account.
- libpcre - libpcre
- libsass - libsass
- redis/valkey - redis
To compile Nitter you need a Nim installation, see To compile Nitter you need a Nim installation, see
[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible [nim-lang.org](https://nim-lang.org/install.html) for details. It is possible to
to install it system-wide or in the user directory you create below. install it system-wide or in the user directory you create below.
To compile the scss files, you need to install `libsass`. On Ubuntu and Debian, To compile the scss files, you need to install `libsass`. On Ubuntu and Debian,
you can use `libsass-dev`. you can use `libsass-dev`.
Redis is required for caching and in the future for account info. As of 2024 Redis is required for caching and in the future for account info. It should be
Redis is no longer open source, so using the fork Valkey is recommended. It available on most distros as `redis` or `redis-server` (Ubuntu/Debian).
should be available on most distros as `redis` or `redis-server` Running it with the default config is fine, Nitter's default config is set to
(Ubuntu/Debian), or `valkey`/`valkey-server`. Running it with the default use the default Redis port and localhost.
config is fine, Nitter's default config is set to use the default port and
localhost.
Here's how to create a `nitter` user, clone the repo, and build the project Here's how to create a `nitter` user, clone the repo, and build the project
along with the scss and md files. along with the scss and md files.
@@ -104,7 +93,7 @@ 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 build -d:release
$ nimble scss $ nimble scss
$ nimble md $ nimble md
$ cp nitter.example.conf nitter.conf $ cp nitter.example.conf nitter.conf

View File

@@ -7,7 +7,12 @@
# disable annoying warnings # disable annoying warnings
warning("GcUnsafe2", off) warning("GcUnsafe2", off)
warning("HoleEnumConv", off)
hint("XDeclaredButNotUsed", off) hint("XDeclaredButNotUsed", off)
hint("XCannotRaiseY", off) hint("XCannotRaiseY", off)
hint("User", off) hint("User", off)
const
nimVersion = (major: NimMajor, minor: NimMinor, patch: NimPatch)
when nimVersion >= (1, 6, 0):
warning("HoleEnumConv", off)

View File

@@ -9,7 +9,6 @@ services:
- "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy - "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
volumes: volumes:
- ./nitter.conf:/src/nitter.conf:Z,ro - ./nitter.conf:/src/nitter.conf:Z,ro
- ./sessions.jsonl:/src/sessions.jsonl:Z,ro # Run get_sessions.py to get the credentials
depends_on: depends_on:
- nitter-redis - nitter-redis
restart: unless-stopped restart: unless-stopped

View File

@@ -23,9 +23,15 @@ redisMaxConnections = 30
hmacKey = "secretkey" # random key for cryptographic signing of video urls hmacKey = "secretkey" # random key for cryptographic signing of video urls
base64Media = false # use base64 encoding for proxied media urls base64Media = false # use base64 encoding for proxied media urls
enableRSS = true # set this to false to disable RSS feeds enableRSS = true # set this to false to disable RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.sessions) enableDebug = false # enable request logs and debug endpoints (/.tokens)
proxy = "" # http/https url, SOCKS proxies are not supported proxy = "" # http/https url, SOCKS proxies are not supported
proxyAuth = "" proxyAuth = ""
tokenCount = 10
# minimum amount of usable tokens. tokens are used to authorize API requests,
# but they expire after ~1 hour, and have a limit of 500 requests per endpoint.
# the limits reset every 15 minutes, and the pool is filled up so there's
# 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
# Change default preferences here, see src/prefs_impl.nim for a complete list # Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences] [Preferences]

View File

@@ -10,11 +10,11 @@ bin = @["nitter"]
# Dependencies # Dependencies
requires "nim >= 1.6.10" requires "nim >= 1.4.8"
requires "jester#baca3f" requires "jester#baca3f"
requires "karax#5cf360c" requires "karax#5cf360c"
requires "sass#7dfdd03" requires "sass#7dfdd03"
requires "nimcrypto#a079df9" requires "nimcrypto#4014ef9"
requires "markdown#158efe3" requires "markdown#158efe3"
requires "packedjson#9e6fbb6" requires "packedjson#9e6fbb6"
requires "supersnappy#6c94198" requires "supersnappy#6c94198"
@@ -22,8 +22,8 @@ requires "redpool#8b7c1db"
requires "https://github.com/zedeus/redis#d0a0e6f" requires "https://github.com/zedeus/redis#d0a0e6f"
requires "zippy#ca5989a" requires "zippy#ca5989a"
requires "flatty#e668085" requires "flatty#e668085"
requires "jsony#1de1f08" requires "jsony#ea811be"
requires "oauth#b8c163b"
# Tasks # Tasks

5
public/js/hls.light.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@ function insertBeforeLast(node, elem) {
} }
function getLoadMore(doc) { function getLoadMore(doc) {
return doc.querySelector(".show-more:not(.timeline-item)"); return doc.querySelector('.show-more:not(.timeline-item)');
} }
function isDuplicate(item, itemClass) { function isDuplicate(item, itemClass) {
@@ -15,19 +15,18 @@ function isDuplicate(item, itemClass) {
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null; return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
} }
window.onload = function () { window.onload = function() {
const url = window.location.pathname; const url = window.location.pathname;
const isTweet = url.indexOf("/status/") !== -1; const isTweet = url.indexOf("/status/") !== -1;
const containerClass = isTweet ? ".replies" : ".timeline"; const containerClass = isTweet ? ".replies" : ".timeline";
const itemClass = containerClass + " > div:not(.top-ref)"; const itemClass = containerClass + ' > div:not(.top-ref)';
var html = document.querySelector("html"); var html = document.querySelector("html");
var container = document.querySelector(containerClass); var container = document.querySelector(containerClass);
var loading = false; var loading = false;
function handleScroll(failed) { window.addEventListener('scroll', function() {
if (loading) return; if (loading) return;
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
loading = true; loading = true;
var loadMore = getLoadMore(document); var loadMore = getLoadMore(document);
@@ -36,15 +35,13 @@ window.onload = function () {
loadMore.children[0].text = "Loading..."; loadMore.children[0].text = "Loading...";
var url = new URL(loadMore.children[0].href); var url = new URL(loadMore.children[0].href);
url.searchParams.append("scroll", "true"); url.searchParams.append('scroll', 'true');
fetch(url.toString()).then(function (response) { fetch(url.toString()).then(function (response) {
if (response.status === 404) throw "error";
return response.text(); return response.text();
}).then(function (html) { }).then(function (html) {
var parser = new DOMParser(); var parser = new DOMParser();
var doc = parser.parseFromString(html, "text/html"); var doc = parser.parseFromString(html, 'text/html');
loadMore.remove(); loadMore.remove();
for (var item of doc.querySelectorAll(itemClass)) { for (var item of doc.querySelectorAll(itemClass)) {
@@ -60,18 +57,10 @@ window.onload = function () {
if (isTweet) container.appendChild(newLoadMore); if (isTweet) container.appendChild(newLoadMore);
else insertBeforeLast(container, newLoadMore); else insertBeforeLast(container, newLoadMore);
}).catch(function (err) { }).catch(function (err) {
console.warn("Something went wrong.", err); console.warn('Something went wrong.', err);
if (failed > 3) { loading = true;
loadMore.children[0].text = "Error";
return;
}
loading = false;
handleScroll((failed || 0) + 1);
}); });
} }
} });
window.addEventListener("scroll", () => handleScroll());
}; };
// @license-end // @license-end

View File

@@ -4,15 +4,15 @@ Nitter is a free and open source alternative Twitter front-end focused on
privacy and performance. The source is available on GitHub at privacy and performance. The source is available on GitHub at
<https://github.com/zedeus/nitter> <https://github.com/zedeus/nitter>
- No JavaScript or ads * No JavaScript or ads
- All requests go through the backend, client never talks to Twitter * All requests go through the backend, client never talks to Twitter
- Prevents Twitter from tracking your IP or JavaScript fingerprint * Prevents Twitter from tracking your IP or JavaScript fingerprint
- Uses Twitter's unofficial API (no developer account required) * Uses Twitter's unofficial API (no rate limits or developer account required)
- Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com) * Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
- RSS feeds * RSS feeds
- Themes * Themes
- Mobile support (responsive design) * Mobile support (responsive design)
- AGPLv3 licensed, no proprietary instances permitted * AGPLv3 licensed, no proprietary instances permitted
Nitter's GitHub wiki contains Nitter's GitHub wiki contains
[instances](https://github.com/zedeus/nitter/wiki/Instances) and [instances](https://github.com/zedeus/nitter/wiki/Instances) and
@@ -21,13 +21,12 @@ maintained by the community.
## Why use Nitter? ## Why use Nitter?
It's impossible to use Twitter without JavaScript enabled, and as of 2024 you It's impossible to use Twitter without JavaScript enabled. For privacy-minded
need to sign up. For privacy-minded folks, preventing JavaScript analytics and folks, preventing JavaScript analytics and IP-based tracking is important, but
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix, apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
it's impossible. Despite being behind a VPN and using heavy-duty adblockers, a VPN and using heavy-duty adblockers, you can get accurately tracked with your
you can get accurately tracked with your [browser's [browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no [no JavaScript required](https://noscriptfingerprint.com/). This all became
JavaScript required](https://noscriptfingerprint.com/). This all became
particularly important after Twitter [removed the particularly important after Twitter [removed the
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws) ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
for users to control whether their data gets sent to advertisers. for users to control whether their data gets sent to advertisers.
@@ -43,13 +42,12 @@ Twitter account.
## Donating ## Donating
Liberapay: https://liberapay.com/zedeus \ Liberapay: <https://liberapay.com/zedeus> \
Patreon: https://patreon.com/nitter \ Patreon: <https://patreon.com/nitter> \
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55 \ BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460 \ ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL \ LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW \ XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
## Contact ## Contact

View File

@@ -7,20 +7,20 @@ import experimental/parser as newParser
proc getGraphUser*(username: string): Future[User] {.async.} = proc getGraphUser*(username: string): Future[User] {.async.} =
if username.len == 0: return if username.len == 0: return
let let
variables = """{"screen_name": "$1"}""" % username variables = %*{"screen_name": username}
params = {"variables": variables, "features": gqlFeatures} params = {"variables": $variables, "features": gqlFeatures}
js = await fetchRaw(graphUser ? params, Api.userScreenName) js = await fetchRaw(graphUser ? params, Api.userScreenName)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserById*(id: string): Future[User] {.async.} = proc getGraphUserById*(id: string): Future[User] {.async.} =
if id.len == 0 or id.any(c => not c.isDigit): return if id.len == 0 or id.any(c => not c.isDigit): return
let let
variables = """{"rest_id": "$1"}""" % id variables = %*{"userId": id}
params = {"variables": variables, "features": gqlFeatures} params = {"variables": $variables, "features": gqlFeatures}
js = await fetchRaw(graphUserById ? params, Api.userRestId) js = await fetchRaw(graphUserById ? params, Api.userRestId)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
@@ -40,7 +40,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
variables = listTweetsVariables % [id, cursor] variables = listTweetsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures} params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphListTweets ? params, Api.listTweets) js = await fetch(graphListTweets ? params, Api.listTweets)
result = parseGraphTimeline(js, "list", after).tweets result = parseGraphTimeline(js, "list", after)
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
let let
@@ -50,8 +50,8 @@ proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
proc getGraphList*(id: string): Future[List] {.async.} = proc getGraphList*(id: string): Future[List] {.async.} =
let let
variables = """{"listId": "$1"}""" % id variables = %*{"listId": id}
params = {"variables": variables, "features": gqlFeatures} params = {"variables": $variables, "features": gqlFeatures}
result = parseGraphList(await fetch(graphListById ? params, Api.list)) result = parseGraphList(await fetch(graphListById ? params, Api.list))
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
@@ -72,7 +72,7 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
variables = """{"rest_id": "$1"}""" % id variables = tweetResultVariables % id
params = {"variables": variables, "features": gqlFeatures} params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphTweetResult ? params, Api.tweetResult) js = await fetch(graphTweetResult ? params, Api.tweetResult)
result = parseGraphTweetResult(js) result = parseGraphTweetResult(js)
@@ -95,10 +95,10 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
if after.len > 0: if after.len > 0:
result.replies = await getReplies(id, after) result.replies = await getReplies(id, after)
proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} =
let q = genQueryParam(query) let q = genQueryParam(query)
if q.len == 0 or q == emptyQuery: if q.len == 0 or q == emptyQuery:
return Timeline(query: query, beginning: true) return Result[Tweet](query: query, beginning: true)
var var
variables = %*{ variables = %*{
@@ -112,37 +112,34 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) result = parseGraphSearch(await fetch(url, Api.search), after)
result.query = query result.query = query
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} =
if query.text.len == 0: if query.text.len == 0:
return Result[User](query: query, beginning: true) return Result[User](query: query, beginning: true)
var var url = userSearch ? {
variables = %*{ "q": query.text,
"rawQuery": query.text, "skip_status": "1",
"count": 20, "count": "20",
"product": "People", "page": page
"withDownvotePerspective": false, }
"withReactionsMetadata": false,
"withReactionsPerspective": false
}
if after.len > 0:
variables["cursor"] = % after
result.beginning = false
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} result = parseUsers(await fetchRaw(url, Api.userSearch))
result = parseGraphSearch[User](await fetch(url, Api.search), after)
result.query = query result.query = query
if page.len == 0:
result.bottom = "2"
elif page.allCharsInSet(Digits):
result.bottom = $(parseInt(page) + 1)
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} =
if id.len == 0: return if name.len == 0: return
let let
variables = userTweetsVariables % [id, ""] ps = genParams({"screen_name": name, "trim_user": "true"},
params = {"variables": variables, "features": gqlFeatures} count="18", ext=false)
url = graphUserMedia ? params url = photoRail ? ps
result = parseGraphPhotoRail(await fetch(url, Api.userMedia)) result = parsePhotoRail(await fetch(url, Api.timeline))
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
let client = newAsyncHttpClient(maxRedirects=0) let client = newAsyncHttpClient(maxRedirects=0)

View File

@@ -1,78 +1,66 @@
# 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
import jsony, packedjson, zippy, oauth1 import jsony, packedjson, zippy
import types, auth, consts, parserutils, http_pool import types, tokens, consts, parserutils, http_pool
import experimental/types/common import experimental/types/common
const const
rlRemaining = "x-rate-limit-remaining" rlRemaining = "x-rate-limit-remaining"
rlReset = "x-rate-limit-reset" rlReset = "x-rate-limit-reset"
rlLimit = "x-rate-limit-limit"
errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
var pool: HttpPool var pool: HttpPool
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc genParams*(pars: openArray[(string, string)] = @[]; cursor="";
let count="20"; ext=true): seq[(string, string)] =
encodedUrl = url.replace(",", "%2C").replace("+", "%20") result = timelineParams
params = OAuth1Parameters( for p in pars:
consumerKey: consumerKey, result &= p
signatureMethod: "HMAC-SHA1", if ext:
timestamp: $int(round(epochTime())), result &= ("ext", "mediaStats")
nonce: "0", result &= ("include_ext_alt_text", "1")
isIncludeVersionToHeader: true, result &= ("include_ext_media_availability", "1")
token: oauthToken if count.len > 0:
) result &= ("count", count)
signature = getSignature(HttpGet, encodedUrl, "", params, consumerSecret, oauthTokenSecret) if cursor.len > 0:
# The raw cursor often has plus signs, which sometimes get turned into spaces,
# so we need to turn them back into a plus
if " " in cursor:
result &= ("cursor", cursor.replace(" ", "+"))
else:
result &= ("cursor", cursor)
params.signature = percentEncode(signature) proc genHeaders*(token: Token = nil): HttpHeaders =
return getOauth1RequestHeader(params)["authorization"]
proc getCookieHeader(authToken, ct0: string): string =
"auth_token=" & authToken & "; ct0=" & ct0
proc genHeaders*(session: Session, url: string): HttpHeaders =
result = newHttpHeaders({ result = newHttpHeaders({
"connection": "keep-alive", "connection": "keep-alive",
"authorization": auth,
"content-type": "application/json", "content-type": "application/json",
"x-guest-token": if token == nil: "" else: token.tok,
"x-twitter-active-user": "yes", "x-twitter-active-user": "yes",
"x-twitter-client-language": "en", "authority": "api.twitter.com",
"authority": "api.x.com",
"accept-encoding": "gzip", "accept-encoding": "gzip",
"accept-language": "en-US,en;q=0.9", "accept-language": "en-US,en;q=0.9",
"accept": "*/*", "accept": "*/*",
"DNT": "1", "DNT": "1"
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
}) })
case session.kind template updateToken() =
of SessionKind.oauth: if resp.headers.hasKey(rlRemaining):
result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) let
of SessionKind.cookie: remaining = parseInt(resp.headers[rlRemaining])
result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" reset = parseInt(resp.headers[rlReset])
result["x-twitter-auth-type"] = "OAuth2Session" token.setRateLimit(api, remaining, reset)
result["x-csrf-token"] = session.ct0
result["cookie"] = getCookieHeader(session.authToken, session.ct0)
template fetchImpl(result, fetchBody) {.dirty.} = template fetchImpl(result, fetchBody) {.dirty.} =
once: once:
pool = HttpPool() pool = HttpPool()
var session = await getSession(api) var token = await getToken(api)
case session.kind if token.tok.len == 0:
of SessionKind.oauth: raise rateLimitError()
if session.oauthToken.len == 0:
echo "[sessions] Empty oauth token, session: ", session.id
raise rateLimitError()
of SessionKind.cookie:
if session.authToken.len == 0 or session.ct0.len == 0:
echo "[sessions] Empty cookie credentials, session: ", session.id
raise rateLimitError()
try: try:
var resp: AsyncResponse var resp: AsyncResponse
pool.use(genHeaders(session, $url)): pool.use(genHeaders(token)):
template getContent = template getContent =
resp = await c.get($url) resp = await c.get($url)
result = await resp.body result = await resp.body
@@ -83,79 +71,57 @@ template fetchImpl(result, fetchBody) {.dirty.} =
badClient = true badClient = true
raise newException(BadClientError, "Bad client") raise newException(BadClientError, "Bad client")
if resp.headers.hasKey(rlRemaining):
let
remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset])
limit = parseInt(resp.headers[rlLimit])
session.setRateLimit(api, remaining, reset, limit)
if result.len > 0: if result.len > 0:
if resp.headers.getOrDefault("content-encoding") == "gzip": if resp.headers.getOrDefault("content-encoding") == "gzip":
result = uncompress(result, dfGzip) result = uncompress(result, dfGzip)
else:
if result.startsWith("{\"errors"): echo "non-gzip body, url: ", url, ", body: ", result
let errors = result.fromJson(Errors)
if errors notin errorsToSkip:
echo "Fetch error, API: ", api, ", errors: ", errors
if errors in {expiredToken, badToken, locked}:
invalidate(session)
raise rateLimitError()
elif errors in {rateLimited}:
# rate limit hit, resets after 24 hours
setLimited(session, api)
raise rateLimitError()
elif result.startsWith("429 Too Many Requests"):
echo "[sessions] 429 error, API: ", api, ", session: ", session.id
session.apis[api].remaining = 0
# rate limit hit, resets after the 15 minute window
raise rateLimitError()
fetchBody fetchBody
release(token, used=true)
if resp.status == $Http400: if resp.status == $Http400:
echo "ERROR 400, ", api, ": ", result
raise newException(InternalError, $url) raise newException(InternalError, $url)
except InternalError as e: except InternalError as e:
raise e raise e
except BadClientError as e: except BadClientError as e:
raise e release(token, used=true)
except OSError as e:
raise e raise e
except Exception as e: except Exception as e:
let id = if session.isNil: "null" else: $session.id echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url
echo "error: ", e.name, ", msg: ", e.msg, ", sessionId: ", id, ", url: ", url if "length" notin e.msg and "descriptor" notin e.msg:
release(token, invalid=true)
raise rateLimitError() raise rateLimitError()
finally:
release(session)
template retry(bod) =
try:
bod
except RateLimitError:
echo "[sessions] Rate limited, retrying ", api, " request..."
bod
proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
retry: var body: string
var body: string fetchImpl body:
fetchImpl body: 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 result = newJNull()
result = newJNull()
let error = result.getError updateToken()
if error != null and error notin errorsToSkip:
echo "Fetch error, API: ", api, ", error: ", error let error = result.getError
if error in {expiredToken, badToken, locked}: if error in {invalidToken, badToken}:
invalidate(session) echo "fetch error: ", result.getError
raise rateLimitError() release(token, invalid=true)
raise rateLimitError()
proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} =
retry: 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 result.setLen(0)
result.setLen(0)
updateToken()
if result.startsWith("{\"errors"):
let errors = result.fromJson(Errors)
if errors in {invalidToken, badToken}:
echo "fetch error: ", errors
release(token, invalid=true)
raise rateLimitError()

View File

@@ -1,203 +0,0 @@
#SPDX-License-Identifier: AGPL-3.0-only
import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os]
import types
import experimental/parser/session
# max requests at a time per session to avoid race conditions
const
maxConcurrentReqs = 2
hourInSeconds = 60 * 60
apiMaxReqs: Table[Api, int] = {
Api.search: 50,
Api.tweetDetail: 500,
Api.userTweets: 500,
Api.userTweetsAndReplies: 500,
Api.userMedia: 500,
Api.userRestId: 500,
Api.userScreenName: 500,
Api.tweetResult: 500,
Api.list: 500,
Api.listTweets: 500,
Api.listMembers: 500,
Api.listBySlug: 500
}.toTable
var
sessionPool: seq[Session]
enableLogging = false
template log(str: varargs[string, `$`]) =
echo "[sessions] ", str.join("")
proc snowflakeToEpoch(flake: int64): int64 =
int64(((flake shr 22) + 1288834974657) div 1000)
proc getSessionPoolHealth*(): JsonNode =
let now = epochTime().int
var
totalReqs = 0
limited: PackedSet[int64]
reqsPerApi: Table[string, int]
oldest = now.int64
newest = 0'i64
average = 0'i64
for session in sessionPool:
let created = snowflakeToEpoch(session.id)
if created > newest:
newest = created
if created < oldest:
oldest = created
average += created
if session.limited:
limited.incl session.id
for api in session.apis.keys:
let
apiStatus = session.apis[api]
limit = if apiStatus.limit > 0: apiStatus.limit else: apiMaxReqs.getOrDefault(api, 0)
reqs = limit - apiStatus.remaining
# no requests made with this session and endpoint since the limit reset
if apiStatus.reset < now:
continue
reqsPerApi.mgetOrPut($api, 0).inc reqs
totalReqs.inc reqs
if sessionPool.len > 0:
average = average div sessionPool.len
else:
oldest = 0
average = 0
return %*{
"sessions": %*{
"total": sessionPool.len,
"limited": limited.card,
"oldest": $fromUnix(oldest),
"newest": $fromUnix(newest),
"average": $fromUnix(average)
},
"requests": %*{
"total": totalReqs,
"apis": reqsPerApi
}
}
proc getSessionPoolDebug*(): JsonNode =
let now = epochTime().int
var list = newJObject()
for session in sessionPool:
let sessionJson = %*{
"apis": newJObject(),
"pending": session.pending,
}
if session.limited:
sessionJson["limited"] = %true
for api in session.apis.keys:
let
apiStatus = session.apis[api]
obj = %*{}
if apiStatus.reset > now.int:
obj["remaining"] = %apiStatus.remaining
obj["reset"] = %apiStatus.reset
if "remaining" notin obj:
continue
sessionJson{"apis", $api} = obj
list[$session.id] = sessionJson
return %list
proc rateLimitError*(): ref RateLimitError =
newException(RateLimitError, "rate limited")
proc noSessionsError*(): ref NoSessionsError =
newException(NoSessionsError, "no sessions available")
proc isLimited(session: Session; api: Api): bool =
if session.isNil:
return true
if session.limited and api != Api.userTweets:
if (epochTime().int - session.limitedAt) > hourInSeconds:
session.limited = false
log "resetting limit: ", session.id
return false
else:
return true
if api in session.apis:
let limit = session.apis[api]
return limit.remaining <= 10 and limit.reset > epochTime().int
else:
return false
proc isReady(session: Session; api: Api): bool =
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api))
proc invalidate*(session: var Session) =
if session.isNil: return
log "invalidating: ", session.id
# TODO: This isn't sufficient, but it works for now
let idx = sessionPool.find(session)
if idx > -1: sessionPool.delete(idx)
session = nil
proc release*(session: Session) =
if session.isNil: return
dec session.pending
proc getSession*(api: Api): Future[Session] {.async.} =
for i in 0 ..< sessionPool.len:
if result.isReady(api): break
result = sessionPool.sample()
if not result.isNil and result.isReady(api):
inc result.pending
else:
log "no sessions available for API: ", api
raise noSessionsError()
proc setLimited*(session: Session; api: Api) =
session.limited = true
session.limitedAt = epochTime().int
log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", id: ", session.id
proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) =
# avoid undefined behavior in race conditions
if api in session.apis:
let rateLimit = session.apis[api]
if rateLimit.reset >= reset and rateLimit.remaining < remaining:
return
if rateLimit.reset == reset and rateLimit.remaining >= remaining:
session.apis[api].remaining = remaining
return
session.apis[api] = RateLimit(limit: limit, remaining: remaining, reset: reset)
proc initSessionPool*(cfg: Config; path: string) =
enableLogging = cfg.enableDebug
if path.endsWith(".json"):
log "ERROR: .json is not supported, the file must be a valid JSONL file ending in .jsonl"
quit 1
if not fileExists(path):
log "ERROR: ", path, " not found. This file is required to authenticate API requests."
quit 1
log "parsing JSONL account sessions file: ", path
for line in path.lines:
sessionPool.add parseSession(line)
log "successfully added ", sessionPool.len, " valid account sessions"

View File

@@ -1,33 +1,58 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils import uri, sequtils, strutils
const const
consumerKey* = "3nVuSoBZnx6U4vzUxf5w" auth* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
gql = parseUri("https://api.x.com") / "graphql" api = parseUri("https://api.twitter.com")
activate* = $(api / "1.1/guest/activate.json")
graphUser* = gql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" photoRail* = api / "1.1/statuses/media_timeline.json"
graphUserById* = gql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" userSearch* = api / "1.1/users/search.json"
graphUserTweets* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2"
graphUserTweetsAndReplies* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2" graphql = api / "graphql"
graphUserMedia* = gql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" graphUser* = graphql / "pVrmNaXcxPjisIvKtLDMEA/UserByScreenName"
graphTweet* = gql / "Vorskcd2tZ-tc4Gx3zbk4Q/ConversationTimelineV2" graphUserById* = graphql / "1YAM811Q8Ry4XyPpJclURQ/UserByRestId"
graphTweetResult* = gql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" graphUserTweets* = graphql / "WzJjibAcDa-oCjCcLOotcg/UserTweets"
graphSearchTimeline* = gql / "KI9jCXUx3Ymt-hDKLOZb9Q/SearchTimeline" graphUserTweetsAndReplies* = graphql / "fn9oRltM1N4thkh5CVusPg/UserTweetsAndReplies"
graphListById* = gql / "oygmAig8kjn0pKsx_bUadQ/ListByRestId" graphUserMedia* = graphql / "qQoeS7szGavsi8-ehD2AWg/UserMedia"
graphListBySlug* = gql / "88GTz-IPPWLn1EiU8XoNVg/ListBySlug" graphTweet* = graphql / "miKSMGb2R1SewIJv2-ablQ/TweetDetail"
graphListMembers* = gql / "kSmxeqEeelqdHSR7jMnb_w/ListMembers" graphTweetResult* = graphql / "0kc0a_7TTr3dvweZlMslsQ/TweetResultByRestId"
graphListTweets* = gql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
graphListTweets* = graphql / "jZntL0oVJSdjhmPcdbw_eA/ListLatestTweetsTimeline"
timelineParams* = {
"include_profile_interstitial_type": "0",
"include_blocking": "0",
"include_blocked_by": "0",
"include_followed_by": "0",
"include_want_retweets": "0",
"include_mute_edge": "0",
"include_can_dm": "0",
"include_can_media_tag": "1",
"include_ext_is_blue_verified": "1",
"skip_status": "1",
"cards_platform": "Web-12",
"include_cards": "1",
"include_composer_source": "0",
"include_reply_count": "1",
"tweet_mode": "extended",
"include_entities": "1",
"include_user_entities": "1",
"include_ext_media_color": "0",
"send_error_codes": "1",
"simple_quoted_tweet": "1",
"include_quote_count": "1"
}.toSeq
gqlFeatures* = """{ gqlFeatures* = """{
"android_graphql_skip_api_media_color_palette": false,
"blue_business_profile_image_shape_enabled": false, "blue_business_profile_image_shape_enabled": false,
"creator_subscriptions_subscription_count_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true, "creator_subscriptions_tweet_preview_api_enabled": true,
"freedom_of_speech_not_reach_fetch_enabled": false, "freedom_of_speech_not_reach_fetch_enabled": false,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
"hidden_profile_likes_enabled": false,
"highlights_tweets_tab_ui_enabled": false, "highlights_tweets_tab_ui_enabled": false,
"interactive_text_enabled": false, "interactive_text_enabled": false,
"longform_notetweets_consumption_enabled": true, "longform_notetweets_consumption_enabled": true,
@@ -39,74 +64,58 @@ const
"responsive_web_graphql_exclude_directive_enabled": true, "responsive_web_graphql_exclude_directive_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"responsive_web_graphql_timeline_navigation_enabled": false, "responsive_web_graphql_timeline_navigation_enabled": false,
"responsive_web_media_download_video_enabled": false,
"responsive_web_text_conversations_enabled": false, "responsive_web_text_conversations_enabled": false,
"responsive_web_twitter_article_tweet_consumption_enabled": false,
"responsive_web_twitter_blue_verified_badge_is_enabled": true, "responsive_web_twitter_blue_verified_badge_is_enabled": true,
"rweb_lists_timeline_redesign_enabled": true, "rweb_lists_timeline_redesign_enabled": true,
"spaces_2022_h2_clipping": true, "spaces_2022_h2_clipping": true,
"spaces_2022_h2_spaces_communities": true, "spaces_2022_h2_spaces_communities": true,
"standardized_nudges_misinfo": false, "standardized_nudges_misinfo": false,
"subscriptions_verification_info_enabled": true,
"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_awards_web_tipping_enabled": false,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false, "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
"tweetypie_unmention_optimization_enabled": false, "tweetypie_unmention_optimization_enabled": false,
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
"verified_phone_label_enabled": false, "verified_phone_label_enabled": false,
"vibe_api_enabled": false, "vibe_api_enabled": false,
"view_counts_everywhere_api_enabled": false, "view_counts_everywhere_api_enabled": false
"premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": false,
"responsive_web_jetfuel_frame": false,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_image_annotation_enabled": false,
"rweb_tipjar_consumption_enabled": false,
"profile_label_improvements_pcf_label_in_post_enabled": false,
"creator_subscriptions_quote_tweet_preview_enabled": false,
"c9s_tweet_anatomy_moderator_badge_enabled": false,
"responsive_web_grok_analyze_post_followups_enabled": false,
"rweb_video_timestamps_enabled": false,
"responsive_web_grok_share_attachment_enabled": false,
"articles_preview_enabled": false,
"immersive_video_status_linkable_timestamps": false,
"articles_api_enabled": false,
"responsive_web_grok_analysis_button_from_backend": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetVariables* = """{ tweetVariables* = """{
"focalTweetId": "$1", "focalTweetId": "$1",
$2 $2
"includeHasBirdwatchNotes": false,
"includePromotedContent": false,
"withBirdwatchNotes": false, "withBirdwatchNotes": false,
"withVoice": false, "includePromotedContent": false,
"withV2Timeline": true "withDownvotePerspective": false,
}""".replace(" ", "").replace("\n", "") "withReactionsMetadata": false,
"withReactionsPerspective": false,
"withVoice": false
}"""
# oldUserTweetsVariables* = """{ tweetResultVariables* = """{
# "userId": "$1", $2 "tweetId": "$1",
# "count": 20, "includePromotedContent": false,
# "includePromotedContent": false, "withDownvotePerspective": false,
# "withDownvotePerspective": false, "withReactionsMetadata": false,
# "withReactionsMetadata": false, "withReactionsPerspective": false,
# "withReactionsPerspective": false, "withVoice": false,
# "withVoice": false, "withCommunity": false
# "withV2Timeline": true }"""
# }
# """
userTweetsVariables* = """{ userTweetsVariables* = """{
"rest_id": "$1", $2 "userId": "$1", $2
"count": 20 "count": 20,
"includePromotedContent": false,
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false,
"withVoice": false,
"withV2Timeline": true
}""" }"""
listTweetsVariables* = """{ listTweetsVariables* = """{
"rest_id": "$1", $2 "listId": "$1", $2
"count": 20 "count": 20,
"includePromotedContent": false,
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false,
"withVoice": false
}""" }"""

View File

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

View File

@@ -1,28 +0,0 @@
import std/strutils
import jsony
import ../types/session
from ../../types import Session, SessionKind
proc parseSession*(raw: string): Session =
let session = raw.fromJson(RawSession)
let kind = if session.kind == "": "oauth" else: session.kind
case kind
of "oauth":
let id = session.oauthToken[0 ..< session.oauthToken.find('-')]
result = Session(
kind: SessionKind.oauth,
id: parseBiggestInt(id),
oauthToken: session.oauthToken,
oauthSecret: session.oauthTokenSecret
)
of "cookie":
let id = if session.id.len > 0: parseBiggestInt(session.id) else: 0
result = Session(
kind: SessionKind.cookie,
id: id,
authToken: session.authToken,
ct0: session.ct0
)
else:
raise newException(ValueError, "Unknown session kind: " & kind)

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
import options import options
from ../../types import User import user
type type
GraphUser* = object GraphUser* = object
data*: tuple[userResult: UserData] data*: tuple[user: UserData]
UserData* = object UserData* = object
result*: UserResult result*: UserResult
UserResult = object UserResult = object
legacy*: User legacy*: RawUser
restId*: string restId*: string
isBlueVerified*: bool isBlueVerified*: bool
unavailableReason*: Option[string] reason*: Option[string]

View File

@@ -1,9 +0,0 @@
type
RawSession* = object
kind*: string
username*: string
id*: string
oauthToken*: string
oauthTokenSecret*: string
authToken*: string
ct0*: string

View File

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

View File

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

View File

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

View File

@@ -11,8 +11,6 @@ const
let let
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com" twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>""" twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
xRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?x\.com"
xLinkRegex = re"""<a href="https:\/\/x.com([^"]+)">x\.com(\S+)</a>"""
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase}) ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
@@ -58,18 +56,12 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
if prefs.replaceYouTube.len > 0 and "youtu" in result: if prefs.replaceYouTube.len > 0 and "youtu" in result:
result = result.replace(ytRegex, prefs.replaceYouTube) result = result.replace(ytRegex, prefs.replaceYouTube)
if prefs.replaceTwitter.len > 0: if prefs.replaceTwitter.len > 0 and ("twitter.com" in body or tco in body):
if tco in result: result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") result = result.replace(cards, prefs.replaceTwitter & "/cards")
if "x.com" in result: result = result.replace(twRegex, prefs.replaceTwitter)
result = result.replace(xRegex, prefs.replaceTwitter) result = result.replacef(twLinkRegex, a(
result = result.replacef(xLinkRegex, a( prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
if "twitter.com" in result:
result = result.replace(cards, prefs.replaceTwitter & "/cards")
result = result.replace(twRegex, prefs.replaceTwitter)
result = result.replacef(twLinkRegex, a(
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result): if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
@@ -90,8 +82,6 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
for line in manifest.splitLines: for line in manifest.splitLines:
let url = let url =
if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2] if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
else: line else: line
if url.startsWith('/'): if url.startsWith('/'):
let path = "https://video.twimg.com" & url let path = "https://video.twimg.com" & url

View File

@@ -39,8 +39,11 @@ template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped =
try: try:
body body
except BadClientError, ProtocolError: except ProtocolError:
# Twitter returned 503 or closed the connection, we need a new client # Twitter closed the connection, retry
body
except BadClientError:
# Twitter returned 503, we need a new client
pool.release(c, true) pool.release(c, true)
badClient = false badClient = false
c = pool.acquire(heads) c = pool.acquire(heads)

View File

@@ -6,7 +6,7 @@ from os import getEnv
import jester import jester
import types, config, prefs, formatters, redis_cache, http_pool, auth import types, config, prefs, formatters, redis_cache, http_pool, tokens
import views/[general, about] import views/[general, about]
import routes/[ import routes/[
preferences, timeline, status, media, search, rss, list, debug, preferences, timeline, status, media, search, rss, list, debug,
@@ -15,13 +15,8 @@ import routes/[
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues" const issuesUrl = "https://github.com/zedeus/nitter/issues"
let let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") let (cfg, fullCfg) = getConfig(configPath)
(cfg, fullCfg) = getConfig(configPath)
sessionsPath = getEnv("NITTER_SESSIONS_FILE", "./sessions.jsonl")
initSessionPool(cfg, sessionsPath)
if not cfg.enableDebug: if not cfg.enableDebug:
# Silence Jester's query warning # Silence Jester's query warning
@@ -43,6 +38,8 @@ waitFor initRedisPool(cfg)
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
stdout.flushFile stdout.flushFile
asyncCheck initTokenPool(cfg)
createUnsupportedRouter(cfg) createUnsupportedRouter(cfg)
createResolverRouter(cfg) createResolverRouter(cfg)
createPrefRouter(cfg) createPrefRouter(cfg)
@@ -90,18 +87,13 @@ routes:
error BadClientError: error BadClientError:
echo error.exc.name, ": ", error.exc.msg echo error.exc.name, ": ", error.exc.msg
resp Http500, showError("Network error occurred, please try again.", cfg) resp Http500, showError("Network error occured, please try again.", cfg)
error RateLimitError: error RateLimitError:
const link = a("another instance", href = instancesUrl) const link = a("another instance", href = instancesUrl)
resp Http429, showError( resp Http429, showError(
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg) &"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
error NoSessionsError:
const link = a("another instance", href = instancesUrl)
resp Http429, showError(
&"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg)
extend rss, "" extend rss, ""
extend status, "" extend status, ""
extend search, "" extend search, ""

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 import strutils, options, tables, times, math
import packedjson, packedjson/deserialiser import packedjson, packedjson/deserialiser
import types, parserutils, utils import types, parserutils, utils
import experimental/parser/unifiedcard import experimental/parser/unifiedcard
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet proc parseGraphTweet(js: JsonNode): Tweet
proc parseUser(js: JsonNode; id=""): User = proc parseUser(js: JsonNode; id=""): User =
if js.isNull: return if js.isNull: return
@@ -21,7 +21,7 @@ proc parseUser(js: JsonNode; id=""): User =
tweets: js{"statuses_count"}.getInt, tweets: js{"statuses_count"}.getInt,
likes: js{"favourites_count"}.getInt, likes: js{"favourites_count"}.getInt,
media: js{"media_count"}.getInt, media: js{"media_count"}.getInt,
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")), verified: js{"verified"}.getBool or js{"ext_is_blue_verified"}.getBool,
protected: js{"protected"}.getBool, protected: js{"protected"}.getBool,
joinDate: js{"created_at"}.getTime joinDate: js{"created_at"}.getTime
) )
@@ -29,13 +29,11 @@ proc parseUser(js: JsonNode; id=""): User =
result.expandUserEntities(js) result.expandUserEntities(js)
proc parseGraphUser(js: JsonNode): User = proc parseGraphUser(js: JsonNode): User =
var user = js{"user_result", "result"} let user = ? js{"user_results", "result"}
if user.isNull: result = parseUser(user{"legacy"})
user = ? js{"user_results", "result"}
result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false): if "is_blue_verified" in user:
result.verifiedType = blue result.verified = true
proc parseGraphList*(js: JsonNode): List = proc parseGraphList*(js: JsonNode): List =
if js.isNull: return if js.isNull: return
@@ -83,17 +81,13 @@ proc parseGif(js: JsonNode): Gif =
proc parseVideo(js: JsonNode): Video = proc parseVideo(js: JsonNode): Video =
result = Video( result = Video(
thumb: js{"media_url_https"}.getImageStr, thumb: js{"media_url_https"}.getImageStr,
views: getVideoViewCount(js), views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt),
available: true, available: js{"ext_media_availability", "status"}.getStr.toLowerAscii == "available",
title: js{"ext_alt_text"}.getStr, title: js{"ext_alt_text"}.getStr,
durationMs: js{"video_info", "duration_millis"}.getInt durationMs: js{"video_info", "duration_millis"}.getInt,
# playbackType: mp4 playbackType: m3u8
) )
with status, js{"ext_media_availability", "status"}:
if status.getStr.len > 0 and status.getStr.toLowerAscii != "available":
result.available = false
with title, js{"additional_media_info", "title"}: with title, js{"additional_media_info", "title"}:
result.title = title.getStr result.title = title.getStr
@@ -105,6 +99,9 @@ proc parseVideo(js: JsonNode): Video =
contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary")) contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary"))
url = v{"url"}.getStr url = v{"url"}.getStr
if contentType == mp4:
result.playbackType = mp4
result.variants.add VideoVariant( result.variants.add VideoVariant(
contentType: contentType, contentType: contentType,
bitrate: v{"bitrate"}.getInt, bitrate: v{"bitrate"}.getInt,
@@ -219,13 +216,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
) )
) )
result.expandTweetEntities(js)
# fix for pinned threads # fix for pinned threads
if result.hasThread and result.threadId == 0: if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId result.threadId = js{"self_thread", "id_str"}.getId
if "retweeted_status" in js: if js{"is_quote_status"}.getBool:
result.retweet = some Tweet()
elif js{"is_quote_status"}.getBool:
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId) result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
# legacy # legacy
@@ -252,8 +249,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
else: else:
result.card = some parseCard(jsCard, js{"entities", "urls"}) result.card = some parseCard(jsCard, js{"entities", "urls"})
result.expandTweetEntities(js)
with jsMedia, js{"extended_entities", "media"}: with jsMedia, js{"extended_entities", "media"}:
for m in jsMedia: for m in jsMedia:
case m{"type"}.getStr case m{"type"}.getStr
@@ -270,11 +265,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.gif = some(parseGif(m)) result.gif = some(parseGif(m))
else: discard else: discard
with url, m{"url"}:
if result.text.endsWith(url.getStr):
result.text.removeSuffix(url.getStr)
result.text = result.text.strip()
with jsWithheld, js{"withheld_in_countries"}: with jsWithheld, js{"withheld_in_countries"}:
let withheldInCountries: seq[string] = let withheldInCountries: seq[string] =
if jsWithheld.kind != JArray: @[] if jsWithheld.kind != JArray: @[]
@@ -289,7 +279,110 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.text.removeSuffix(" Learn more.") result.text.removeSuffix(" Learn more.")
result.available = false result.available = false
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet = 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[T](res: var Result[T]; global: GlobalObjects; js: JsonNode) =
if js.kind != JArray or js.len == 0:
return
for i in js:
when T is Tweet:
if res.beginning and i{"pinEntry"}.notNull:
with pin, parsePin(i, global):
res.content.add pin
with r, i{"replaceEntry", "entry"}:
if "top" in r{"entryId"}.getStr:
res.top = r.getCursor
elif "bottom" in r{"entryId"}.getStr:
res.bottom = r.getCursor
proc parseTimeline*(js: JsonNode; after=""): Timeline =
result = 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.content.add tweet
elif "cursor-top" in entry:
result.top = e.getCursor
elif "cursor-bottom" in entry:
result.bottom = e.getCursor
elif entry.startsWith("sq-cursor"):
with cursor, e{"content", "operation", "cursor"}:
if cursor{"cursorType"}.getStr == "Bottom":
result.bottom = cursor{"value"}.getStr
else:
result.top = cursor{"value"}.getStr
proc parsePhotoRail*(js: JsonNode): PhotoRail =
for tweet in js:
let
t = parseTweet(tweet, js{"card"})
url = if t.photos.len > 0: t.photos[0]
elif t.video.isSome: get(t.video).thumb
elif t.gif.isSome: get(t.gif).thumb
elif t.card.isSome: get(t.card).image
else: ""
if url.len == 0: continue
result.add GalleryPhoto(url: url, tweetId: $t.id)
proc parseGraphTweet(js: JsonNode): Tweet =
if js.kind == JNull: if js.kind == JNull:
return Tweet() return Tweet()
@@ -297,22 +390,13 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
of "TweetUnavailable": of "TweetUnavailable":
return Tweet() return Tweet()
of "TweetTombstone": of "TweetTombstone":
with text, js{"tombstone", "richText"}: return Tweet(text: js{"tombstone", "text"}.getTombstone)
return Tweet(text: text.getTombstone)
with text, js{"tombstone", "text"}:
return Tweet(text: text.getTombstone)
return Tweet()
of "TweetPreviewDisplay": of "TweetPreviewDisplay":
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.") return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
of "TweetWithVisibilityResults": of "TweetWithVisibilityResults":
return parseGraphTweet(js{"tweet"}, isLegacy) return parseGraphTweet(js{"tweet"})
else:
discard
if not js.hasKey("legacy"): var jsCard = copy(js{"card", "legacy"})
return Tweet()
var jsCard = copy(js{if isLegacy: "card" else: "tweet_card", "legacy"})
if jsCard.kind != JNull: if jsCard.kind != JNull:
var values = newJObject() var values = newJObject()
for val in jsCard["binding_values"]: for val in jsCard["binding_values"]:
@@ -320,152 +404,100 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
jsCard["binding_values"] = values jsCard["binding_values"] = values
result = parseTweet(js{"legacy"}, jsCard) result = parseTweet(js{"legacy"}, jsCard)
result.id = js{"rest_id"}.getId
result.user = parseGraphUser(js{"core"}) result.user = parseGraphUser(js{"core"})
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}: with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
result.expandNoteTweetEntities(noteTweet) result.expandNoteTweetEntities(noteTweet)
if result.quote.isSome: if result.quote.isSome:
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy)) result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
let thread = js{"content", "items"}
for t in js{"content", "items"}: for t in js{"content", "items"}:
let entryId = t{"entryId"}.getStr let entryId = t{"entryId"}.getStr
if "cursor-showmore" in entryId: if "cursor-showmore" in entryId:
let cursor = t{"item", "content", "value"} let cursor = t{"item", "itemContent", "value"}
result.thread.cursor = cursor.getStr result.thread.cursor = cursor.getStr
result.thread.hasMore = true result.thread.hasMore = true
elif "tweet" in entryId and "promoted" notin entryId: elif "tweet" in entryId:
let let tweet = parseGraphTweet(t{"item", "itemContent", "tweet_results", "result"})
isLegacy = t{"item"}.hasKey("itemContent") result.thread.content.add tweet
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
else: ("content", "tweetResult")
with content, t{"item", contentKey}: if t{"item", "itemContent", "tweetDisplayType"}.getStr == "SelfThread":
result.thread.content.add parseGraphTweet(content{resultKey, "result"}, isLegacy) result.self = true
if content{"tweetDisplayType"}.getStr == "SelfThread":
result.self = true
proc parseGraphTweetResult*(js: JsonNode): Tweet = proc parseGraphTweetResult*(js: JsonNode): Tweet =
with tweet, js{"data", "tweet_result", "result"}: with tweet, js{"data", "tweetResult", "result"}:
result = parseGraphTweet(tweet, false) result = parseGraphTweet(tweet)
proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversation = proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result = Conversation(replies: Result[Chain](beginning: true)) result = Conversation(replies: Result[Chain](beginning: true))
let let instructions = ? js{"data", "threaded_conversation_with_injections", "instructions"}
rootKey = if v2: "timeline_response" else: "threaded_conversation_with_injections_v2"
contentKey = if v2: "content" else: "itemContent"
resultKey = if v2: "tweetResult" else: "tweet_results"
let instructions = ? js{"data", rootKey, "instructions"}
if instructions.len == 0: if instructions.len == 0:
return return
for i in instructions: for e in instructions[0]{"entries"}:
if i{"__typename"}.getStr == "TimelineAddEntries": let entryId = e{"entryId"}.getStr
for e in i{"entries"}: # echo entryId
let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet"):
if entryId.startsWith("tweet"): with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
with tweetResult, e{"content", contentKey, resultKey, "result"}: let tweet = parseGraphTweet(tweetResult)
let tweet = parseGraphTweet(tweetResult, not v2)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId()) tweet.id = parseBiggestInt(entryId.getId())
if $tweet.id == tweetId: if $tweet.id == tweetId:
result.tweet = tweet result.tweet = tweet
else: else:
result.before.content.add tweet result.before.content.add tweet
elif entryId.startsWith("conversationthread"): elif entryId.startsWith("tombstone"):
let (thread, self) = parseGraphThread(e) let id = entryId.getId()
if self: let tweet = Tweet(
result.after = thread id: parseBiggestInt(id),
elif thread.content.len > 0: available: false,
result.replies.content.add thread text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
elif entryId.startsWith("tombstone"): )
let id = entryId.getId()
let tweet = Tweet(
id: parseBiggestInt(id),
available: false,
text: e{"content", contentKey, "tombstoneInfo", "richText"}.getTombstone
)
if id == tweetId: if id == tweetId:
result.tweet = tweet result.tweet = tweet
else: else:
result.before.content.add tweet result.before.content.add tweet
elif entryId.startsWith("cursor-bottom"): elif entryId.startsWith("conversationthread"):
result.replies.bottom = e{"content", contentKey, "value"}.getStr let (thread, self) = parseGraphThread(e)
if self:
result.after = thread
else:
result.replies.content.add thread
elif entryId.startsWith("cursor-bottom"):
result.replies.bottom = e{"content", "itemContent", "value"}.getStr
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline =
result = Profile(tweets: Timeline(beginning: after.len == 0)) result = Timeline(beginning: after.len == 0)
let instructions = let instructions =
if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"} if root == "list": ? js{"data", "list", "tweets_timeline", "timeline", "instructions"}
else: ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} else: ? js{"data", "user", "result", "timeline_v2", "timeline", "instructions"}
if instructions.len == 0: if instructions.len == 0:
return return
for i in instructions: for i in instructions:
if i{"__typename"}.getStr == "TimelineAddEntries": if i{"type"}.getStr == "TimelineAddEntries":
for e in i{"entries"}: for e in i{"entries"}:
let entryId = e{"entryId"}.getStr let entryId = e{"entryId"}.getStr
if entryId.startsWith("tweet"): if entryId.startsWith("tweet"):
with tweetResult, e{"content", "content", "tweetResult", "result"}: with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
let tweet = parseGraphTweet(tweetResult, false) let tweet = parseGraphTweet(tweetResult)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId()) tweet.id = parseBiggestInt(entryId.getId())
result.tweets.content.add tweet result.content.add tweet
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
let (thread, self) = parseGraphThread(e)
result.tweets.content.add thread.content
elif entryId.startsWith("cursor-bottom"): elif entryId.startsWith("cursor-bottom"):
result.tweets.bottom = e{"content", "value"}.getStr result.bottom = e{"content", "value"}.getStr
if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry":
with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}:
let tweet = parseGraphTweet(tweetResult, false)
tweet.pinned = true
if not tweet.available and tweet.tombstone.len == 0:
let entryId = i{"entry", "entryId"}.getEntryId
if entryId.len > 0:
tweet.id = parseBiggestInt(entryId)
result.pinned = some tweet
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = proc parseGraphSearch*(js: JsonNode; after=""): Timeline =
result = @[] result = Timeline(beginning: after.len == 0)
let instructions =
? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
for i in instructions:
if i{"__typename"}.getStr == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e{"entryId"}.getStr
if entryId.startsWith("tweet"):
with tweetResult, e{"content", "content", "tweetResult", "result"}:
let t = parseGraphTweet(tweetResult, false)
if not t.available:
t.id = parseBiggestInt(entryId.getId())
let url =
if t.photos.len > 0: t.photos[0]
elif t.video.isSome: get(t.video).thumb
elif t.gif.isSome: get(t.gif).thumb
elif t.card.isSome: get(t.card).image
else: ""
if url.len > 0:
result.add GalleryPhoto(url: url, tweetId: $t.id)
if result.len == 16:
break
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
result = Result[T](beginning: after.len == 0)
let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"} let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
if instructions.len == 0: if instructions.len == 0:
@@ -474,21 +506,15 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
for instruction in instructions: for instruction in instructions:
let typ = instruction{"type"}.getStr let typ = instruction{"type"}.getStr
if typ == "TimelineAddEntries": if typ == "TimelineAddEntries":
for e in instruction{"entries"}: for e in instructions[0]{"entries"}:
let entryId = e{"entryId"}.getStr let entryId = e{"entryId"}.getStr
when T is Tweets: if entryId.startsWith("tweet"):
if entryId.startsWith("tweet"): with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
with tweetRes, e{"content", "itemContent", "tweet_results", "result"}: let tweet = parseGraphTweet(tweetResult)
let tweet = parseGraphTweet(tweetRes) if not tweet.available:
if not tweet.available: tweet.id = parseBiggestInt(entryId.getId())
tweet.id = parseBiggestInt(entryId.getId()) result.content.add tweet
result.content.add tweet elif entryId.startsWith("cursor-bottom"):
elif T is User:
if entryId.startsWith("user"):
with userRes, e{"content", "itemContent"}:
result.content.add parseGraphUser(userRes)
if entryId.startsWith("cursor-bottom"):
result.bottom = e{"content", "value"}.getStr result.bottom = e{"content", "value"}.getStr
elif typ == "TimelineReplaceEntry": elif typ == "TimelineReplaceEntry":
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):

View File

@@ -1,17 +1,9 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import std/[times, macros, htmlgen, options, algorithm, re] import std/[strutils, times, macros, htmlgen, options, algorithm, re]
import std/strutils except escape
import std/unicode except strip import std/unicode except strip
from xmltree import escape
import packedjson import packedjson
import types, utils, formatters import types, utils, formatters
const
unicodeOpen = "\uFFFA"
unicodeClose = "\uFFFB"
xmlOpen = escape("<")
xmlClose = escape(">")
let let
unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})" unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
unReplace = "$1<a href=\"/$2\">@$2</a>" unReplace = "$1<a href=\"/$2\">@$2</a>"
@@ -44,8 +36,7 @@ template with*(ident, value, body): untyped =
template with*(ident; value: JsonNode; body): untyped = template with*(ident; value: JsonNode; body): untyped =
if true: if true:
let ident {.inject.} = value let ident {.inject.} = value
# value.notNull causes a compilation error for versions < 1.6.14 if value.notNull: body
if notNull(value): body
template getCursor*(js: JsonNode): string = template getCursor*(js: JsonNode): string =
js{"content", "operation", "cursor", "value"}.getStr js{"content", "operation", "cursor", "value"}.getStr
@@ -157,12 +148,6 @@ proc getMp4Resolution*(url: string): int =
# cannot determine resolution (e.g. m3u8/non-mp4 video) # cannot determine resolution (e.g. m3u8/non-mp4 video)
return 0 return 0
proc getVideoViewCount*(js: JsonNode): string =
with stats, js{"ext_media_stats"}:
return stats{"view_count"}.getStr($stats{"viewCount"}.getInt)
return $js{"mediaStats", "viewCount"}.getInt(0)
proc extractSlice(js: JsonNode): Slice[int] = proc extractSlice(js: JsonNode): Slice[int] =
result = js["indices"][0].getInt ..< js["indices"][1].getInt result = js["indices"][0].getInt ..< js["indices"][1].getInt
@@ -246,7 +231,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
.replacef(htRegex, htReplace) .replacef(htRegex, htReplace)
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int]; proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
replyTo=""; hasRedundantLink=false) = replyTo=""; hasQuote=false) =
let hasCard = tweet.card.isSome let hasCard = tweet.card.isSome
var replacements = newSeq[ReplaceSlice]() var replacements = newSeq[ReplaceSlice]()
@@ -257,7 +242,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
if urlStr.len == 0 or urlStr notin text: if urlStr.len == 0 or urlStr notin text:
continue continue
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) replacements.extractUrls(u, textSlice.b, hideTwitter = hasQuote)
if hasCard and u{"url"}.getStr == get(tweet.card).url: if hasCard and u{"url"}.getStr == get(tweet.card).url:
get(tweet.card).url = u{"expanded_url"}.getStr get(tweet.card).url = u{"expanded_url"}.getStr
@@ -297,10 +282,9 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
let let
entities = ? js{"entities"} entities = ? js{"entities"}
hasQuote = js{"is_quote_status"}.getBool
textRange = js{"display_text_range"} textRange = js{"display_text_range"}
textSlice = textRange{0}.getInt .. textRange{1}.getInt textSlice = textRange{0}.getInt .. textRange{1}.getInt
hasQuote = js{"is_quote_status"}.getBool
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
var replyTo = "" var replyTo = ""
if tweet.replyId != 0: if tweet.replyId != 0:
@@ -308,14 +292,12 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
replyTo = reply.getStr replyTo = reply.getStr
tweet.reply.add replyTo tweet.reply.add replyTo
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote)
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
let let
entities = ? js{"entity_set"} entities = ? js{"entity_set"}
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose)) text = js{"text"}.getStr
textSlice = 0..text.runeLen textSlice = 0..text.runeLen
tweet.expandTextEntities(entities, text, textSlice) tweet.expandTextEntities(entities, text, textSlice)
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))

View File

@@ -80,7 +80,7 @@ genPrefs:
Media: Media:
mp4Playback(checkbox, true): mp4Playback(checkbox, true):
"Enable mp4 video playback (only for gifs)" "Enable mp4 video playback"
hlsPlayback(checkbox, false): hlsPlayback(checkbox, false):
"Enable HLS video streaming (requires JavaScript)" "Enable HLS video streaming (requires JavaScript)"

View File

@@ -60,7 +60,7 @@ proc genQueryParam*(query: Query): string =
param &= "OR " param &= "OR "
if query.fromUser.len > 0 and query.kind in {posts, media}: if query.fromUser.len > 0 and query.kind in {posts, media}:
param &= "filter:self_threads OR -filter:replies " param &= "filter:self_threads OR-filter:replies "
if "nativeretweets" notin query.excludes: if "nativeretweets" notin query.excludes:
param &= "include:nativeretweets " param &= "include:nativeretweets "

View File

@@ -52,7 +52,6 @@ proc initRedisPool*(cfg: Config) {.async.} =
await migrate("profileDates", "p:*") await migrate("profileDates", "p:*")
await migrate("profileStats", "p:*") await migrate("profileStats", "p:*")
await migrate("userType", "p:*") await migrate("userType", "p:*")
await migrate("verifiedType", "p:*")
pool.withAcquire(r): pool.withAcquire(r):
# optimize memory usage for user ID buckets # optimize memory usage for user ID buckets
@@ -86,7 +85,7 @@ proc cache*(data: List) {.async.} =
await setEx(data.listKey, listCacheTime, compress(toFlatty(data))) await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
proc cache*(data: PhotoRail; name: string) {.async.} = proc cache*(data: PhotoRail; name: string) {.async.} =
await setEx("pr2:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data))) await setEx("pr:" & toLower(name), baseCacheTime, compress(toFlatty(data)))
proc cache*(data: User) {.async.} = proc cache*(data: User) {.async.} =
if data.username.len == 0: return if data.username.len == 0: return
@@ -148,24 +147,24 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
if result.len > 0 and user.id.len > 0: if result.len > 0 and user.id.len > 0:
await all(cacheUserId(result, user.id), cache(user)) await all(cacheUserId(result, user.id), cache(user))
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
# if id == 0: return if id == 0: return
# let tweet = await get(id.tweetKey) let tweet = await get(id.tweetKey)
# if tweet != redisNil: if tweet != redisNil:
# tweet.deserialize(Tweet) tweet.deserialize(Tweet)
# else: else:
# result = await getGraphTweetResult($id) result = await getGraphTweetResult($id)
# if not result.isNil: if not result.isNil:
# await cache(result) await cache(result)
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
if id.len == 0: return if name.len == 0: return
let rail = await get("pr2:" & toLower(id)) let rail = await get("pr:" & toLower(name))
if rail != redisNil: if rail != redisNil:
rail.deserialize(PhotoRail) rail.deserialize(PhotoRail)
else: else:
result = await getPhotoRail(id) result = await getPhotoRail(name)
await cache(result, id) await cache(result, name)
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} = proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
let list = if id.len == 0: redisNil let list = if id.len == 0: redisNil

View File

@@ -1,13 +1,10 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import jester import jester
import router_utils import router_utils
import ".."/[auth, types] import ".."/[tokens, types]
proc createDebugRouter*(cfg: Config) = proc createDebugRouter*(cfg: Config) =
router debug: router debug:
get "/.health": get "/.tokens":
respJson getSessionPoolHealth()
get "/.sessions":
cond cfg.enableDebug cond cfg.enableDebug
respJson getSessionPoolDebug() respJson getPoolJson()

View File

@@ -10,22 +10,22 @@ export api, embed, vdom, tweet, general, router_utils
proc createEmbedRouter*(cfg: Config) = proc createEmbedRouter*(cfg: Config) =
router embed: router embed:
get "/i/videos/tweet/@id": get "/i/videos/tweet/@id":
let tweet = await getGraphTweetResult(@"id") let convo = await getTweet(@"id")
if tweet == nil or tweet.video.isNone: if convo == nil or convo.tweet == nil or convo.tweet.video.isNone:
resp Http404 resp Http404
resp renderVideoEmbed(tweet, cfg, request) resp renderVideoEmbed(convo.tweet, cfg, request)
get "/@user/status/@id/embed": get "/@user/status/@id/embed":
let let
tweet = await getGraphTweetResult(@"id") convo = await getTweet(@"id")
prefs = cookiePrefs() prefs = cookiePrefs()
path = getPath() path = getPath()
if tweet == nil: if convo == nil or convo.tweet == nil:
resp Http404 resp Http404
resp renderTweetEmbed(tweet, path, prefs, cfg, request) resp renderTweetEmbed(convo.tweet, path, prefs, cfg, request)
get "/embed/Tweet.html": get "/embed/Tweet.html":
let id = @"id" let id = @"id"

View File

@@ -12,7 +12,8 @@ export httpclient, os, strutils, asyncstreams, base64, re
const const
m3u8Mime* = "application/vnd.apple.mpegurl" m3u8Mime* = "application/vnd.apple.mpegurl"
maxAge* = "max-age=604800" mp4Mime* = "video/mp4"
maxAge* = "public, max-age=604800, must-revalidate"
proc safeFetch*(url: string): Future[string] {.async.} = proc safeFetch*(url: string): Future[string] {.async.} =
let client = newAsyncHttpClient() let client = newAsyncHttpClient()
@@ -20,59 +21,84 @@ proc safeFetch*(url: string): Future[string] {.async.} =
except: discard except: discard
finally: client.close() finally: client.close()
template respond*(req: asynchttpserver.Request; headers) = template respond*(req: asynchttpserver.Request; code: HttpCode;
var msg = "HTTP/1.1 200 OK\c\L" headers: seq[(string, string)]) =
for k, v in headers: var msg = "HTTP/1.1 " & $code & "\c\L"
for (k, v) in headers:
msg.add(k & ": " & v & "\c\L") msg.add(k & ": " & v & "\c\L")
msg.add "\c\L" msg.add "\c\L"
yield req.client.send(msg) yield req.client.send(msg, flags={})
proc getContentLength(res: AsyncResponse): string =
result = "0"
if res.headers.hasKey("content-length"):
result = $res.contentLength
elif res.headers.hasKey("content-range"):
result = res.headers["content-range"]
result = result[result.find('/') + 1 .. ^1]
if result == "*":
result.setLen(0)
proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
result = Http200 result = Http200
let let
request = req.getNativeReq() request = req.getNativeReq()
client = newAsyncHttpClient() hashed = $hash(url)
if request.headers.getOrDefault("If-None-Match") == hashed:
return Http304
let c = newAsyncHttpClient(headers=newHttpHeaders({
"accept": "*/*",
"range": $req.headers.getOrDefault("range")
}))
try: try:
let res = await client.get(url) var res = await c.get(url)
if res.status != "200 OK": if not res.status.startsWith("20"):
if res.status != "404 Not Found":
echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url]
return Http404 return Http404
let hashed = $hash(url) var headers = @{
if request.headers.getOrDefault("If-None-Match") == hashed: "accept-ranges": "bytes",
return Http304 "content-type": $res.headers.getOrDefault("content-type"),
"cache-control": maxAge,
"age": $res.headers.getOrDefault("age"),
"date": $res.headers.getOrDefault("date"),
"last-modified": $res.headers.getOrDefault("last-modified")
}
let contentLength = var tries = 0
if res.headers.hasKey("content-length"): while tries <= 10 and res.headers.hasKey("transfer-encoding"):
res.headers["content-length", 0] await sleepAsync(100 + tries * 200)
else: res = await c.get(url)
"" tries.inc
let headers = newHttpHeaders({ let contentLength = res.getContentLength
"Content-Type": res.headers["content-type", 0], if contentLength.len > 0:
"Content-Length": contentLength, headers.add ("content-length", contentLength)
"Cache-Control": maxAge,
"ETag": hashed
})
respond(request, headers) if res.headers.hasKey("content-range"):
headers.add ("content-range", $res.headers.getOrDefault("content-range"))
respond(request, Http206, headers)
else:
respond(request, Http200, headers)
var (hasValue, data) = (true, "") var (hasValue, data) = (true, "")
while hasValue: while hasValue:
(hasValue, data) = await res.bodyStream.read() (hasValue, data) = await res.bodyStream.read()
if hasValue: if hasValue:
await request.client.send(data) await request.client.send(data, flags={})
data.setLen 0 data.setLen 0
except HttpRequestError, ProtocolError, OSError: except OSError: discard
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] except ProtocolError, HttpRequestError:
result = Http404 result = Http404
finally: finally:
client.close() c.close()
template check*(code): untyped = template check*(c): untyped =
let code = c
if code != Http200: if code != Http200:
resp code resp code
else: else:
@@ -86,48 +112,37 @@ proc decoded*(req: jester.Request; index: int): string =
if based: decode(encoded) if based: decode(encoded)
else: decodeUrl(encoded) else: decodeUrl(encoded)
proc getPicUrl*(req: jester.Request): string =
result = decoded(req, 1)
if "twimg.com" notin result:
result.insert(twimg)
if not result.startsWith(https):
result.insert(https)
proc createMediaRouter*(cfg: Config) = proc createMediaRouter*(cfg: Config) =
router media: router media:
get "/pic/?": get "/pic/?":
resp Http404 resp Http404
get re"^\/pic\/orig\/(enc)?\/?(.+)": get re"^\/pic\/orig\/(enc)?\/?(.+)":
var url = decoded(request, 1) let url = getPicUrl(request)
if "twimg.com" notin url: cond isTwitterUrl(parseUri(url)) == true
url.insert(twimg) check await proxyMedia(request, url & "?name=orig")
if not url.startsWith(https):
url.insert(https)
url.add("?name=orig")
let uri = parseUri(url)
cond isTwitterUrl(uri) == true
let code = await proxyMedia(request, url)
check code
get re"^\/pic\/(enc)?\/?(.+)": get re"^\/pic\/(enc)?\/?(.+)":
var url = decoded(request, 1) let url = getPicUrl(request)
if "twimg.com" notin url: cond isTwitterUrl(parseUri(url)) == true
url.insert(twimg) check await proxyMedia(request, url)
if not url.startsWith(https):
url.insert(https)
let uri = parseUri(url)
cond isTwitterUrl(uri) == true
let code = await proxyMedia(request, url)
check code
get re"^\/video\/(enc)?\/?(.+)\/(.+)$": get re"^\/video\/(enc)?\/?(.+)\/(.+)$":
let url = decoded(request, 2) let url = decoded(request, 2)
cond "http" in url cond "http" in url
if getHmac(url) != request.matches[1]: if getHmac(url) != request.matches[1]:
resp Http403, showError("Failed to verify signature", cfg) resp showError("Failed to verify signature", cfg)
if ".mp4" in url or ".ts" in url or ".m4s" in url: if ".mp4" in url or ".ts" in url or ".m4s" in url:
let code = await proxyMedia(request, url) check await proxyMedia(request, url)
check code
var content: string var content: string
if ".vmap" in url: if ".vmap" in url:

View File

@@ -23,16 +23,18 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
names = getNames(name) names = getNames(name)
if names.len == 1: if names.len == 1:
profile = await fetchProfile(after, query, skipRail=true) profile = await fetchProfile(after, query, skipRail=true, skipPinned=true)
else: else:
var q = query var q = query
q.fromUser = names q.fromUser = names
profile.tweets = await getGraphTweetSearch(q, after) profile = Profile(
# this is kinda dumb tweets: await getGraphSearch(q, after),
profile.user = User( # this is kinda dumb
username: name, user: User(
fullname: names.join(" | "), username: name,
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png" fullname: names.join(" | "),
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
)
) )
if profile.user.suspended: if profile.user.suspended:
@@ -76,7 +78,7 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0: if rss.cursor.len > 0:
respRss(rss, "Search") respRss(rss, "Search")
let tweets = await getGraphTweetSearch(query, cursor) let tweets = await getGraphSearch(query, cursor)
rss.cursor = tweets.bottom rss.cursor = tweets.bottom
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)

View File

@@ -29,13 +29,13 @@ proc createSearchRouter*(cfg: Config) =
redirect("/" & q) redirect("/" & q)
var users: Result[User] var users: Result[User]
try: try:
users = await getGraphUserSearch(query, getCursor()) users = await getUserSearch(query, getCursor())
except InternalError: except InternalError:
users = Result[User](beginning: true, query: query) users = Result[User](beginning: true, query: query)
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
of tweets: of tweets:
let let
tweets = await getGraphTweetSearch(query, getCursor()) tweets = await getGraphSearch(query, getCursor())
rss = "/search/rss?" & genQueryUrl(query) rss = "/search/rss?" & genQueryUrl(query)
resp renderMain(renderTweetSearch(tweets, prefs, getPath()), resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
request, cfg, prefs, title, rss=rss) request, cfg, prefs, title, rss=rss)

View File

@@ -27,7 +27,8 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
else: else:
body body
proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] {.async.} = proc fetchProfile*(after: string; query: Query; skipRail=false;
skipPinned=false): Future[Profile] {.async.} =
let let
name = query.fromUser[0] name = query.fromUser[0]
userId = await getUserId(name) userId = await getUserId(name)
@@ -44,21 +45,37 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
after.setLen 0 after.setLen 0
let let
timeline =
case query.kind
of posts: getGraphUserTweets(userId, TimelineKind.tweets, after)
of replies: getGraphUserTweets(userId, TimelineKind.replies, after)
of media: getGraphUserTweets(userId, TimelineKind.media, after)
else: getGraphSearch(query, after)
rail = rail =
skipIf(skipRail or query.kind == media, @[]): skipIf(skipRail or query.kind == media, @[]):
getCachedPhotoRail(userId) getCachedPhotoRail(name)
user = getCachedUser(name) user = await getCachedUser(name)
result = var pinned: Option[Tweet]
case query.kind if not skipPinned and user.pinnedTweet > 0 and
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) after.len == 0 and query.kind in {posts, replies}:
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) let tweet = await getCachedTweet(user.pinnedTweet)
of media: await getGraphUserTweets(userId, TimelineKind.media, after) if not tweet.isNil:
else: Profile(tweets: await getGraphTweetSearch(query, after)) tweet.pinned = true
tweet.user = user
pinned = some tweet
result.user = await user result = Profile(
result.photoRail = await rail user: user,
pinned: pinned,
tweets: await timeline,
photoRail: await rail
)
if result.user.protected or result.user.suspended:
return
result.tweets.query = query result.tweets.query = query
@@ -66,11 +83,11 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
rss, after: string): Future[string] {.async.} = rss, after: string): Future[string] {.async.} =
if query.fromUser.len != 1: if query.fromUser.len != 1:
let let
timeline = await getGraphTweetSearch(query, after) timeline = await getGraphSearch(query, after)
html = renderTweetSearch(timeline, prefs, getPath()) html = renderTweetSearch(timeline, prefs, getPath())
return renderMain(html, request, cfg, prefs, "Multi", rss=rss) return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
var profile = await fetchProfile(after, query) var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins)
template u: untyped = profile.user template u: untyped = profile.user
if u.suspended: if u.suspended:
@@ -121,7 +138,7 @@ proc createTimelineRouter*(cfg: Config) =
# used for the infinite scroll feature # used for the infinite scroll feature
if @"scroll".len > 0: if @"scroll".len > 0:
if query.fromUser.len != 1: if query.fromUser.len != 1:
var timeline = await getGraphTweetSearch(query, after) var timeline = await getGraphSearch(query, after)
if timeline.content.len == 0: resp Http404 if timeline.content.len == 0: resp Http404
timeline.beginning = true timeline.beginning = true
resp $renderTweetSearch(timeline, prefs, getPath()) resp $renderTweetSearch(timeline, prefs, getPath())

View File

@@ -28,8 +28,6 @@ $more_replies_dots: #AD433B;
$error_red: #420A05; $error_red: #420A05;
$verified_blue: #1DA1F2; $verified_blue: #1DA1F2;
$verified_business: #FAC82B;
$verified_government: #C1B6A4;
$icon_text: $fg_color; $icon_text: $fg_color;
$tab: $fg_color; $tab: $fg_color;
@@ -39,5 +37,8 @@ $shadow: rgba(0,0,0,.6);
$shadow_dark: rgba(0,0,0,.2); $shadow_dark: rgba(0,0,0,.2);
//fonts //fonts
$font_stack: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; $font_0: Helvetica Neue;
$font_icon: fontello; $font_1: Helvetica;
$font_2: Arial;
$font_3: sans-serif;
$font_4: fontello;

View File

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

View File

@@ -136,7 +136,7 @@ input::-webkit-datetime-edit-year-field:focus {
left: 2px; left: 2px;
bottom: 0; bottom: 0;
font-size: 13px; font-size: 13px;
font-family: $font_icon; font-family: $font_4;
content: '\e803'; content: '\e803';
} }
} }

View File

@@ -70,9 +70,8 @@ nav {
.lp { .lp {
height: 14px; height: 14px;
display: inline-block; margin-top: 2px;
position: relative; display: block;
top: 2px;
fill: var(--fg_nav); fill: var(--fg_nav);
&:hover { &:hover {

View File

@@ -115,7 +115,7 @@
} }
.profile-card-tabs-name { .profile-card-tabs-name {
flex-shrink: 100; @include breakable;
} }
.profile-card-avatar { .profile-card-avatar {

View File

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

View File

@@ -17,7 +17,7 @@
} }
.tweet-content { .tweet-content {
font-family: $font_stack; font-family: $font_3;
line-height: 1.3em; line-height: 1.3em;
pointer-events: all; pointer-events: all;
display: inline; display: inline;
@@ -201,10 +201,6 @@
.tweet-stats { .tweet-stats {
margin-bottom: -3px; margin-bottom: -3px;
-webkit-user-select: none; -webkit-user-select: none;
a {
pointer-events: all;
}
} }
.tweet-stat { .tweet-stat {

View File

@@ -110,29 +110,3 @@
margin-left: 58px; margin-left: 58px;
padding: 7px 0; padding: 7px 0;
} }
.timeline-item.thread.more-replies-thread {
padding: 0 0.75em;
&::before {
top: 40px;
margin-bottom: 31px;
}
.more-replies {
display: flex;
padding-top: unset !important;
margin-top: 8px;
&::before {
display: inline-block;
position: relative;
top: -1px;
line-height: 0.4em;
}
.more-replies-text {
display: inline;
}
}
}

View File

@@ -16,8 +16,6 @@ video {
} }
.video-container { .video-container {
min-height: 80px;
min-width: 200px;
max-height: 530px; max-height: 530px;
margin: 0; margin: 0;
display: flex; display: flex;

164
src/tokens.nim Normal file
View File

@@ -0,0 +1,164 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, times, sequtils, json, random
import strutils, tables
import types, consts
const
maxConcurrentReqs = 5 # max requests at a time per token, to avoid race conditions
maxLastUse = 1.hours # if a token is unused for 60 minutes, it expires
maxAge = 2.hours + 55.minutes # tokens expire after 3 hours
failDelay = initDuration(minutes=30)
var
tokenPool: seq[Token]
lastFailed: Time
enableLogging = false
let headers = newHttpHeaders({"authorization": auth})
template log(str) =
if enableLogging: echo "[tokens] ", str
proc getPoolJson*(): JsonNode =
var
list = newJObject()
totalReqs = 0
totalPending = 0
reqsPerApi: Table[string, int]
for token in tokenPool:
totalPending.inc(token.pending)
list[token.tok] = %*{
"apis": newJObject(),
"pending": token.pending,
"init": $token.init,
"lastUse": $token.lastUse
}
for api in token.apis.keys:
list[token.tok]["apis"][$api] = %token.apis[api]
let
maxReqs =
case api
of Api.timeline: 187
of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets,
Api.userTweets, Api.userTweetsAndReplies, Api.userMedia,
Api.userRestId, Api.userScreenName,
Api.tweetDetail, Api.tweetResult, Api.search: 500
of Api.userSearch: 900
reqs = maxReqs - token.apis[api].remaining
reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs
totalReqs.inc(reqs)
return %*{
"amount": tokenPool.len,
"requests": totalReqs,
"pending": totalPending,
"apis": reqsPerApi,
"tokens": list
}
proc rateLimitError*(): ref RateLimitError =
newException(RateLimitError, "rate limited")
proc fetchToken(): Future[Token] {.async.} =
if getTime() - lastFailed < failDelay:
raise rateLimitError()
let client = newAsyncHttpClient(headers=headers)
try:
let
resp = await client.postContent(activate)
tokNode = parseJson(resp)["guest_token"]
tok = tokNode.getStr($(tokNode.getInt))
time = getTime()
return Token(tok: tok, init: time, lastUse: time)
except Exception as e:
echo "[tokens] fetching token failed: ", e.msg
if "Try again" notin e.msg:
echo "[tokens] fetching tokens paused, resuming in 30 minutes"
lastFailed = getTime()
finally:
client.close()
proc expired(token: Token): bool =
let time = getTime()
token.init < time - maxAge or token.lastUse < time - maxLastUse
proc isLimited(token: Token; api: Api): bool =
if token.isNil or token.expired:
return true
if api in token.apis:
let limit = token.apis[api]
return (limit.remaining <= 10 and limit.reset > epochTime().int)
else:
return false
proc isReady(token: Token; api: Api): bool =
not (token.isNil or token.pending > maxConcurrentReqs or token.isLimited(api))
proc release*(token: Token; used=false; invalid=false) =
if token.isNil: return
if invalid or token.expired:
if invalid: log "discarding invalid token"
elif token.expired: log "discarding expired token"
let idx = tokenPool.find(token)
if idx > -1: tokenPool.delete(idx)
elif used:
dec token.pending
token.lastUse = getTime()
proc getToken*(api: Api): Future[Token] {.async.} =
for i in 0 ..< tokenPool.len:
if result.isReady(api): break
release(result)
result = tokenPool.sample()
if not result.isReady(api):
release(result)
result = await fetchToken()
log "added new token to pool"
tokenPool.add result
if not result.isNil:
inc result.pending
else:
raise rateLimitError()
proc setRateLimit*(token: Token; api: Api; remaining, reset: int) =
# avoid undefined behavior in race conditions
if api in token.apis:
let limit = token.apis[api]
if limit.reset >= reset and limit.remaining < remaining:
return
token.apis[api] = RateLimit(remaining: remaining, reset: reset)
proc poolTokens*(amount: int) {.async.} =
var futs: seq[Future[Token]]
for i in 0 ..< amount:
futs.add fetchToken()
for token in futs:
var newToken: Token
try: newToken = await token
except: discard
if not newToken.isNil:
log "added new token to pool"
tokenPool.add newToken
proc initTokenPool*(cfg: Config) {.async.} =
enableLogging = cfg.enableDebug
while true:
if tokenPool.countIt(not it.isLimited(Api.timeline)) < cfg.minTokens:
await poolTokens(min(4, cfg.minTokens - tokenPool.len))
await sleepAsync(2000)

View File

@@ -6,17 +6,20 @@ genPrefsType()
type type
RateLimitError* = object of CatchableError RateLimitError* = object of CatchableError
NoSessionsError* = object of CatchableError
InternalError* = object of CatchableError InternalError* = object of CatchableError
BadClientError* = object of CatchableError BadClientError* = object of CatchableError
TimelineKind* {.pure.} = enum TimelineKind* {.pure.} = enum
tweets, replies, media tweets
replies
media
Api* {.pure.} = enum Api* {.pure.} = enum
tweetDetail tweetDetail
tweetResult tweetResult
timeline
search search
userSearch
list list
listBySlug listBySlug
listMembers listMembers
@@ -28,59 +31,37 @@ type
userMedia userMedia
RateLimit* = object RateLimit* = object
limit*: int
remaining*: int remaining*: int
reset*: int reset*: int
SessionKind* = enum Token* = ref object
oauth tok*: string
cookie init*: Time
lastUse*: Time
Session* = ref object
id*: int64
pending*: int pending*: int
limited*: bool
limitedAt*: int
apis*: Table[Api, RateLimit] apis*: Table[Api, RateLimit]
case kind*: SessionKind
of oauth:
oauthToken*: string
oauthSecret*: string
of cookie:
authToken*: string
ct0*: string
Error* = enum Error* = enum
null = 0 null = 0
noUserMatches = 17 noUserMatches = 17
protectedUser = 22 protectedUser = 22
missingParams = 25 missingParams = 25
timeout = 29
couldntAuth = 32 couldntAuth = 32
doesntExist = 34 doesntExist = 34
unauthorized = 37
invalidParam = 47 invalidParam = 47
userNotFound = 50 userNotFound = 50
suspended = 63 suspended = 63
rateLimited = 88 rateLimited = 88
expiredToken = 89 invalidToken = 89
listIdOrSlug = 112 listIdOrSlug = 112
tweetNotFound = 144 tweetNotFound = 144
tweetNotAuthorized = 179 tweetNotAuthorized = 179
forbidden = 200 forbidden = 200
badRequest = 214
badToken = 239 badToken = 239
locked = 326
noCsrf = 353 noCsrf = 353
tweetUnavailable = 421 tweetUnavailable = 421
tweetCensored = 422 tweetCensored = 422
VerifiedType* = enum
none = "None"
blue = "Blue"
business = "Business"
government = "Government"
User* = object User* = object
id*: string id*: string
username*: string username*: string
@@ -96,7 +77,7 @@ type
tweets*: int tweets*: int
likes*: int likes*: int
media*: int media*: int
verifiedType*: VerifiedType verified*: bool
protected*: bool protected*: bool
suspended*: bool suspended*: bool
joinDate*: DateTime joinDate*: DateTime
@@ -180,10 +161,9 @@ type
imageDirectMessage = "image_direct_message" imageDirectMessage = "image_direct_message"
audiospace = "audiospace" audiospace = "audiospace"
newsletterPublication = "newsletter_publication" newsletterPublication = "newsletter_publication"
jobDetails = "job_details"
hidden hidden
unknown unknown
Card* = object Card* = object
kind*: CardKind kind*: CardKind
url*: string url*: string
@@ -225,8 +205,6 @@ type
video*: Option[Video] video*: Option[Video]
photos*: seq[string] photos*: seq[string]
Tweets* = seq[Tweet]
Result*[T] = object Result*[T] = object
content*: seq[T] content*: seq[T]
top*, bottom*: string top*, bottom*: string
@@ -234,7 +212,7 @@ type
query*: Query query*: Query
Chain* = object Chain* = object
content*: Tweets content*: seq[Tweet]
hasMore*: bool hasMore*: bool
cursor*: string cursor*: string
@@ -244,7 +222,7 @@ type
after*: Chain after*: Chain
replies*: Result[Chain] replies*: Result[Chain]
Timeline* = Result[Tweets] Timeline* = Result[Tweet]
Profile* = object Profile* = object
user*: User user*: User
@@ -296,6 +274,3 @@ type
proc contains*(thread: Chain; tweet: Tweet): bool = proc contains*(thread: Chain; tweet: Tweet): bool =
thread.content.anyIt(it.id == tweet.id) thread.content.anyIt(it.id == tweet.id)
proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
timeline.add @[tweet]

View File

@@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, uri, tables, base64 import strutils, strformat, uri, tables, base64
import nimcrypto import nimcrypto
import types
var var
hmacKey: string hmacKey: string
@@ -16,8 +17,7 @@ const
"twimg.com", "twimg.com",
"abs.twimg.com", "abs.twimg.com",
"pbs.twimg.com", "pbs.twimg.com",
"video.twimg.com", "video.twimg.com"
"x.com"
] ]
proc setHmacKey*(key: string) = proc setHmacKey*(key: string) =
@@ -29,6 +29,20 @@ proc setProxyEncoding*(state: bool) =
proc getHmac*(data: string): string = proc getHmac*(data: string): string =
($hmac(sha256, hmacKey, data))[0 .. 12] ($hmac(sha256, hmacKey, data))[0 .. 12]
proc getBestMp4VidVariant(video: Video): VideoVariant =
for v in video.variants:
if v.bitrate >= result.bitrate:
result = v
proc getVidVariant*(video: Video; playbackType: VideoType): VideoVariant =
case playbackType
of mp4:
return video.getBestMp4VidVariant
of m3u8, vmap:
for variant in video.variants:
if variant.contentType == playbackType:
return variant
proc getVidUrl*(link: string): string = proc getVidUrl*(link: string): string =
if link.len == 0: return if link.len == 0: return
let sig = getHmac(link) let sig = getHmac(link)
@@ -58,4 +72,4 @@ proc isTwitterUrl*(uri: Uri): bool =
uri.hostname in twitterDomains uri.hostname in twitterDomains
proc isTwitterUrl*(url: string): bool = proc isTwitterUrl*(url: string): bool =
isTwitterUrl(parseUri(url)) parseUri(url).hostname in twitterDomains

View File

@@ -11,7 +11,7 @@ const doctype = "<!DOCTYPE html>\n"
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
let thumb = get(tweet.video).thumb let thumb = get(tweet.video).thumb
let vidUrl = getVideoEmbed(cfg, tweet.id) let vidUrl = getVideoEmbed(cfg, tweet.id)
let prefs = Prefs(hlsPlayback: true, mp4Playback: true) let prefs = Prefs(hlsPlayback: true)
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))

View File

@@ -52,7 +52,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): buildHtml(head):
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") link(rel="stylesheet", type="text/css", href="/css/style.css?v=18")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
if theme.len > 0: if theme.len > 0:
@@ -73,7 +73,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
if prefs.hlsPlayback: if prefs.hlsPlayback:
script(src="/js/hls.min.js", `defer`="") script(src="/js/hls.light.min.js", `defer`="")
script(src="/js/hlsPlayback.js", `defer`="") script(src="/js/hlsPlayback.js", `defer`="")
if prefs.infiniteScroll: if prefs.infiniteScroll:

View File

@@ -23,13 +23,6 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
if text.len > 0: if text.len > 0:
text " " & text text " " & text
template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none:
let lower = ($user.verifiedType).toLowerAscii()
icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
else:
text ""
proc linkUser*(user: User, class=""): VNode = proc linkUser*(user: User, class=""): VNode =
let let
isName = "username" notin class isName = "username" notin class
@@ -39,11 +32,11 @@ proc linkUser*(user: User, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)): buildHtml(a(href=href, class=class, title=nameText)):
text nameText text nameText
if isName: if isName and user.verified:
verifiedIcon(user) icon "ok", class="verified-icon", title="Verified account"
if user.protected: if isName and user.protected:
text " " text " "
icon "lock", title="Protected account" icon "lock", title="Protected account"
proc linkText*(text: string; class=""): VNode = proc linkText*(text: string; class=""): VNode =
let url = if "http" notin text: https & text else: text let url = if "http" notin text: https & text else: text
@@ -91,7 +84,7 @@ proc genDate*(pref, state: string): VNode =
proc genImg*(url: string; class=""): VNode = proc genImg*(url: string; class=""): VNode =
buildHtml(): buildHtml():
img(src=getPicUrl(url), class=class, alt="", loading="lazy") img(src=getPicUrl(url), class=class, alt="", loading="lazy", decoding="async")
proc getTabClass*(query: Query; tab: QueryKind): string = proc getTabClass*(query: Query; tab: QueryKind): string =
if query.kind == tab: "tab-item active" if query.kind == tab: "tab-item active"

View File

@@ -28,24 +28,6 @@
Twitter feed for: ${desc}. Generated by ${cfg.hostname} Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#end proc #end proc
# #
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
#result = profile.tweets.content
#if profile.pinned.isSome and result.len > 0:
# let pinnedTweet = profile.pinned.get
# var inserted = false
# for threadIdx in 0 ..< result.len:
# if not inserted:
# for tweetIdx in 0 ..< result[threadIdx].len:
# if result[threadIdx][tweetIdx].id < pinnedTweet.id:
# result[threadIdx].insert(pinnedTweet, tweetIdx)
# inserted = true
# end if
# end for
# end if
# end for
#end if
#end proc
#
#proc renderRssTweet(tweet: Tweet; cfg: Config): string = #proc renderRssTweet(tweet: Tweet; cfg: Config): string =
#let tweet = tweet.retweet.get(tweet) #let tweet = tweet.retweet.get(tweet)
#let urlPrefix = getUrlPrefix(cfg) #let urlPrefix = getUrlPrefix(cfg)
@@ -74,31 +56,24 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#end if #end if
#end proc #end proc
# #
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = #proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string =
#let urlPrefix = getUrlPrefix(cfg) #let urlPrefix = getUrlPrefix(cfg)
#var links: seq[string] #var links: seq[string]
#for thread in tweets: #for t in tweets:
# for tweet in thread: # let retweet = if t.retweet.isSome: t.user.username else: ""
# if userId.len > 0 and tweet.user.id != userId: continue # let tweet = if retweet.len > 0: t.retweet.get else: t
# end if # let link = getLink(tweet)
# # if link in links: continue
# let retweet = if tweet.retweet.isSome: tweet.user.username else: "" # end if
# let tweet = if retweet.len > 0: tweet.retweet.get else: tweet # links.add link
# if not tweet.available: continue <item>
# end if <title>${getTitle(tweet, retweet)}</title>
# let link = getLink(tweet) <dc:creator>@${tweet.user.username}</dc:creator>
# if link in links: continue <description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
# end if <pubDate>${getRfc822Time(tweet)}</pubDate>
# links.add link <guid>${urlPrefix & link}</guid>
<item> <link>${urlPrefix & link}</link>
<title>${getTitle(tweet, retweet)}</title> </item>
<dc:creator>@${tweet.user.username}</dc:creator>
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
<pubDate>${getRfc822Time(tweet)}</pubDate>
<guid>${urlPrefix & link}</guid>
<link>${urlPrefix & link}</link>
</item>
# end for
#end for #end for
#end proc #end proc
# #
@@ -126,15 +101,14 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
<width>128</width> <width>128</width>
<height>128</height> <height>128</height>
</image> </image>
#let tweetsList = getTweetsWithPinned(profile) #if profile.tweets.content.len > 0:
#if tweetsList.len > 0: ${renderRssTweets(profile.tweets.content, cfg)}
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
#end if #end if
</channel> </channel>
</rss> </rss>
#end proc #end proc
# #
#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string = #proc renderListRss*(tweets: seq[Tweet]; list: List; cfg: Config): string =
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}" #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
#result = "" #result = ""
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@@ -151,7 +125,7 @@ ${renderRssTweets(tweets, cfg)}
</rss> </rss>
#end proc #end proc
# #
#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string = #proc renderSearchRss*(tweets: seq[Tweet]; name, param: string; cfg: Config): string =
#let link = &"{getUrlPrefix(cfg)}/search" #let link = &"{getUrlPrefix(cfg)}/search"
#let escName = xmltree.escape(name) #let escName = xmltree.escape(name)
#result = "" #result = ""

View File

@@ -24,9 +24,9 @@ proc renderSearch*(): VNode =
buildHtml(tdiv(class="panel-container")): buildHtml(tdiv(class="panel-container")):
tdiv(class="search-bar"): tdiv(class="search-bar"):
form(`method`="get", action="/search", autocomplete="off"): form(`method`="get", action="/search", autocomplete="off"):
hiddenField("f", "tweets") hiddenField("f", "users")
input(`type`="text", name="q", autofocus="", input(`type`="text", name="q", autofocus="",
placeholder="Search...", dir="auto") placeholder="Enter username...", dir="auto")
button(`type`="submit"): icon "search" button(`type`="submit"): icon "search"
proc renderProfileTabs*(query: Query; username: string): VNode = proc renderProfileTabs*(query: Query; username: string): VNode =
@@ -88,7 +88,7 @@ proc renderSearchPanel*(query: Query): VNode =
span(class="search-title"): text "Near" span(class="search-title"): text "Near"
genInput("near", "", query.near, "Location...", autofocus=false) genInput("near", "", query.near, "Location...", autofocus=false)
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =
let query = results.query let query = results.query
buildHtml(tdiv(class="timeline-container")): buildHtml(tdiv(class="timeline-container")):

View File

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, algorithm, uri, options import strutils, strformat, sequtils, algorithm, uri, options
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import ".."/[types, query, formatters] import ".."/[types, query, formatters]
@@ -39,22 +39,24 @@ proc renderNoneFound(): VNode =
h2(class="timeline-none"): h2(class="timeline-none"):
text "No items found" text "No items found"
proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="thread-line")): buildHtml(tdiv(class="thread-line")):
let sortedThread = thread.sortedByIt(it.id) let sortedThread = thread.sortedByIt(it.id)
for i, tweet in sortedThread: for i, tweet in sortedThread:
# thread has a gap, display "more replies" link
if i > 0 and tweet.replyId != sortedThread[i - 1].id:
tdiv(class="timeline-item thread more-replies-thread"):
tdiv(class="more-replies"):
a(class="more-replies-text", href=getLink(tweet)):
text "more replies"
let show = i == thread.high and sortedThread[0].id != tweet.threadId let show = i == thread.high and sortedThread[0].id != tweet.threadId
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
renderTweet(tweet, prefs, path, class=(header & "thread"), renderTweet(tweet, prefs, path, class=(header & "thread"),
index=i, last=(i == thread.high), showThread=show) index=i, last=(i == thread.high), showThread=show)
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
result = @[it]
if it.retweet.isSome or it.replyId in threads: return
for t in tweets:
if t.id == result[0].replyId:
result.insert t
elif t.replyId == result[0].id:
result.add t
proc renderUser(user: User; prefs: Prefs): VNode = proc renderUser(user: User; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-item")): buildHtml(tdiv(class="timeline-item")):
a(class="tweet-link", href=("/" & user.username)) a(class="tweet-link", href=("/" & user.username))
@@ -87,7 +89,7 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
else: else:
renderNoMore() renderNoMore()
proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =
buildHtml(tdiv(class="timeline")): buildHtml(tdiv(class="timeline")):
if not results.beginning: if not results.beginning:
@@ -103,26 +105,26 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
else: else:
renderNoneFound() renderNoneFound()
else: else:
var retweets: seq[int64] var
threads: seq[int64]
retweets: seq[int64]
for thread in results.content: for tweet in results.content:
if thread.len == 1: let rt = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
let
tweet = thread[0]
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
if retweetId in retweets or tweet.id in retweets or if tweet.id in threads or rt in retweets or tweet.id in retweets or
tweet.pinned and prefs.hidePins: tweet.pinned and prefs.hidePins: continue
continue
let thread = results.content.threadFilter(threads, tweet)
if thread.len < 2:
var hasThread = tweet.hasThread var hasThread = tweet.hasThread
if retweetId != 0 and tweet.retweet.isSome: if rt != 0:
retweets &= retweetId retweets &= rt
hasThread = get(tweet.retweet).hasThread hasThread = get(tweet.retweet).hasThread
renderTweet(tweet, prefs, path, showThread=hasThread) renderTweet(tweet, prefs, path, showThread=hasThread)
else: else:
renderThread(thread, prefs, path) renderThread(thread, prefs, path)
threads &= thread.mapIt(it.id)
if results.bottom.len > 0: renderMore(results.query, results.bottom)
renderMore(results.query, results.bottom)
renderToTop() renderToTop()

View File

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, sequtils, strformat, options, algorithm, uri import strutils, sequtils, strformat, options
import karax/[karaxdsl, vdom, vstyles] import karax/[karaxdsl, vdom, vstyles]
from jester import Request from jester import Request
@@ -10,17 +10,20 @@ import general
const doctype = "<!DOCTYPE html>\n" const doctype = "<!DOCTYPE html>\n"
proc renderMiniAvatar(user: User; prefs: Prefs): VNode = proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini")) let url = getPicUrl(user.getUserPic("_mini"))
buildHtml():
img(class=(prefs.getAvatarClass & " mini"), src=url, loading="lazy")
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode = proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode =
buildHtml(tdiv): buildHtml(tdiv):
if pinned: if retweet.len > 0:
tdiv(class="pinned"):
span: icon "pin", "Pinned Tweet"
elif retweet.len > 0:
tdiv(class="retweet-header"): tdiv(class="retweet-header"):
span: icon "retweet", retweet & " retweeted" span: icon "retweet", retweet & " retweeted"
if tweet.pinned:
tdiv(class="pinned"):
span: icon "pin", "Pinned Tweet"
tdiv(class="tweet-header"): tdiv(class="tweet-header"):
a(class="tweet-avatar", href=("/" & tweet.user.username)): a(class="tweet-avatar", href=("/" & tweet.user.username)):
var size = "_bigger" var size = "_bigger"
@@ -82,34 +85,35 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
let let
container = if video.description.len == 0 and video.title.len == 0: "" container = if video.description.len == 0 and video.title.len == 0: ""
else: " card-container" else: " card-container"
playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4 playbackType = if prefs.proxyVideos and video.hasMp4Url: mp4
else: video.playbackType else: video.playbackType
buildHtml(tdiv(class="attachments card")): buildHtml(tdiv(class="attachments card")):
tdiv(class="gallery-video" & container): tdiv(class="gallery-video" & container):
tdiv(class="attachment video-container"): tdiv(class="attachment video-container"):
let thumb = getSmallPic(video.thumb) let thumb = getSmallPic(video.thumb)
if not video.available: let canPlay = prefs.isPlaybackEnabled(playbackType)
img(src=thumb, loading="lazy")
renderVideoUnavailable(video) if video.available and canPlay:
elif not prefs.isPlaybackEnabled(playbackType):
img(src=thumb, loading="lazy")
renderVideoDisabled(playbackType, path)
else:
let let
vars = video.variants.filterIt(it.contentType == playbackType) vidUrl = video.getVidVariant(playbackType).url
vidUrl = vars.sortedByIt(it.resolution)[^1].url
source = if prefs.proxyVideos: getVidUrl(vidUrl) source = if prefs.proxyVideos: getVidUrl(vidUrl)
else: vidUrl else: vidUrl
case playbackType case playbackType
of mp4: of mp4:
video(poster=thumb, controls="", muted=prefs.muteVideos): video(src=source, poster=thumb, controls="", muted=prefs.muteVideos, preload="metadata")
source(src=source, `type`="video/mp4")
of m3u8, vmap: of m3u8, vmap:
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">" verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle") tdiv(class="overlay-circle"): span(class="overlay-triangle")
verbatim "</div>" verbatim "</div>"
else:
img(src=thumb, loading="lazy", decoding="async")
if not canPlay:
renderVideoDisabled(playbackType, path)
else:
renderVideoUnavailable(video)
if container.len > 0: if container.len > 0:
tdiv(class="card-content"): tdiv(class="card-content"):
h2(class="card-title"): text video.title h2(class="card-title"): text video.title
@@ -142,7 +146,7 @@ proc renderPoll(poll: Poll): VNode =
proc renderCardImage(card: Card): VNode = proc renderCardImage(card: Card): VNode =
buildHtml(tdiv(class="card-image-container")): buildHtml(tdiv(class="card-image-container")):
tdiv(class="card-image"): tdiv(class="card-image"):
genImg(card.image) img(src=getPicUrl(card.image), alt="", loading="lazy")
if card.kind == player: if card.kind == player:
tdiv(class="card-overlay"): tdiv(class="card-overlay"):
tdiv(class="overlay-circle"): tdiv(class="overlay-circle"):
@@ -178,11 +182,11 @@ func formatStat(stat: int): string =
if stat > 0: insertSep($stat, ',') if stat > 0: insertSep($stat, ',')
else: "" else: ""
proc renderStats(tweet_id: int64; stats: TweetStats; views: string): VNode = proc renderStats(stats: TweetStats; views: string): VNode =
buildHtml(tdiv(class="tweet-stats")): buildHtml(tdiv(class="tweet-stats")):
span(class="tweet-stat"): icon "comment", formatStat(stats.replies) span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
a(class="tweet-stat", href=("/search?q=" & encodeUrl(&"-from:quotedreplies url:{tweet_id}") & "&e-nativeretweets=on")): icon "quote", formatStat(stats.quotes) span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
span(class="tweet-stat"): icon "heart", formatStat(stats.likes) span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
if views.len > 0: if views.len > 0:
span(class="tweet-stat"): icon "play", insertSep(views, ',') span(class="tweet-stat"): icon "play", insertSep(views, ',')
@@ -198,7 +202,8 @@ proc renderAttribution(user: User; prefs: Prefs): VNode =
buildHtml(a(class="attribution", href=("/" & user.username))): buildHtml(a(class="attribution", href=("/" & user.username))):
renderMiniAvatar(user, prefs) renderMiniAvatar(user, prefs)
strong: text user.fullname strong: text user.fullname
verifiedIcon(user) if user.verified:
icon "ok", class="verified-icon", title="Verified account"
proc renderMediaTags(tags: seq[User]): VNode = proc renderMediaTags(tags: seq[User]): VNode =
buildHtml(tdiv(class="media-tag-block")): buildHtml(tdiv(class="media-tag-block")):
@@ -286,10 +291,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.quote.isSome: if tweet.quote.isSome:
renderQuote(tweet.quote.get(), prefs, path) renderQuote(tweet.quote.get(), prefs, path)
let let fullTweet = tweet
fullTweet = tweet
pinned = tweet.pinned
var retweet: string var retweet: string
var tweet = fullTweet var tweet = fullTweet
if tweet.retweet.isSome: if tweet.retweet.isSome:
@@ -302,7 +304,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
tdiv(class="tweet-body"): tdiv(class="tweet-body"):
var views = "" var views = ""
renderHeader(tweet, retweet, pinned, prefs) renderHeader(tweet, retweet, prefs)
if not afterTweet and index == 0 and tweet.reply.len > 0 and if not afterTweet and index == 0 and tweet.reply.len > 0 and
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username): (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
@@ -343,7 +345,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderMediaTags(tweet.mediaTags) renderMediaTags(tweet.mediaTags)
if not prefs.hideTweetStats: if not prefs.hideTweetStats:
renderStats(tweet.id, tweet.stats, views) renderStats(tweet.stats, views)
if showThread: if showThread:
a(class="show-thread", href=("/i/status/" & $tweet.threadId)): a(class="show-thread", href=("/i/status/" & $tweet.threadId)):

View File

@@ -13,32 +13,32 @@ card = [
'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],
['nim_lang/status/1082989146040340480', ['FluentAI/status/1116417904831029248',
'Nim in 2018: A short recap', 'Amazons Alexa isnt just AI — thousands of humans are listening',
'There were several big news in the Nim world in 2018 two new major releases, partnership with Status, and much more. But let us go chronologically.', 'One of the only ways to improve Alexa is to have human beings check it for errors',
'nim-lang.org', True] 'theverge.com', 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', 'facebookresearch/fairseq',
'', 'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in Python.',
'github.com'], 'github.com'],
['brent_p/status/1088857328680488961', ['brent_p/status/1088857328680488961',
'GitHub - brentp/hts-nim: nim wrapper for htslib for parsing genomics data files', 'Hts Nim Sugar',
'', 'hts-nim is a library that allows one to use htslib via the nim programming language. Nim is a garbage-collected language that compiles to C and often has similar performance. I have become very...',
'github.com'], 'brentp.github.io'],
['voidtarget/status/1133028231672582145', ['voidtarget/status/1133028231672582145',
'sinkingsugar/nimqt-example', 'sinkingsugar/nimqt-example',
'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.', 'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
'github.com'] 'github.com'],
['nim_lang/status/1082989146040340480',
'Nim in 2018: A short recap',
'Posted by u/miran1 - 36 votes and 46 comments',
'reddit.com']
] ]
playable = [ playable = [
@@ -53,6 +53,17 @@ playable = [
'youtube.com'] 'youtube.com']
] ]
# promo = [
# ['BangOlufsen/status/1145698701517754368',
# 'Upgrade your journey', '',
# 'www.bang-olufsen.com'],
# ['BangOlufsen/status/1154934429900406784',
# 'Learn more about Beosound Shape', '',
# 'www.bang-olufsen.com']
# ]
class CardTest(BaseTestCase): class CardTest(BaseTestCase):
@parameterized.expand(card) @parameterized.expand(card)
def test_card(self, tweet, title, description, destination, large): def test_card(self, tweet, title, description, destination, large):
@@ -87,3 +98,13 @@ class CardTest(BaseTestCase):
self.assert_element_visible('.card-overlay') self.assert_element_visible('.card-overlay')
if len(description) > 0: if len(description) > 0:
self.assert_text(description, c.description) self.assert_text(description, c.description)
# @parameterized.expand(promo)
# def test_card_promo(self, tweet, title, description, destination):
# self.open_nitter(tweet)
# c = Card(Conversation.main + " ")
# self.assert_text(title, c.title)
# self.assert_text(destination, c.destination)
# self.assert_element_visible('.video-overlay')
# if len(description) > 0:
# self.assert_text(description, c.description)

View File

@@ -4,7 +4,7 @@ from parameterized import parameterized
profiles = [ profiles = [
['mobile_test', 'Test account', ['mobile_test', 'Test account',
'Test Account. test test Testing username with @mobile_test_2 and a #hashtag', 'Test Account. test test Testing username with @mobile_test_2 and a #hashtag',
'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '97'], 'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '100'],
['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13'] ['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13']
] ]
@@ -66,8 +66,8 @@ class ProfileTest(BaseTestCase):
self.assert_text(f'User "{username}" not found') self.assert_text(f'User "{username}" not found')
def test_suspended(self): def test_suspended(self):
self.open_nitter('suspendme') self.open_nitter('user')
self.assert_text('User "suspendme" has been suspended') self.assert_text('User "user" has been suspended')
@parameterized.expand(banner_image) @parameterized.expand(banner_image)
def test_banner_image(self, username, url): def test_banner_image(self, username, url):

View File

@@ -2,8 +2,14 @@ from base import BaseTestCase, Quote, Conversation
from parameterized import parameterized from parameterized import parameterized
text = [ text = [
['elonmusk/status/1138136540096319488',
'TREV PAGE', '@Model3Owners',
"""As of March 58.4% of new car sales in Norway are electric.
What are we doing wrong? reuters.com/article/us-norwa…"""],
['nim_lang/status/1491461266849808397#m', ['nim_lang/status/1491461266849808397#m',
'Nim', '@nim_lang', 'Nim language', '@nim_lang',
"""What's better than Nim 1.6.0? """What's better than Nim 1.6.0?
Nim 1.6.2 :) Nim 1.6.2 :)

View File

@@ -2,8 +2,8 @@ from base import BaseTestCase
from parameterized import parameterized from parameterized import parameterized
#class SearchTest(BaseTestCase): class SearchTest(BaseTestCase):
#@parameterized.expand([['@mobile_test'], ['@mobile_test_2']]) @parameterized.expand([['@mobile_test'], ['@mobile_test_2']])
#def test_username_search(self, username): def test_username_search(self, username):
#self.search_username(username) self.search_username(username)
#self.assert_text(f'{username}') self.assert_text(f'{username}')

View File

@@ -1,18 +1,23 @@
from base import BaseTestCase, Timeline from base import BaseTestCase, Timeline
from parameterized import parameterized from parameterized import parameterized
normal = [['jack'], ['elonmusk']] normal = [['mobile_test'], ['mobile_test_2']]
after = [['jack', '1681686036294803456'], after = [['mobile_test', 'HBaAgJPsqtGNhA0AAA%3D%3D'],
['elonmusk', '1681686036294803456']] ['mobile_test_2', 'HBaAgJPsqtGNhA0AAA%3D%3D']]
no_more = [['mobile_test_8?cursor=DAABCgABF4YVAqN___kKAAICNn_4msIQAAgAAwAAAAIAAA']] no_more = [['mobile_test_8?cursor=HBaAwJCsk%2F6%2FtgQAAA%3D%3D']]
empty = [['emptyuser'], ['mobile_test_10']] empty = [['emptyuser'], ['mobile_test_10']]
protected = [['mobile_test_7'], ['Empty_user']] protected = [['mobile_test_7'], ['Empty_user']]
photo_rail = [['mobile_test', ['Bo0nDsYIYAIjqVn', 'BoQbwJAIUAA0QCY', 'BoQbRQxIIAA3FWD', 'Bn8Qh8iIIAABXrG']]] photo_rail = [['mobile_test', [
'BzUnaDFCUAAmrjs', 'Bo0nDsYIYAIjqVn', 'Bos--KNIQAAA7Li', 'Boq1sDJIYAAxaoi',
'BonISmPIEAAhP3G', 'BoQbwJAIUAA0QCY', 'BoQbRQxIIAA3FWD', 'Bn8Qh8iIIAABXrG',
'Bn8QIG3IYAA0IGT', 'Bn8O3QeIUAAONai', 'Bn8NGViIAAATNG4', 'BkKovdrCUAAEz79',
'BkKoe_oCIAASAqr', 'BkKoRLNCAAAYfDf', 'BkKndxoCQAE1vFt', 'BPEmIbYCMAE44dl'
]]]
class TweetTest(BaseTestCase): class TweetTest(BaseTestCase):
@@ -55,10 +60,10 @@ class TweetTest(BaseTestCase):
self.assert_element_absent(Timeline.older) self.assert_element_absent(Timeline.older)
self.assert_element_absent(Timeline.end) self.assert_element_absent(Timeline.end)
#@parameterized.expand(photo_rail) @parameterized.expand(photo_rail)
#def test_photo_rail(self, username, images): def test_photo_rail(self, username, images):
#self.open_nitter(username) self.open_nitter(username)
#self.assert_element_visible(Timeline.photo_rail) self.assert_element_visible(Timeline.photo_rail)
#for i, url in enumerate(images): for i, url in enumerate(images):
#img = self.get_attribute(Timeline.photo_rail + f' a:nth-child({i + 1}) img', 'src') img = self.get_attribute(Timeline.photo_rail + f' a:nth-child({i + 1}) img', 'src')
#self.assertIn(url, img) self.assertIn(url, img)

View File

@@ -1,4 +1,4 @@
from base import BaseTestCase, Tweet, Conversation, get_timeline_tweet from base import BaseTestCase, Tweet, get_timeline_tweet
from parameterized import parameterized from parameterized import parameterized
# image = tweet + 'div.attachments.media-body > div > div > a > div > img' # image = tweet + 'div.attachments.media-body > div > div > a > div > img'
@@ -28,15 +28,14 @@ invalid = [
] ]
multiline = [ multiline = [
[1718660434457239868, 'WebDesignMuseum', [400897186990284800, 'mobile_test_3',
""" """
Happy 32nd Birthday HTML tags!
KEEP
On October 29, 1991, the internet pioneer, Tim Berners-Lee, published a document entitled HTML Tags. CALM
AND
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. CLICHÉ
ON"""]
#WebDesignHistory"""]
] ]
link = [ link = [
@@ -75,6 +74,10 @@ retweet = [
[3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr'] [3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr']
] ]
reply = [
['mobile_test/with_replies', 15]
]
class TweetTest(BaseTestCase): class TweetTest(BaseTestCase):
@parameterized.expand(timeline) @parameterized.expand(timeline)
@@ -100,18 +103,18 @@ class TweetTest(BaseTestCase):
@parameterized.expand(multiline) @parameterized.expand(multiline)
def test_multiline_formatting(self, tid, username, text): def test_multiline_formatting(self, tid, username, text):
self.open_nitter(f'{username}/status/{tid}') self.open_nitter(f'{username}/status/{tid}')
self.assert_text(text.strip('\n'), Conversation.main) self.assert_text(text.strip('\n'), '.main-tweet')
@parameterized.expand(emoji) @parameterized.expand(emoji)
def test_emoji(self, tweet, text): def test_emoji(self, tweet, text):
self.open_nitter(tweet) self.open_nitter(tweet)
self.assert_text(text, Conversation.main) self.assert_text(text, '.main-tweet')
@parameterized.expand(link) @parameterized.expand(link)
def test_link(self, tweet, links): def test_link(self, tweet, links):
self.open_nitter(tweet) self.open_nitter(tweet)
for link in links: for link in links:
self.assert_text(link, Conversation.main) self.assert_text(link, '.main-tweet')
@parameterized.expand(username) @parameterized.expand(username)
def test_username(self, tweet, usernames): def test_username(self, tweet, usernames):
@@ -134,8 +137,8 @@ class TweetTest(BaseTestCase):
self.open_nitter(tweet) self.open_nitter(tweet)
self.assert_text('Tweet not found', '.error-panel') self.assert_text('Tweet not found', '.error-panel')
#@parameterized.expand(reply) @parameterized.expand(reply)
#def test_thread(self, tweet, num): def test_thread(self, tweet, num):
#self.open_nitter(tweet) self.open_nitter(tweet)
#thread = self.find_element(f'.timeline > div:nth-child({num})') thread = self.find_element(f'.timeline > div:nth-child({num})')
#self.assertIn(thread.get_attribute('class'), 'thread-line') self.assertIn(thread.get_attribute('class'), 'thread-line')

View File

@@ -14,7 +14,7 @@ poll = [
image = [ image = [
['mobile_test/status/519364660823207936', 'BzUnaDFCUAAmrjs'], ['mobile_test/status/519364660823207936', 'BzUnaDFCUAAmrjs'],
#['mobile_test_2/status/324619691039543297', 'BIFH45vCUAAQecj'] ['mobile_test_2/status/324619691039543297', 'BIFH45vCUAAQecj']
] ]
gif = [ gif = [
@@ -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'],

View File

@@ -1,162 +0,0 @@
#!/usr/bin/env python3
import requests
import json
import sys
import pyotp
import cloudscraper
# NOTE: pyotp, requests and cloudscraper are dependencies
# > pip install pyotp requests cloudscraper
TW_CONSUMER_KEY = '3nVuSoBZnx6U4vzUxf5w'
TW_CONSUMER_SECRET = 'Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys'
def auth(username, password, otp_secret):
bearer_token_req = requests.post("https://api.twitter.com/oauth2/token",
auth=(TW_CONSUMER_KEY, TW_CONSUMER_SECRET),
headers={"Content-Type": "application/x-www-form-urlencoded"},
data='grant_type=client_credentials'
).json()
bearer_token = ' '.join(str(x) for x in bearer_token_req.values())
guest_token = requests.post(
"https://api.twitter.com/1.1/guest/activate.json",
headers={'Authorization': bearer_token}
).json().get('guest_token')
if not guest_token:
print("Failed to obtain guest token.")
sys.exit(1)
twitter_header = {
'Authorization': bearer_token,
"Content-Type": "application/json",
"User-Agent": "TwitterAndroid/10.21.0-release.0 (310210000-r-0) ONEPLUS+A3010/9 (OnePlus;ONEPLUS+A3010;OnePlus;OnePlus3;0;;1;2016)",
"X-Twitter-API-Version": '5',
"X-Twitter-Client": "TwitterAndroid",
"X-Twitter-Client-Version": "10.21.0-release.0",
"OS-Version": "28",
"System-User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9; ONEPLUS A3010 Build/PKQ1.181203.001)",
"X-Twitter-Active-User": "yes",
"X-Guest-Token": guest_token,
"X-Twitter-Client-DeviceID": ""
}
scraper = cloudscraper.create_scraper()
scraper.headers = twitter_header
task1 = scraper.post(
'https://api.twitter.com/1.1/onboarding/task.json',
params={
'flow_name': 'login',
'api_version': '1',
'known_device_token': '',
'sim_country_code': 'us'
},
json={
"flow_token": None,
"input_flow_data": {
"country_code": None,
"flow_context": {
"referrer_context": {
"referral_details": "utm_source=google-play&utm_medium=organic",
"referrer_url": ""
},
"start_location": {
"location": "deeplink"
}
},
"requested_variant": None,
"target_user_id": 0
}
}
)
scraper.headers['att'] = task1.headers.get('att')
task2 = scraper.post(
'https://api.twitter.com/1.1/onboarding/task.json',
json={
"flow_token": task1.json().get('flow_token'),
"subtask_inputs": [{
"enter_text": {
"suggestion_id": None,
"text": username,
"link": "next_link"
},
"subtask_id": "LoginEnterUserIdentifier"
}]
}
)
task3 = scraper.post(
'https://api.twitter.com/1.1/onboarding/task.json',
json={
"flow_token": task2.json().get('flow_token'),
"subtask_inputs": [{
"enter_password": {
"password": password,
"link": "next_link"
},
"subtask_id": "LoginEnterPassword"
}],
}
)
for t3_subtask in task3.json().get('subtasks', []):
if "open_account" in t3_subtask:
return t3_subtask["open_account"]
elif "enter_text" in t3_subtask:
response_text = t3_subtask["enter_text"]["hint_text"]
totp = pyotp.TOTP(otp_secret)
generated_code = totp.now()
task4resp = scraper.post(
"https://api.twitter.com/1.1/onboarding/task.json",
json={
"flow_token": task3.json().get("flow_token"),
"subtask_inputs": [
{
"enter_text": {
"suggestion_id": None,
"text": generated_code,
"link": "next_link",
},
"subtask_id": "LoginTwoFactorAuthChallenge",
}
],
}
)
task4 = task4resp.json()
for t4_subtask in task4.get("subtasks", []):
if "open_account" in t4_subtask:
return t4_subtask["open_account"]
return None
if __name__ == "__main__":
if len(sys.argv) != 5:
print("Usage: python3 get_session.py <username> <password> <2fa secret> <path>")
sys.exit(1)
username = sys.argv[1]
password = sys.argv[2]
otp_secret = sys.argv[3]
path = sys.argv[4]
result = auth(username, password, otp_secret)
if result is None:
print("Authentication failed.")
sys.exit(1)
session_entry = {
"oauth_token": result.get("oauth_token"),
"oauth_token_secret": result.get("oauth_token_secret")
}
try:
with open(path, "a") as f:
f.write(json.dumps(session_entry) + "\n")
print("Authentication successful. Session appended to", path)
except Exception as e:
print(f"Failed to write session information: {e}")
sys.exit(1)

View File

@@ -1,158 +0,0 @@
#!/usr/bin/env python3
"""
Authenticates with X.com/Twitter and extracts session cookies for use with Nitter.
Handles 2FA, extracts user info, and outputs clean JSON for sessions.jsonl.
Requirements:
pip install -r tools/requirements.txt
Usage:
python3 tools/get_web_session.py <username> <password> [totp_seed] [--append sessions.jsonl] [--headless]
Examples:
# Output to terminal
python3 tools/get_web_session.py myusername mypassword TOTP_BASE32_SECRET
# Append to sessions.jsonl
python3 tools/get_web_session.py myusername mypassword TOTP_SECRET --append sessions.jsonl
# Headless mode (may increase detection risk)
python3 tools/get_web_session.py myusername mypassword TOTP_SECRET --headless
Output:
{"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."}
"""
import sys
import json
import asyncio
import pyotp
import nodriver as uc
import os
async def login_and_get_cookies(username, password, totp_seed=None, headless=False):
"""Authenticate with X.com and extract session cookies"""
# Note: headless mode may increase detection risk from bot-detection systems
browser = await uc.start(headless=headless)
tab = await browser.get('https://x.com/i/flow/login')
try:
# Enter username
print('[*] Entering username...', file=sys.stderr)
username_input = await tab.find('input[autocomplete="username"]', timeout=10)
await username_input.send_keys(username + '\n')
await asyncio.sleep(1)
# Enter password
print('[*] Entering password...', file=sys.stderr)
password_input = await tab.find('input[autocomplete="current-password"]', timeout=15)
await password_input.send_keys(password + '\n')
await asyncio.sleep(2)
# Handle 2FA if needed
page_content = await tab.get_content()
if 'verification code' in page_content or 'Enter code' in page_content:
if not totp_seed:
raise Exception('2FA required but no TOTP seed provided')
print('[*] 2FA detected, entering code...', file=sys.stderr)
totp_code = pyotp.TOTP(totp_seed).now()
code_input = await tab.select('input[type="text"]')
await code_input.send_keys(totp_code + '\n')
await asyncio.sleep(3)
# Get cookies
print('[*] Retrieving cookies...', file=sys.stderr)
for _ in range(20): # 20 second timeout
cookies = await browser.cookies.get_all()
cookies_dict = {cookie.name: cookie.value for cookie in cookies}
if 'auth_token' in cookies_dict and 'ct0' in cookies_dict:
print('[*] Found both cookies', file=sys.stderr)
# Extract ID from twid cookie (may be URL-encoded)
user_id = None
if 'twid' in cookies_dict:
twid = cookies_dict['twid']
# Try to extract the ID from twid (format: u%3D<id> or u=<id>)
if 'u%3D' in twid:
user_id = twid.split('u%3D')[1].split('&')[0].strip('"')
elif 'u=' in twid:
user_id = twid.split('u=')[1].split('&')[0].strip('"')
cookies_dict['username'] = username
if user_id:
cookies_dict['id'] = user_id
return cookies_dict
await asyncio.sleep(1)
raise Exception('Timeout waiting for cookies')
finally:
browser.stop()
async def main():
if len(sys.argv) < 3:
print('Usage: python3 twitter-auth.py username password [totp_seed] [--append sessions.jsonl] [--headless]')
sys.exit(1)
username = sys.argv[1]
password = sys.argv[2]
totp_seed = None
append_file = None
headless = False
# Parse optional arguments
i = 3
while i < len(sys.argv):
arg = sys.argv[i]
if arg == '--append':
if i + 1 < len(sys.argv):
append_file = sys.argv[i + 1]
i += 2 # Skip '--append' and filename
else:
print('[!] Error: --append requires a filename', file=sys.stderr)
sys.exit(1)
elif arg == '--headless':
headless = True
i += 1
elif not arg.startswith('--'):
if totp_seed is None:
totp_seed = arg
i += 1
else:
# Unkown args
print(f'[!] Warning: Unknown argument: {arg}', file=sys.stderr)
i += 1
try:
cookies = await login_and_get_cookies(username, password, totp_seed, headless)
session = {
'kind': 'cookie',
'username': cookies['username'],
'id': cookies.get('id'),
'auth_token': cookies['auth_token'],
'ct0': cookies['ct0']
}
output = json.dumps(session)
if append_file:
with open(append_file, 'a') as f:
f.write(output + '\n')
print(f'✓ Session appended to {append_file}', file=sys.stderr)
else:
print(output)
os._exit(0)
except Exception as error:
print(f'[!] Error: {error}', file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -1,2 +0,0 @@
nodriver>=0.48.0
pyotp