Compare commits
80 Commits
55d4469401
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
4eefa1bf07
|
|||
|
|
31d210ca47 | ||
|
|
dae68b4f13 | ||
|
|
8516ebe2b7 | ||
|
|
b83227aaf5 | ||
|
0e707271a4
|
|||
|
f68e234ea6
|
|||
|
|
404b06b5f3 | ||
|
|
2b922c049a | ||
|
948a0fdc5c
|
|||
|
|
78101df2cc | ||
|
|
12bbddf204 | ||
|
|
4979d07f2e | ||
|
|
f038b53fa2 | ||
|
|
4748311f8d | ||
|
|
d47eb8f0eb | ||
|
|
1657eeb769 | ||
|
|
25df682094 | ||
|
|
53edbbc4e9 | ||
|
|
5b4a3fe691 | ||
|
d3d825dc50
|
|||
|
|
f8a17fdaa5 | ||
|
b4d2f9aec9
|
|||
|
2e122dbcb1
|
|||
|
56762c9026
|
|||
|
3d9a7e3014
|
|||
|
|
b0d9c1d51a | ||
|
dba8410146
|
|||
|
704db60297
|
|||
|
73360e6972
|
|||
|
62a4347b96
|
|||
|
443996df96
|
|||
|
441ea68fd7
|
|||
|
2fd13de8e1
|
|||
|
4e05923cd8
|
|||
|
5a79ec025a
|
|||
|
644cda63a0
|
|||
|
|
78d788b27f | ||
|
49dc4b4f00
|
|||
|
c654cdf57e
|
|||
|
a7e8f3add0
|
|||
|
ce1139e335
|
|||
|
3845fb1213
|
|||
|
4df434a7c6
|
|||
|
57a1fc6820
|
|||
|
8d87371763
|
|||
|
2e2a96d7d8
|
|||
|
5e60c28ce5
|
|||
|
3256ba8b03
|
|||
|
b45ae21354
|
|||
|
bb6728b6ec
|
|||
|
e430765516
|
|||
|
3167cab01d
|
|||
|
0e4dbdef99
|
|||
|
9950260662
|
|||
|
dc0e894443
|
|||
|
6ef9816c4f
|
|||
|
f03a4e311f
|
|||
|
045baf1529
|
|||
|
|
9c8310f473
|
||
|
|
824a7e346a | ||
|
|
e8de18317e | ||
|
|
6b655cddd8 | ||
|
|
886f2d2a45 | ||
|
|
bb6eb81a20 | ||
|
|
0bb0b7e78c | ||
|
|
a666c4867c | ||
|
|
778eb35ee3 | ||
|
|
c3ff68b061
|
||
|
|
da72089568
|
||
|
|
a009028a74
|
||
|
|
3f3196d103 | ||
|
|
032b121a90
|
||
|
|
2f8986e804
|
||
|
|
2feed68232
|
||
|
|
68fc7b71c8 | ||
|
|
039f637cae
|
||
|
|
95236b4f15
|
||
|
|
c3e818f5bd
|
||
|
|
1e1e9e265a
|
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
||||
github: zedeus
|
||||
liberapay: zedeus
|
||||
patreon: nitter
|
||||
63
.github/workflows/build-docker.yml
vendored
63
.github/workflows/build-docker.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "README.md"
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
uses: ./.github/workflows/run-tests.yml
|
||||
secrets: inherit
|
||||
build-docker-amd64:
|
||||
needs: [tests]
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
version: latest
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push AMD64 Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
|
||||
build-docker-arm64:
|
||||
needs: [tests]
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
version: latest
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push ARM64 Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.arm64
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64
|
||||
108
.github/workflows/run-tests.yml
vendored
108
.github/workflows/run-tests.yml
vendored
@@ -1,108 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
branches-ignore:
|
||||
- master
|
||||
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:
|
||||
build-test:
|
||||
name: Build and test
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
nim: ["1.6.x", "2.0.x", "2.2.x", "devel"]
|
||||
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: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }}
|
||||
restore-keys: |
|
||||
${{ matrix.nim }}-nimble-v2-
|
||||
|
||||
- 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:
|
||||
python-version: "3.10"
|
||||
cache: pip
|
||||
|
||||
- name: Setup Nim
|
||||
uses: jiro4989/setup-nim-action@v2
|
||||
with:
|
||||
nim-version: devel
|
||||
use-nightlies: true
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Project
|
||||
run: nimble build -d:release -Y
|
||||
|
||||
- name: Install SeleniumBase and Chromedriver
|
||||
run: |
|
||||
pip install seleniumbase
|
||||
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
|
||||
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
|
||||
nimble md
|
||||
nimble scss
|
||||
echo '${{ secrets.SESSIONS }}' | head -n1
|
||||
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
./nitter &
|
||||
pytest -n1 tests
|
||||
51
.travis.yml
51
.travis.yml
@@ -1,51 +0,0 @@
|
||||
jobs:
|
||||
include:
|
||||
- stage: test
|
||||
if: (NOT type IN (pull_request)) AND (branch = master)
|
||||
dist: bionic
|
||||
language: python
|
||||
python:
|
||||
- 3.6
|
||||
services:
|
||||
- docker
|
||||
- xvfb
|
||||
script:
|
||||
- sudo apt update
|
||||
- sudo apt install --force-yes chromium-chromedriver
|
||||
- wget https://www.dropbox.com/s/ckuoaubd1crrj2k/Linux_x64_737173_chrome-linux.zip -O chrome.zip
|
||||
- unzip chrome.zip
|
||||
- cd chrome-linux
|
||||
- sudo rm /usr/bin/google-chrome
|
||||
- sudo ln -s chrome /usr/bin/google-chrome
|
||||
- cd ..
|
||||
- pip3 install --upgrade pip
|
||||
- pip3 install -U seleniumbase pytest
|
||||
- docker run -d -p 127.0.0.1:8080:8080/tcp $IMAGE_NAME:$TRAVIS_COMMIT
|
||||
- sleep 10
|
||||
- cd tests
|
||||
- pytest --headless -n 8 --reruns 10 --reruns-delay 2
|
||||
- stage: pr
|
||||
if: type IN (pull_request)
|
||||
dist: bionic
|
||||
language: python
|
||||
python:
|
||||
- 3.6
|
||||
services:
|
||||
- docker
|
||||
- xvfb
|
||||
script:
|
||||
- sudo apt update
|
||||
- sudo apt install --force-yes chromium-chromedriver
|
||||
- wget https://www.dropbox.com/s/ckuoaubd1crrj2k/Linux_x64_737173_chrome-linux.zip -O chrome.zip
|
||||
- unzip chrome.zip
|
||||
- cd chrome-linux
|
||||
- sudo rm /usr/bin/google-chrome
|
||||
- sudo ln -s chrome /usr/bin/google-chrome
|
||||
- cd ..
|
||||
- pip3 install --upgrade pip
|
||||
- pip3 install -U seleniumbase pytest
|
||||
- docker build -t $IMAGE_NAME:$TRAVIS_COMMIT .
|
||||
- docker run -d -p 127.0.0.1:8080:8080/tcp $IMAGE_NAME:$TRAVIS_COMMIT
|
||||
- sleep 10
|
||||
- cd tests
|
||||
- pytest --headless -n 8 --reruns 3 --reruns-delay 2
|
||||
@@ -1,25 +0,0 @@
|
||||
FROM alpine:3.20.6 as nim
|
||||
LABEL maintainer="setenforce@protonmail.com"
|
||||
|
||||
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble
|
||||
|
||||
WORKDIR /src/nitter
|
||||
|
||||
COPY nitter.nimble .
|
||||
RUN nimble install -y --depsOnly
|
||||
|
||||
COPY . .
|
||||
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
|
||||
&& nimble scss \
|
||||
&& nimble md
|
||||
|
||||
FROM alpine:3.20.6
|
||||
WORKDIR /src/
|
||||
RUN apk --no-cache add pcre ca-certificates openssl
|
||||
COPY --from=nim /src/nitter/nitter ./
|
||||
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
|
||||
COPY --from=nim /src/nitter/public ./public
|
||||
EXPOSE 8080
|
||||
RUN adduser -h /src/ -D -s /bin/sh nitter
|
||||
USER nitter
|
||||
CMD ./nitter
|
||||
@@ -24,15 +24,22 @@ hmacKey = "secretkey" # random key for cryptographic signing of video urls
|
||||
base64Media = false # use base64 encoding for proxied media urls
|
||||
enableRSS = true # set this to false to disable RSS feeds
|
||||
enableDebug = false # enable request logs and debug endpoints (/.sessions)
|
||||
logLevel = "info" # log level (debug, info, warn, error, fatal)
|
||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||
proxyAuth = ""
|
||||
defaultFollowedAccounts = "eff,fsf" # default accounts to show when user follows none
|
||||
disableTid = false # enable this if cookie-based auth is failing
|
||||
|
||||
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
||||
[Preferences]
|
||||
theme = "Nitter"
|
||||
replaceTwitter = "nitter.net"
|
||||
replaceYouTube = "piped.video"
|
||||
replaceReddit = "teddit.net"
|
||||
theme = "Kuuro"
|
||||
replaceTwitter = "nt.kuuro.net"
|
||||
replaceYouTube = "inv.nadeko.net"
|
||||
replaceReddit = "rd.kuuro.net"
|
||||
replaceImgur = "rd.kuuro.net"
|
||||
replaceFandom = "ph.kuuro.net"
|
||||
replaceSoundCloud = "sc.kuuro.net"
|
||||
proxyVideos = true
|
||||
hlsPlayback = false
|
||||
infiniteScroll = false
|
||||
hideNsfw = true
|
||||
|
||||
@@ -10,7 +10,7 @@ bin = @["nitter"]
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires "nim >= 1.6.10"
|
||||
requires "nim >= 2.0.0"
|
||||
requires "jester#baca3f"
|
||||
requires "karax#5cf360c"
|
||||
requires "sass#7dfdd03"
|
||||
|
||||
44
public/css/fontello.css
vendored
44
public/css/fontello.css
vendored
@@ -1,16 +1,15 @@
|
||||
@font-face {
|
||||
font-family: 'fontello';
|
||||
src: url('/fonts/fontello.eot?21002321');
|
||||
src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'),
|
||||
url('/fonts/fontello.woff2?21002321') format('woff2'),
|
||||
url('/fonts/fontello.woff?21002321') format('woff'),
|
||||
url('/fonts/fontello.ttf?21002321') format('truetype'),
|
||||
url('/fonts/fontello.svg?21002321#fontello') format('svg');
|
||||
src: url('/fonts/fontello.eot?61663884');
|
||||
src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'),
|
||||
url('/fonts/fontello.woff2?61663884') format('woff2'),
|
||||
url('/fonts/fontello.woff?61663884') format('woff'),
|
||||
url('/fonts/fontello.ttf?61663884') format('truetype'),
|
||||
url('/fonts/fontello.svg?61663884#fontello') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
[class^="icon-"]:before, [class*=" icon-"]:before {
|
||||
[class^="icon-"]:before, [class*=" icon-"]:before {
|
||||
font-family: "fontello";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
@@ -32,22 +31,23 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-heart:before { content: '\2665'; } /* '♥' */
|
||||
.icon-quote:before { content: '\275e'; } /* '❞' */
|
||||
.icon-comment:before { content: '\e802'; } /* '' */
|
||||
.icon-ok:before { content: '\e803'; } /* '' */
|
||||
.icon-play:before { content: '\e804'; } /* '' */
|
||||
.icon-link:before { content: '\e805'; } /* '' */
|
||||
.icon-calendar:before { content: '\e806'; } /* '' */
|
||||
.icon-location:before { content: '\e807'; } /* '' */
|
||||
|
||||
.icon-views:before { content: '\e800'; } /* '' */
|
||||
.icon-heart:before { content: '\e801'; } /* '' */
|
||||
.icon-quote:before { content: '\e802'; } /* '' */
|
||||
.icon-comment:before { content: '\e803'; } /* '' */
|
||||
.icon-ok:before { content: '\e804'; } /* '' */
|
||||
.icon-play:before { content: '\e805'; } /* '' */
|
||||
.icon-link:before { content: '\e806'; } /* '' */
|
||||
.icon-calendar:before { content: '\e807'; } /* '' */
|
||||
.icon-location:before { content: '\e808'; } /* '' */
|
||||
.icon-picture:before { content: '\e809'; } /* '' */
|
||||
.icon-lock:before { content: '\e80a'; } /* '' */
|
||||
.icon-down:before { content: '\e80b'; } /* '' */
|
||||
.icon-retweet:before { content: '\e80d'; } /* '' */
|
||||
.icon-search:before { content: '\e80e'; } /* '' */
|
||||
.icon-pin:before { content: '\e80f'; } /* '' */
|
||||
.icon-cog:before { content: '\e812'; } /* '' */
|
||||
.icon-rss-feed:before { content: '\e813'; } /* '' */
|
||||
.icon-retweet:before { content: '\e80c'; } /* '' */
|
||||
.icon-search:before { content: '\e80d'; } /* '' */
|
||||
.icon-pin:before { content: '\e80e'; } /* '' */
|
||||
.icon-cog:before { content: '\e80f'; } /* '' */
|
||||
.icon-rss:before { content: '\e810'; } /* '' */
|
||||
.icon-info:before { content: '\f128'; } /* '' */
|
||||
.icon-bird:before { content: '\f309'; } /* '' */
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
body {
|
||||
--bg_color: #000000;
|
||||
--fg_color: #FFFFFF;
|
||||
--fg_faded: #FFFFFFD4;
|
||||
--bg_color: #000;
|
||||
--fg_color: #EEE;
|
||||
--fg_faded: #EEEEEED4;
|
||||
--fg_dark: var(--accent);
|
||||
--fg_nav: var(--accent);
|
||||
|
||||
--bg_panel: #0C0C0C;
|
||||
--bg_elements: #000000;
|
||||
--bg_overlays: var(--bg_panel);
|
||||
--bg_hover: #131313;
|
||||
--bg_hover: #0F0F0F;
|
||||
|
||||
--grey: #E2E2E2;
|
||||
--dark_grey: #4E4E4E;
|
||||
--darker_grey: #272727;
|
||||
--darkest_grey: #212121;
|
||||
--border_grey: #7D7D7D;
|
||||
--grey: #939393;
|
||||
--dark_grey: #404040;
|
||||
--darker_grey: #1F1F1F;
|
||||
--darkest_grey: #151515;
|
||||
--border_grey: #1c1c1c;
|
||||
|
||||
--accent: #FF6C60;
|
||||
--accent_light: #FFACA0;
|
||||
--accent_dark: #909090;
|
||||
--accent_border: #FF6C6091;
|
||||
--accent_dark: #707070;
|
||||
--accent_border: #FF6C6060;
|
||||
|
||||
--play_button: var(--accent);
|
||||
--play_button_hover: var(--accent_light);
|
||||
|
||||
--more_replies_dots: #A7A7A7;
|
||||
--more_replies_dots: #808080;
|
||||
--error_red: #420A05;
|
||||
|
||||
--verified_blue: #1DA1F2;
|
||||
|
||||
59
public/css/themes/kuuro.css
Normal file
59
public/css/themes/kuuro.css
Normal file
@@ -0,0 +1,59 @@
|
||||
body {
|
||||
--bg_color: #050505;
|
||||
--fg_color: #d2d2d2;
|
||||
--fg_faded: #949494;
|
||||
--fg_dark: var(--accent);
|
||||
--fg_nav: var(--accent);
|
||||
|
||||
--bg_panel: #0a0a0a;
|
||||
--bg_elements: #0f0f0f;
|
||||
--bg_overlays: var(--bg_panel);
|
||||
--bg_hover: #151515;
|
||||
|
||||
--grey: #949494;
|
||||
--dark_grey: #444444;
|
||||
--darker_grey: #333333;
|
||||
--darkest_grey: #111111;
|
||||
--border_grey: rgba(255, 255, 255, 0.06);
|
||||
|
||||
--accent: #f3c2e6;
|
||||
--accent_light: #ffd6f2;
|
||||
--accent_dark: #a6859d;
|
||||
--accent_border: #f3c2e660;
|
||||
|
||||
--play_button: var(--accent);
|
||||
--play_button_hover: var(--accent_light);
|
||||
|
||||
--more_replies_dots: #5f6364;
|
||||
--error_red: #ec5f67;
|
||||
|
||||
--verified_blue: #6699cc;
|
||||
--icon_text: var(--fg_color);
|
||||
|
||||
--tab: var(--fg_color);
|
||||
--tab_selected: var(--accent);
|
||||
|
||||
--profile_stat: var(--fg_color);
|
||||
}
|
||||
|
||||
*:not(.avatar) {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
input, textarea, select, button,
|
||||
.card-container,
|
||||
.profile-card,
|
||||
.photo-rail-card,
|
||||
.timeline-item,
|
||||
.search-field,
|
||||
nav,
|
||||
.timeline-footer,
|
||||
.show-more,
|
||||
.unavailable-box,
|
||||
.tweet-embed,
|
||||
.overlay-panel,
|
||||
.timeline-header,
|
||||
.tab {
|
||||
outline: 1px solid var(--border_grey) !important;
|
||||
outline-offset: -1px !important;
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
Font license info
|
||||
|
||||
|
||||
## Modern Pictograms
|
||||
|
||||
Copyright (c) 2012 by John Caserta. All rights reserved.
|
||||
|
||||
Author: John Caserta
|
||||
License: SIL (http://scripts.sil.org/OFL)
|
||||
Homepage: http://thedesignoffice.org/project/modern-pictograms/
|
||||
|
||||
|
||||
## Entypo
|
||||
|
||||
Copyright (C) 2012 by Daniel Bruce
|
||||
@@ -37,12 +46,3 @@ Font license info
|
||||
Homepage: http://aristeides.com/
|
||||
|
||||
|
||||
## Modern Pictograms
|
||||
|
||||
Copyright (c) 2012 by John Caserta. All rights reserved.
|
||||
|
||||
Author: John Caserta
|
||||
License: SIL (http://scripts.sil.org/OFL)
|
||||
Homepage: http://thedesignoffice.org/project/modern-pictograms/
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,26 +1,28 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata>
|
||||
<metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata>
|
||||
<defs>
|
||||
<font id="fontello" horiz-adv-x="1000" >
|
||||
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
||||
<missing-glyph horiz-adv-x="1000" />
|
||||
<glyph glyph-name="heart" unicode="♥" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
|
||||
<glyph glyph-name="views" unicode="" d="M180 516l0-538-180 0 0 538 180 0z m250-138l0-400-180 0 0 400 180 0z m250 344l0-744-180 0 0 744 180 0z" horiz-adv-x="680" />
|
||||
|
||||
<glyph glyph-name="quote" unicode="❞" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
|
||||
<glyph glyph-name="heart" unicode="" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
|
||||
|
||||
<glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
|
||||
<glyph glyph-name="quote" unicode="" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
|
||||
|
||||
<glyph glyph-name="ok" unicode="" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
|
||||
<glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
||||
<glyph glyph-name="ok" unicode="" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
||||
<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
||||
|
||||
<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
|
||||
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
||||
|
||||
<glyph glyph-name="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
|
||||
<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
|
||||
|
||||
<glyph glyph-name="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
|
||||
|
||||
<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
|
||||
|
||||
@@ -28,19 +30,19 @@
|
||||
|
||||
<glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
||||
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
||||
|
||||
<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
|
||||
<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
|
||||
|
||||
<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
|
||||
<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
|
||||
|
||||
<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
|
||||
<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
|
||||
|
||||
<glyph glyph-name="rss-feed" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
||||
<glyph glyph-name="rss" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
||||
|
||||
<glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
|
||||
|
||||
<glyph glyph-name="bird" unicode="" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" />
|
||||
</font>
|
||||
</defs>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,7 @@
|
||||
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
const LOADING_TEXT = "Loading...";
|
||||
|
||||
function insertBeforeLast(node, elem) {
|
||||
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
|
||||
}
|
||||
@@ -15,63 +17,177 @@ function isDuplicate(item, itemClass) {
|
||||
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
|
||||
}
|
||||
|
||||
function addScrollToURL(href) {
|
||||
const url = new URL(href);
|
||||
url.searchParams.append("scroll", "true");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function fetchAndParse(url) {
|
||||
return fetch(url)
|
||||
.then(function (response) {
|
||||
return response.text();
|
||||
})
|
||||
.then(function (html) {
|
||||
var parser = new DOMParser();
|
||||
return parser.parseFromString(html, "text/html");
|
||||
});
|
||||
}
|
||||
|
||||
window.onload = function () {
|
||||
const url = window.location.pathname;
|
||||
const isTweet = url.indexOf("/status/") !== -1;
|
||||
const isHomepage = url === "/" || url === "";
|
||||
|
||||
if (isHomepage) return;
|
||||
|
||||
const isIncompleteThread =
|
||||
isTweet && document.querySelector(".timeline-item.more-replies") != null;
|
||||
|
||||
const containerClass = isTweet ? ".replies" : ".timeline";
|
||||
const itemClass = containerClass + " > div:not(.top-ref)";
|
||||
|
||||
var html = document.querySelector("html");
|
||||
var container = document.querySelector(containerClass);
|
||||
var mainContainer = document.querySelector(containerClass);
|
||||
var loading = false;
|
||||
|
||||
function handleScroll(failed) {
|
||||
if (loading) return;
|
||||
|
||||
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
||||
loading = true;
|
||||
var loadMore = getLoadMore(document);
|
||||
if (loadMore == null) return;
|
||||
|
||||
loadMore.children[0].text = "Loading...";
|
||||
|
||||
var url = new URL(loadMore.children[0].href);
|
||||
url.searchParams.append("scroll", "true");
|
||||
|
||||
fetch(url.toString()).then(function (response) {
|
||||
if (response.status === 404) throw "error";
|
||||
|
||||
return response.text();
|
||||
}).then(function (html) {
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(html, "text/html");
|
||||
loadMore.remove();
|
||||
|
||||
for (var item of doc.querySelectorAll(itemClass)) {
|
||||
if (item.className == "timeline-item show-more") continue;
|
||||
if (isDuplicate(item, itemClass)) continue;
|
||||
if (isTweet) container.appendChild(item);
|
||||
else insertBeforeLast(container, item);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
const newLoadMore = getLoadMore(doc);
|
||||
if (newLoadMore == null) return;
|
||||
if (isTweet) container.appendChild(newLoadMore);
|
||||
else insertBeforeLast(container, newLoadMore);
|
||||
}).catch(function (err) {
|
||||
console.warn("Something went wrong.", err);
|
||||
if (failed > 3) {
|
||||
loadMore.children[0].text = "Error";
|
||||
return;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
handleScroll((failed || 0) + 1);
|
||||
});
|
||||
}
|
||||
function catchErrors(err) {
|
||||
console.warn("Something went wrong.", err);
|
||||
loading = true;
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", () => handleScroll());
|
||||
function appendLoadedReplies(loadMore) {
|
||||
return function (doc) {
|
||||
loadMore.remove();
|
||||
|
||||
for (var item of doc.querySelectorAll(itemClass)) {
|
||||
if (item.className == "timeline-item show-more") continue;
|
||||
if (isDuplicate(item, itemClass)) continue;
|
||||
if (isTweet) mainContainer.appendChild(item);
|
||||
else insertBeforeLast(mainContainer, item);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
const newLoadMore = getLoadMore(doc);
|
||||
if (newLoadMore == null) return;
|
||||
if (isTweet) mainContainer.appendChild(newLoadMore);
|
||||
else insertBeforeLast(mainContainer, newLoadMore);
|
||||
};
|
||||
}
|
||||
|
||||
var scrollListener = null;
|
||||
if (!isIncompleteThread) {
|
||||
scrollListener = (e) => {
|
||||
if (loading) return;
|
||||
|
||||
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
||||
loading = true;
|
||||
var loadMore = getLoadMore(document);
|
||||
if (loadMore == null) return;
|
||||
|
||||
loadMore.children[0].text = LOADING_TEXT;
|
||||
|
||||
const fetchUrl = addScrollToURL(loadMore.children[0].href);
|
||||
fetchAndParse(fetchUrl)
|
||||
.then(appendLoadedReplies(loadMore))
|
||||
.catch(catchErrors);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
function getEarlierReplies(doc) {
|
||||
return doc.querySelector(".timeline-item.more-replies.earlier-replies");
|
||||
}
|
||||
|
||||
function getLaterReplies(doc) {
|
||||
return doc.querySelector(".after-tweet > .timeline-item.more-replies");
|
||||
}
|
||||
|
||||
function prependLoadedThread(loadMore) {
|
||||
return function (doc) {
|
||||
loadMore.remove();
|
||||
|
||||
const targetSelector = ".before-tweet.thread-line";
|
||||
const threadContainer = document.querySelector(targetSelector);
|
||||
|
||||
const earlierReplies = doc.querySelector(targetSelector);
|
||||
for (var i = earlierReplies.children.length - 1; i >= 0; i--) {
|
||||
threadContainer.insertBefore(
|
||||
earlierReplies.children[i],
|
||||
threadContainer.children[0]
|
||||
);
|
||||
}
|
||||
loading = false;
|
||||
};
|
||||
}
|
||||
|
||||
function appendLoadedThread(loadMore) {
|
||||
return function (doc) {
|
||||
const targetSelector = ".after-tweet.thread-line";
|
||||
const threadContainer = document.querySelector(targetSelector);
|
||||
|
||||
const laterReplies = doc.querySelector(targetSelector);
|
||||
while (laterReplies && laterReplies.firstChild) {
|
||||
threadContainer.appendChild(laterReplies.firstChild);
|
||||
}
|
||||
|
||||
const finalReply = threadContainer.lastElementChild;
|
||||
if (finalReply.classList.contains("thread-last")) {
|
||||
fetchAndParse(finalReply.children[0].href).then(function (lastDoc) {
|
||||
loadMore.remove();
|
||||
const anyResponses = lastDoc.querySelector(".replies");
|
||||
anyResponses &&
|
||||
insertBeforeLast(
|
||||
threadContainer.parentElement.parentElement,
|
||||
anyResponses
|
||||
);
|
||||
loading = false;
|
||||
});
|
||||
} else {
|
||||
loadMore.remove();
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
scrollListener = (e) => {
|
||||
if (loading) return;
|
||||
|
||||
if (html.scrollTop <= html.clientHeight) {
|
||||
var loadMore = getEarlierReplies(document);
|
||||
if (loadMore == null) return;
|
||||
loading = true;
|
||||
|
||||
loadMore.children[0].text = LOADING_TEXT;
|
||||
|
||||
fetchAndParse(loadMore.children[0].href)
|
||||
.then(prependLoadedThread(loadMore))
|
||||
.catch(catchErrors);
|
||||
} else if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
||||
var loadMore = getLaterReplies(document);
|
||||
if (loadMore != null) {
|
||||
loading = true;
|
||||
|
||||
loadMore.children[0].text = LOADING_TEXT;
|
||||
|
||||
fetchAndParse(loadMore.children[0].href)
|
||||
.then(appendLoadedThread(loadMore))
|
||||
.catch(catchErrors);
|
||||
} else {
|
||||
loadMore = getLoadMore(document);
|
||||
if (loadMore == null) return;
|
||||
loading = true;
|
||||
|
||||
loadMore.children[0].text = LOADING_TEXT;
|
||||
|
||||
mainContainer = document.querySelector(containerClass);
|
||||
fetchAndParse(loadMore.children[0].href)
|
||||
.then(appendLoadedReplies(loadMore))
|
||||
.catch(catchErrors);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", scrollListener);
|
||||
};
|
||||
// @license-end
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# About
|
||||
|
||||
This Nitter instance is hosted by [Kuuro.net](https://kuuro.net/services)
|
||||
|
||||
Nitter is a free and open source alternative Twitter front-end focused on
|
||||
privacy and performance. The source is available on GitHub at
|
||||
<https://github.com/zedeus/nitter>
|
||||
@@ -43,6 +45,28 @@ Twitter account.
|
||||
|
||||
## Donating
|
||||
|
||||
You can either donate to me for hosting this instance, or to the Nitter project for development.
|
||||
|
||||
Donating to me helps keep this Nitter instance running for everyone. Donating to the Nitter project helps the development of the software itself. Both are appreciated!
|
||||
|
||||
### Donating to me
|
||||
<br>
|
||||
|
||||
#### Credit/debit card and bank transfer
|
||||
|
||||
Buy Me A Coffee: <https://buymeacoffee.com/kuu7o>
|
||||
|
||||
#### Cryptocurrency
|
||||
|
||||
Monero: 44AAFK5nJEM8CMFGHh1DnPDVLnsAXu9y7VcKTAar5F1AGwQcoCi5hwC22efxGWsp4HfwRRwRRMj2X5ypTo3vkjpJPMxwynZ \
|
||||
Bitcoin: bc1q8uzvmm3y23kfmvmvva2n2uptqty5hzmlg2vjyt \
|
||||
Ethereum: 0xe7Bf8f78Ffc92F4b50f879AEc7Fd41A463DC9c28 \
|
||||
Litecoin: LaqWXCsNBinE5ho2cUJiDZ4MFmPH6QYZ4s \
|
||||
Wownero: Wo4NPaJdM3yETa2JXfjJbgKbD4BHXSEsk4Vj6F5gbcb99yaG7fAgZQGLp2EwvNDqE4QKWm63mtWfvfkbbX8dipeR31zPxDbsd \
|
||||
Tron: TMbMarrcsiVVgB5CRMqhShcGHMXYozpocg
|
||||
|
||||
### Donating to the Nitter project
|
||||
|
||||
Liberapay: https://liberapay.com/zedeus \
|
||||
Patreon: https://patreon.com/nitter \
|
||||
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55 \
|
||||
@@ -53,4 +77,4 @@ ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faq
|
||||
|
||||
## Contact
|
||||
|
||||
Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org).
|
||||
Feel free to join Nitter [Matrix channel](https://matrix.to/#/#nitter:matrix.org).
|
||||
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 957 KiB After Width: | Height: | Size: 790 KiB |
131
src/api.nim
131
src/api.nim
@@ -1,58 +1,101 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
|
||||
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||
import packedjson
|
||||
import types, query, formatters, consts, apiutils, parser
|
||||
import experimental/parser as newParser
|
||||
|
||||
# Helper to generate params object for GraphQL requests
|
||||
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
|
||||
result.add ("variables", variables)
|
||||
result.add ("features", gqlFeatures)
|
||||
if fieldToggles.len > 0:
|
||||
result.add ("fieldToggles", fieldToggles)
|
||||
|
||||
proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl =
|
||||
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
|
||||
|
||||
proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
|
||||
let url = apiUrl(endpoint, variables, fieldToggles)
|
||||
return ApiReq(cookie: url, oauth: url)
|
||||
|
||||
proc mediaUrl(id: string; cursor: string): ApiReq =
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]),
|
||||
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
|
||||
)
|
||||
|
||||
proc userTweetsUrl(id: string; cursor: string): ApiReq =
|
||||
result = ApiReq(
|
||||
# cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
|
||||
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
|
||||
)
|
||||
# might change this in the future pending testing
|
||||
result.cookie = result.oauth
|
||||
|
||||
proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
|
||||
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
|
||||
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
|
||||
)
|
||||
|
||||
proc tweetDetailUrl(id: string; cursor: string): ApiReq =
|
||||
let cookieVars = tweetDetailVars % [id, cursor]
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
|
||||
oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
|
||||
)
|
||||
|
||||
proc userUrl(username: string): ApiReq =
|
||||
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
|
||||
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
|
||||
)
|
||||
|
||||
proc getGraphUser*(username: string): Future[User] {.async.} =
|
||||
if username.len == 0: return
|
||||
let
|
||||
variables = """{"screen_name": "$1"}""" % username
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetchRaw(graphUser ? params, Api.userScreenName)
|
||||
let js = await fetchRaw(userUrl(username))
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||
if id.len == 0 or id.any(c => not c.isDigit): return
|
||||
let
|
||||
variables = """{"rest_id": "$1"}""" % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetchRaw(graphUserById ? params, Api.userRestId)
|
||||
url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
|
||||
js = await fetchRaw(url)
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = userTweetsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
(url, apiId) = case kind
|
||||
of TimelineKind.tweets: (graphUserTweets, Api.userTweets)
|
||||
of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies)
|
||||
of TimelineKind.media: (graphUserMedia, Api.userMedia)
|
||||
js = await fetch(url ? params, apiId)
|
||||
result = parseGraphTimeline(js, "user", after)
|
||||
url = case kind
|
||||
of TimelineKind.tweets: userTweetsUrl(id, cursor)
|
||||
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
|
||||
of TimelineKind.media: mediaUrl(id, cursor)
|
||||
js = await fetch(url)
|
||||
result = parseGraphTimeline(js, after)
|
||||
|
||||
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = listTweetsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphListTweets ? params, Api.listTweets)
|
||||
result = parseGraphTimeline(js, "list", after).tweets
|
||||
url = apiReq(graphListTweets, restIdVars % [id, cursor])
|
||||
js = await fetch(url)
|
||||
result = parseGraphTimeline(js, after).tweets
|
||||
|
||||
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
||||
let
|
||||
variables = %*{"screenName": name, "listSlug": list}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug))
|
||||
url = apiReq(graphListBySlug, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphList(js)
|
||||
|
||||
proc getGraphList*(id: string): Future[List] {.async.} =
|
||||
let
|
||||
variables = """{"listId": "$1"}""" % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
result = parseGraphList(await fetch(graphListById ? params, Api.list))
|
||||
let
|
||||
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
|
||||
js = await fetch(url)
|
||||
result = parseGraphList(js)
|
||||
|
||||
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
||||
if list.id.len == 0: return
|
||||
@@ -66,24 +109,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
|
||||
let
|
||||
url = apiReq(graphListMembers, $variables)
|
||||
js = await fetchRaw(url)
|
||||
result = parseGraphListMembers(js, after)
|
||||
|
||||
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
variables = """{"rest_id": "$1"}""" % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweetResult ? params, Api.tweetResult)
|
||||
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
|
||||
js = await fetch(url)
|
||||
result = parseGraphTweetResult(js)
|
||||
|
||||
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = tweetVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweet ? params, Api.tweetDetail)
|
||||
js = await fetch(tweetDetailUrl(id, cursor))
|
||||
result = parseGraphConversation(js, id)
|
||||
|
||||
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
||||
@@ -103,6 +145,7 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
||||
var
|
||||
variables = %*{
|
||||
"rawQuery": q,
|
||||
"query_source": "typedQuery",
|
||||
"count": 20,
|
||||
"product": "Latest",
|
||||
"withDownvotePerspective": false,
|
||||
@@ -111,8 +154,10 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after)
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[Tweets](js, after)
|
||||
result.query = query
|
||||
|
||||
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
||||
@@ -122,6 +167,7 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
|
||||
var
|
||||
variables = %*{
|
||||
"rawQuery": query.text,
|
||||
"query_source": "typedQuery",
|
||||
"count": 20,
|
||||
"product": "People",
|
||||
"withDownvotePerspective": false,
|
||||
@@ -132,17 +178,16 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
|
||||
variables["cursor"] = % after
|
||||
result.beginning = false
|
||||
|
||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphSearch[User](await fetch(url, Api.search), after)
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[User](js, after)
|
||||
result.query = query
|
||||
|
||||
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
variables = userTweetsVariables % [id, ""]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
url = graphUserMedia ? params
|
||||
result = parseGraphPhotoRail(await fetch(url, Api.userMedia))
|
||||
let js = await fetch(mediaUrl(id, ""))
|
||||
result = parseGraphPhotoRail(js)
|
||||
|
||||
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
||||
let client = newAsyncHttpClient(maxRedirects=0)
|
||||
|
||||
103
src/apiutils.nim
103
src/apiutils.nim
@@ -1,16 +1,30 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
|
||||
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables, logging
|
||||
import jsony, packedjson, zippy, oauth1
|
||||
import types, auth, consts, parserutils, http_pool
|
||||
import types, auth, consts, parserutils, http_pool, tid
|
||||
import experimental/types/common
|
||||
|
||||
const
|
||||
rlRemaining = "x-rate-limit-remaining"
|
||||
rlReset = "x-rate-limit-reset"
|
||||
rlLimit = "x-rate-limit-limit"
|
||||
errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
|
||||
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
|
||||
|
||||
var pool: HttpPool
|
||||
var
|
||||
pool: HttpPool
|
||||
disableTid: bool
|
||||
|
||||
proc setDisableTid*(disable: bool) =
|
||||
disableTid = disable
|
||||
|
||||
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
|
||||
case sessionKind
|
||||
of oauth:
|
||||
let o = req.oauth
|
||||
parseUri("https://api.x.com/graphql") / o.endpoint ? o.params
|
||||
of cookie:
|
||||
let c = req.cookie
|
||||
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
|
||||
|
||||
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
|
||||
let
|
||||
@@ -32,15 +46,15 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
|
||||
proc getCookieHeader(authToken, ct0: string): string =
|
||||
"auth_token=" & authToken & "; ct0=" & ct0
|
||||
|
||||
proc genHeaders*(session: Session, url: string): HttpHeaders =
|
||||
proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
|
||||
result = newHttpHeaders({
|
||||
"connection": "keep-alive",
|
||||
"content-type": "application/json",
|
||||
"x-twitter-active-user": "yes",
|
||||
"x-twitter-client-language": "en",
|
||||
"authority": "api.x.com",
|
||||
"origin": "https://x.com",
|
||||
"accept-encoding": "gzip",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"accept-language": "en-US,en;q=0.5",
|
||||
"accept": "*/*",
|
||||
"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"
|
||||
@@ -48,31 +62,37 @@ proc genHeaders*(session: Session, url: string): HttpHeaders =
|
||||
|
||||
case session.kind
|
||||
of SessionKind.oauth:
|
||||
result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret)
|
||||
result["authority"] = "api.x.com"
|
||||
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
|
||||
of SessionKind.cookie:
|
||||
result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
|
||||
result["x-twitter-auth-type"] = "OAuth2Session"
|
||||
result["x-csrf-token"] = session.ct0
|
||||
result["cookie"] = getCookieHeader(session.authToken, session.ct0)
|
||||
if disableTid:
|
||||
result["authorization"] = bearerToken2
|
||||
else:
|
||||
result["authorization"] = bearerToken
|
||||
result["x-client-transaction-id"] = await genTid(url.path)
|
||||
|
||||
proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
|
||||
result = await getSession(req)
|
||||
case result.kind
|
||||
of SessionKind.oauth:
|
||||
if result.oauthToken.len == 0:
|
||||
warn "[sessions] Empty oauth token, session: ", result.pretty
|
||||
raise rateLimitError()
|
||||
of SessionKind.cookie:
|
||||
if result.authToken.len == 0 or result.ct0.len == 0:
|
||||
warn "[sessions] Empty cookie credentials, session: ", result.pretty
|
||||
raise rateLimitError()
|
||||
|
||||
template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
once:
|
||||
pool = HttpPool()
|
||||
|
||||
var session = await getSession(api)
|
||||
case session.kind
|
||||
of SessionKind.oauth:
|
||||
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:
|
||||
var resp: AsyncResponse
|
||||
pool.use(genHeaders(session, $url)):
|
||||
pool.use(await genHeaders(session, url)):
|
||||
template getContent =
|
||||
resp = await c.get($url)
|
||||
result = await resp.body
|
||||
@@ -88,7 +108,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
remaining = parseInt(resp.headers[rlRemaining])
|
||||
reset = parseInt(resp.headers[rlReset])
|
||||
limit = parseInt(resp.headers[rlLimit])
|
||||
session.setRateLimit(api, remaining, reset, limit)
|
||||
session.setRateLimit(req, remaining, reset, limit)
|
||||
|
||||
if result.len > 0:
|
||||
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
||||
@@ -97,24 +117,22 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
if result.startsWith("{\"errors"):
|
||||
let errors = result.fromJson(Errors)
|
||||
if errors notin errorsToSkip:
|
||||
echo "Fetch error, API: ", api, ", errors: ", errors
|
||||
error "Fetch error, API: ", url.path, ", 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)
|
||||
setLimited(session, req)
|
||||
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
|
||||
warn "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
|
||||
raise rateLimitError()
|
||||
|
||||
fetchBody
|
||||
|
||||
if resp.status == $Http400:
|
||||
echo "ERROR 400, ", api, ": ", result
|
||||
error "ERROR 400, ", url.path, ": ", result
|
||||
raise newException(InternalError, $url)
|
||||
except InternalError as e:
|
||||
raise e
|
||||
@@ -123,8 +141,11 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
||||
except OSError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
let id = if session.isNil: "null" else: $session.id
|
||||
echo "error: ", e.name, ", msg: ", e.msg, ", sessionId: ", id, ", url: ", url
|
||||
let s = session.pretty
|
||||
var safeUrl = $url
|
||||
if safeUrl.len > 100:
|
||||
safeUrl = safeUrl[0 .. 100] & "..."
|
||||
error "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", safeUrl
|
||||
raise rateLimitError()
|
||||
finally:
|
||||
release(session)
|
||||
@@ -133,29 +154,37 @@ template retry(bod) =
|
||||
try:
|
||||
bod
|
||||
except RateLimitError:
|
||||
echo "[sessions] Rate limited, retrying ", api, " request..."
|
||||
info "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..."
|
||||
bod
|
||||
|
||||
proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
|
||||
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
|
||||
retry:
|
||||
var body: string
|
||||
var
|
||||
body: string
|
||||
session = await getAndValidateSession(req)
|
||||
|
||||
let url = req.toUrl(session.kind)
|
||||
|
||||
fetchImpl body:
|
||||
if body.startsWith('{') or body.startsWith('['):
|
||||
result = parseJson(body)
|
||||
else:
|
||||
echo resp.status, ": ", body, " --- url: ", url
|
||||
warn resp.status, ": ", body, " --- url: ", url
|
||||
result = newJNull()
|
||||
|
||||
let error = result.getError
|
||||
if error != null and error notin errorsToSkip:
|
||||
echo "Fetch error, API: ", api, ", error: ", error
|
||||
error "Fetch error, API: ", url.path, ", error: ", error
|
||||
if error in {expiredToken, badToken, locked}:
|
||||
invalidate(session)
|
||||
raise rateLimitError()
|
||||
|
||||
proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} =
|
||||
proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
|
||||
retry:
|
||||
var session = await getAndValidateSession(req)
|
||||
let url = req.toUrl(session.kind)
|
||||
|
||||
fetchImpl result:
|
||||
if not (result.startsWith('{') or result.startsWith('[')):
|
||||
echo resp.status, ": ", result, " --- url: ", url
|
||||
warn resp.status, ": ", result, " --- url: ", url
|
||||
result.setLen(0)
|
||||
|
||||
84
src/auth.nim
84
src/auth.nim
@@ -1,33 +1,41 @@
|
||||
#SPDX-License-Identifier: AGPL-3.0-only
|
||||
import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os]
|
||||
import types
|
||||
import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os, logging]
|
||||
import types, consts
|
||||
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 logSession(args: varargs[string, `$`]) =
|
||||
var s = "[sessions] "
|
||||
for arg in args:
|
||||
s.add arg
|
||||
info s
|
||||
|
||||
proc endpoint(req: ApiReq; session: Session): string =
|
||||
case session.kind
|
||||
of oauth: req.oauth.endpoint
|
||||
of cookie: req.cookie.endpoint
|
||||
|
||||
proc pretty*(session: Session): string =
|
||||
if session.isNil:
|
||||
return "<null>"
|
||||
|
||||
if session.id > 0 and session.username.len > 0:
|
||||
result = $session.id & " (" & session.username & ")"
|
||||
elif session.username.len > 0:
|
||||
result = session.username
|
||||
elif session.id > 0:
|
||||
result = $session.id
|
||||
else:
|
||||
result = "<unknown>"
|
||||
result = $session.kind & " " & result
|
||||
|
||||
proc snowflakeToEpoch(flake: int64): int64 =
|
||||
int64(((flake shr 22) + 1288834974657) div 1000)
|
||||
@@ -57,8 +65,7 @@ proc getSessionPoolHealth*(): JsonNode =
|
||||
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
|
||||
reqs = apiStatus.limit - apiStatus.remaining
|
||||
|
||||
# no requests made with this session and endpoint since the limit reset
|
||||
if apiStatus.reset < now:
|
||||
@@ -123,14 +130,15 @@ proc rateLimitError*(): ref RateLimitError =
|
||||
proc noSessionsError*(): ref NoSessionsError =
|
||||
newException(NoSessionsError, "no sessions available")
|
||||
|
||||
proc isLimited(session: Session; api: Api): bool =
|
||||
proc isLimited(session: Session; req: ApiReq): bool =
|
||||
if session.isNil:
|
||||
return true
|
||||
|
||||
if session.limited and api != Api.userTweets:
|
||||
let api = req.endpoint(session)
|
||||
if session.limited and api != graphUserTweetsV2:
|
||||
if (epochTime().int - session.limitedAt) > hourInSeconds:
|
||||
session.limited = false
|
||||
log "resetting limit: ", session.id
|
||||
logSession "resetting limit: ", session.pretty
|
||||
return false
|
||||
else:
|
||||
return true
|
||||
@@ -141,12 +149,12 @@ proc isLimited(session: Session; api: Api): bool =
|
||||
else:
|
||||
return false
|
||||
|
||||
proc isReady(session: Session; api: Api): bool =
|
||||
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api))
|
||||
proc isReady(session: Session; req: ApiReq): bool =
|
||||
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
|
||||
|
||||
proc invalidate*(session: var Session) =
|
||||
if session.isNil: return
|
||||
log "invalidating: ", session.id
|
||||
logSession "invalidating: ", session.pretty
|
||||
|
||||
# TODO: This isn't sufficient, but it works for now
|
||||
let idx = sessionPool.find(session)
|
||||
@@ -157,24 +165,26 @@ proc release*(session: Session) =
|
||||
if session.isNil: return
|
||||
dec session.pending
|
||||
|
||||
proc getSession*(api: Api): Future[Session] {.async.} =
|
||||
proc getSession*(req: ApiReq): Future[Session] {.async.} =
|
||||
for i in 0 ..< sessionPool.len:
|
||||
if result.isReady(api): break
|
||||
if result.isReady(req): break
|
||||
result = sessionPool.sample()
|
||||
|
||||
if not result.isNil and result.isReady(api):
|
||||
if not result.isNil and result.isReady(req):
|
||||
inc result.pending
|
||||
else:
|
||||
log "no sessions available for API: ", api
|
||||
logSession "no sessions available for API: ", req.cookie.endpoint
|
||||
raise noSessionsError()
|
||||
|
||||
proc setLimited*(session: Session; api: Api) =
|
||||
proc setLimited*(session: Session; req: ApiReq) =
|
||||
let api = req.endpoint(session)
|
||||
session.limited = true
|
||||
session.limitedAt = epochTime().int
|
||||
log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", id: ", session.id
|
||||
logSession "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty
|
||||
|
||||
proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) =
|
||||
proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) =
|
||||
# avoid undefined behavior in race conditions
|
||||
let api = req.endpoint(session)
|
||||
if api in session.apis:
|
||||
let rateLimit = session.apis[api]
|
||||
if rateLimit.reset >= reset and rateLimit.remaining < remaining:
|
||||
@@ -189,15 +199,15 @@ 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"
|
||||
fatal ".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."
|
||||
fatal path, " not found. This file is required to authenticate API requests."
|
||||
quit 1
|
||||
|
||||
log "parsing JSONL account sessions file: ", path
|
||||
logSession "parsing JSONL account sessions file: ", path
|
||||
for line in path.lines:
|
||||
sessionPool.add parseSession(line)
|
||||
|
||||
log "successfully added ", sessionPool.len, " valid account sessions"
|
||||
logSession "successfully added ", sessionPool.len, " valid account sessions"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import parsecfg except Config
|
||||
import types, strutils
|
||||
import types, strutils, sequtils
|
||||
|
||||
proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
|
||||
let val = config.getSectionValue(section, key)
|
||||
@@ -9,6 +9,7 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
|
||||
when T is int: parseInt(val)
|
||||
elif T is bool: parseBool(val)
|
||||
elif T is string: val
|
||||
elif T is seq[string]: val.split(',').mapIt(it.strip())
|
||||
|
||||
proc getConfig*(path: string): (Config, parseCfg.Config) =
|
||||
var cfg = loadConfig(path)
|
||||
@@ -39,8 +40,11 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
||||
minTokens: cfg.get("Config", "tokenCount", 10),
|
||||
enableRss: cfg.get("Config", "enableRSS", true),
|
||||
enableDebug: cfg.get("Config", "enableDebug", false),
|
||||
logLevel: cfg.get("Config", "logLevel", ""),
|
||||
proxy: cfg.get("Config", "proxy", ""),
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", "")
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||
defaultFollowedAccounts: cfg.get("Config", "defaultFollowedAccounts", @["eff", "fsf"]),
|
||||
disableTid: cfg.get("Config", "disableTid", false)
|
||||
)
|
||||
|
||||
return (conf, cfg)
|
||||
|
||||
158
src/consts.nim
158
src/consts.nim
@@ -1,52 +1,62 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import uri, strutils
|
||||
import strutils
|
||||
|
||||
const
|
||||
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
||||
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
||||
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
|
||||
|
||||
gql = parseUri("https://api.x.com") / "graphql"
|
||||
|
||||
graphUser* = gql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
|
||||
graphUserById* = gql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
|
||||
graphUserTweets* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndReplies* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserMedia* = gql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2"
|
||||
graphTweet* = gql / "Vorskcd2tZ-tc4Gx3zbk4Q/ConversationTimelineV2"
|
||||
graphTweetResult* = gql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
|
||||
graphSearchTimeline* = gql / "KI9jCXUx3Ymt-hDKLOZb9Q/SearchTimeline"
|
||||
graphListById* = gql / "oygmAig8kjn0pKsx_bUadQ/ListByRestId"
|
||||
graphListBySlug* = gql / "88GTz-IPPWLn1EiU8XoNVg/ListBySlug"
|
||||
graphListMembers* = gql / "kSmxeqEeelqdHSR7jMnb_w/ListMembers"
|
||||
graphListTweets* = gql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
|
||||
graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
|
||||
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
|
||||
graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
|
||||
graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
|
||||
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
|
||||
graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
|
||||
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
|
||||
graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
|
||||
graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
|
||||
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
|
||||
graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
|
||||
graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
|
||||
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
||||
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
||||
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
||||
|
||||
gqlFeatures* = """{
|
||||
"android_ad_formats_media_component_render_overlay_enabled": false,
|
||||
"android_graphql_skip_api_media_color_palette": false,
|
||||
"android_professional_link_spotlight_display_enabled": false,
|
||||
"blue_business_profile_image_shape_enabled": false,
|
||||
"commerce_android_shop_module_enabled": false,
|
||||
"creator_subscriptions_subscription_count_enabled": false,
|
||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||
"freedom_of_speech_not_reach_fetch_enabled": false,
|
||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
|
||||
"freedom_of_speech_not_reach_fetch_enabled": true,
|
||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
|
||||
"hidden_profile_likes_enabled": false,
|
||||
"highlights_tweets_tab_ui_enabled": false,
|
||||
"interactive_text_enabled": false,
|
||||
"longform_notetweets_consumption_enabled": true,
|
||||
"longform_notetweets_inline_media_enabled": false,
|
||||
"longform_notetweets_inline_media_enabled": true,
|
||||
"longform_notetweets_rich_text_read_enabled": true,
|
||||
"longform_notetweets_richtext_consumption_enabled": true,
|
||||
"longform_notetweets_rich_text_read_enabled": false,
|
||||
"responsive_web_edit_tweet_api_enabled": false,
|
||||
"mobile_app_spotlight_module_enabled": false,
|
||||
"responsive_web_edit_tweet_api_enabled": true,
|
||||
"responsive_web_enhance_cards_enabled": false,
|
||||
"responsive_web_graphql_exclude_directive_enabled": true,
|
||||
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
||||
"responsive_web_graphql_timeline_navigation_enabled": false,
|
||||
"responsive_web_graphql_timeline_navigation_enabled": true,
|
||||
"responsive_web_media_download_video_enabled": false,
|
||||
"responsive_web_text_conversations_enabled": false,
|
||||
"responsive_web_twitter_article_tweet_consumption_enabled": false,
|
||||
"responsive_web_twitter_article_tweet_consumption_enabled": true,
|
||||
"unified_cards_destination_url_params_enabled": false,
|
||||
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
||||
"rweb_lists_timeline_redesign_enabled": true,
|
||||
"spaces_2022_h2_clipping": true,
|
||||
"spaces_2022_h2_spaces_communities": true,
|
||||
"standardized_nudges_misinfo": false,
|
||||
"standardized_nudges_misinfo": true,
|
||||
"subscriptions_verification_info_enabled": true,
|
||||
"subscriptions_verification_info_reason_enabled": true,
|
||||
"subscriptions_verification_info_verified_since_enabled": true,
|
||||
@@ -55,32 +65,48 @@ const
|
||||
"super_follow_tweet_api_enabled": false,
|
||||
"super_follow_user_api_enabled": false,
|
||||
"tweet_awards_web_tipping_enabled": false,
|
||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
|
||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
|
||||
"tweetypie_unmention_optimization_enabled": false,
|
||||
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
|
||||
"verified_phone_label_enabled": false,
|
||||
"vibe_api_enabled": false,
|
||||
"view_counts_everywhere_api_enabled": false,
|
||||
"view_counts_everywhere_api_enabled": true,
|
||||
"premium_content_api_read_enabled": false,
|
||||
"communities_web_enable_tweet_community_results_fetch": false,
|
||||
"responsive_web_jetfuel_frame": false,
|
||||
"communities_web_enable_tweet_community_results_fetch": true,
|
||||
"responsive_web_jetfuel_frame": true,
|
||||
"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,
|
||||
"responsive_web_grok_image_annotation_enabled": true,
|
||||
"responsive_web_grok_imagine_annotation_enabled": true,
|
||||
"rweb_tipjar_consumption_enabled": true,
|
||||
"profile_label_improvements_pcf_label_in_post_enabled": true,
|
||||
"creator_subscriptions_quote_tweet_preview_enabled": false,
|
||||
"c9s_tweet_anatomy_moderator_badge_enabled": false,
|
||||
"responsive_web_grok_analyze_post_followups_enabled": false,
|
||||
"c9s_tweet_anatomy_moderator_badge_enabled": true,
|
||||
"responsive_web_grok_analyze_post_followups_enabled": true,
|
||||
"rweb_video_timestamps_enabled": false,
|
||||
"responsive_web_grok_share_attachment_enabled": false,
|
||||
"articles_preview_enabled": false,
|
||||
"responsive_web_grok_share_attachment_enabled": true,
|
||||
"articles_preview_enabled": true,
|
||||
"immersive_video_status_linkable_timestamps": false,
|
||||
"articles_api_enabled": false,
|
||||
"responsive_web_grok_analysis_button_from_backend": false
|
||||
"responsive_web_grok_analysis_button_from_backend": true,
|
||||
"rweb_video_screen_enabled": false,
|
||||
"payments_enabled": false,
|
||||
"responsive_web_profile_redirect_enabled": false,
|
||||
"responsive_web_grok_show_grok_translated_post": false,
|
||||
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
|
||||
"profile_label_improvements_pcf_label_in_profile_enabled": false,
|
||||
"grok_android_analyze_trend_fetch_enabled": false,
|
||||
"grok_translations_community_note_auto_translation_is_enabled": false,
|
||||
"grok_translations_post_auto_translation_is_enabled": false,
|
||||
"grok_translations_community_note_translation_is_enabled": false,
|
||||
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false,
|
||||
"subscriptions_feature_can_gift_premium": false,
|
||||
"responsive_web_twitter_article_notes_tab_enabled": false,
|
||||
"subscriptions_verification_info_is_identity_verified_enabled": false,
|
||||
"hidden_profile_subscriptions_enabled": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetVariables* = """{
|
||||
"focalTweetId": "$1",
|
||||
tweetVars* = """{
|
||||
"postId": "$1",
|
||||
$2
|
||||
"includeHasBirdwatchNotes": false,
|
||||
"includePromotedContent": false,
|
||||
@@ -89,24 +115,50 @@ const
|
||||
"withV2Timeline": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
# oldUserTweetsVariables* = """{
|
||||
# "userId": "$1", $2
|
||||
# "count": 20,
|
||||
# "includePromotedContent": false,
|
||||
# "withDownvotePerspective": false,
|
||||
# "withReactionsMetadata": false,
|
||||
# "withReactionsPerspective": false,
|
||||
# "withVoice": false,
|
||||
# "withV2Timeline": true
|
||||
# }
|
||||
# """
|
||||
tweetDetailVars* = """{
|
||||
"focalTweetId": "$1",
|
||||
$2
|
||||
"referrer": "profile",
|
||||
"with_rux_injections": false,
|
||||
"rankingMode": "Relevance",
|
||||
"includePromotedContent": true,
|
||||
"withCommunity": true,
|
||||
"withQuickPromoteEligibilityTweetFields": true,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userTweetsVariables* = """{
|
||||
restIdVars* = """{
|
||||
"rest_id": "$1", $2
|
||||
"count": 20
|
||||
}"""
|
||||
|
||||
listTweetsVariables* = """{
|
||||
"rest_id": "$1", $2
|
||||
"count": 20
|
||||
}"""
|
||||
userMediaVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withClientEventToken": false,
|
||||
"withBirdwatchNotes": false,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userTweetsVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withQuickPromoteEligibilityTweetFields": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userTweetsAndRepliesVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withCommunity": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
fieldToggles* = """{"withArticlePlainText":false,"withViewCounts":true}"""
|
||||
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
|
||||
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
|
||||
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
import options
|
||||
import options, strutils
|
||||
import jsony
|
||||
import user, ../types/[graphuser, graphlistmembers]
|
||||
import user, utils, ../types/[graphuser, graphlistmembers]
|
||||
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
||||
|
||||
proc parseUserResult*(userResult: UserResult): User =
|
||||
result = userResult.legacy
|
||||
|
||||
if result.verifiedType == none and userResult.isBlueVerified:
|
||||
result.verifiedType = blue
|
||||
|
||||
if result.username.len == 0 and userResult.core.screenName.len > 0:
|
||||
result.id = userResult.restId
|
||||
result.username = userResult.core.screenName
|
||||
result.fullname = userResult.core.name
|
||||
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
|
||||
|
||||
if userResult.privacy.isSome:
|
||||
result.protected = userResult.privacy.get.protected
|
||||
|
||||
if userResult.location.isSome:
|
||||
result.location = userResult.location.get.location
|
||||
|
||||
if userResult.core.createdAt.len > 0:
|
||||
result.joinDate = parseTwitterDate(userResult.core.createdAt)
|
||||
|
||||
if userResult.verification.isSome:
|
||||
let v = userResult.verification.get
|
||||
if v.verifiedType != VerifiedType.none:
|
||||
result.verifiedType = v.verifiedType
|
||||
|
||||
if userResult.profileBio.isSome and result.bio.len == 0:
|
||||
result.bio = userResult.profileBio.get.description
|
||||
|
||||
proc parseGraphUser*(json: string): User =
|
||||
if json.len == 0 or json[0] != '{':
|
||||
return
|
||||
|
||||
let raw = json.fromJson(GraphUser)
|
||||
let
|
||||
raw = json.fromJson(GraphUser)
|
||||
userResult =
|
||||
if raw.data.userResult.isSome: raw.data.userResult.get.result
|
||||
elif raw.data.user.isSome: raw.data.user.get.result
|
||||
else: UserResult()
|
||||
|
||||
if raw.data.userResult.result.unavailableReason.get("") == "Suspended":
|
||||
if userResult.unavailableReason.get("") == "Suspended" or
|
||||
userResult.reason.get("") == "Suspended":
|
||||
return User(suspended: true)
|
||||
|
||||
result = raw.data.userResult.result.legacy
|
||||
result.id = raw.data.userResult.result.restId
|
||||
if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified:
|
||||
result.verifiedType = blue
|
||||
result = parseUserResult(userResult)
|
||||
|
||||
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||
result = Result[User](
|
||||
@@ -31,7 +63,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||
of TimelineTimelineItem:
|
||||
let userResult = entry.content.itemContent.userResults.result
|
||||
if userResult.restId.len > 0:
|
||||
result.content.add userResult.legacy
|
||||
result.content.add parseUserResult(userResult)
|
||||
of TimelineTimelineCursor:
|
||||
if entry.content.cursorType == "Bottom":
|
||||
result.bottom = entry.content.value
|
||||
|
||||
@@ -13,6 +13,7 @@ proc parseSession*(raw: string): Session =
|
||||
result = Session(
|
||||
kind: SessionKind.oauth,
|
||||
id: parseBiggestInt(id),
|
||||
username: session.username,
|
||||
oauthToken: session.oauthToken,
|
||||
oauthSecret: session.oauthTokenSecret
|
||||
)
|
||||
@@ -21,6 +22,7 @@ proc parseSession*(raw: string): Session =
|
||||
result = Session(
|
||||
kind: SessionKind.cookie,
|
||||
id: id,
|
||||
username: session.username,
|
||||
authToken: session.authToken,
|
||||
ct0: session.ct0
|
||||
)
|
||||
|
||||
8
src/experimental/parser/tid.nim
Normal file
8
src/experimental/parser/tid.nim
Normal file
@@ -0,0 +1,8 @@
|
||||
import jsony
|
||||
import ../types/tid
|
||||
export TidPair
|
||||
|
||||
proc parseTidPairs*(raw: string): seq[TidPair] =
|
||||
result = raw.fromJson(seq[TidPair])
|
||||
if result.len == 0:
|
||||
raise newException(ValueError, "Parsing pairs failed: " & raw)
|
||||
@@ -1,6 +1,9 @@
|
||||
import std/[options, tables, strutils, strformat, sugar]
|
||||
|
||||
import std/[options, tables, strutils, strformat, sugar, logging]
|
||||
|
||||
import jsony
|
||||
import user, ../types/unifiedcard
|
||||
import ../../formatters
|
||||
from ../../types import Card, CardKind, Video
|
||||
from ../../utils import twimg, https
|
||||
|
||||
@@ -77,6 +80,18 @@ proc parseMedia(component: Component; card: UnifiedCard; result: var Card) =
|
||||
of model3d:
|
||||
result.title = "Unsupported 3D model ad"
|
||||
|
||||
proc parseGrokShare(data: ComponentData; card: UnifiedCard; result: var Card) =
|
||||
result.kind = summaryLarge
|
||||
|
||||
data.destination.parseDestination(card, result)
|
||||
result.dest = "Answer by Grok"
|
||||
|
||||
for msg in data.conversationPreview:
|
||||
if msg.sender == "USER":
|
||||
result.title = msg.message.shorten(70)
|
||||
elif msg.sender == "AGENT":
|
||||
result.text = msg.message.shorten(500)
|
||||
|
||||
proc parseUnifiedCard*(json: string): Card =
|
||||
let card = json.fromJson(UnifiedCard)
|
||||
|
||||
@@ -92,12 +107,14 @@ proc parseUnifiedCard*(json: string): Card =
|
||||
component.parseMedia(card, result)
|
||||
of buttonGroup:
|
||||
discard
|
||||
of grokShare:
|
||||
component.data.parseGrokShare(card, result)
|
||||
of ComponentType.jobDetails:
|
||||
component.data.parseJobDetails(card, result)
|
||||
of ComponentType.hidden:
|
||||
result.kind = CardKind.hidden
|
||||
of ComponentType.unknown:
|
||||
echo "ERROR: Unknown component type: ", json
|
||||
error "ERROR: Unknown component type: ", json
|
||||
|
||||
case component.kind
|
||||
of twitterListDetails:
|
||||
|
||||
@@ -58,11 +58,13 @@ proc toUser*(raw: RawUser): User =
|
||||
media: raw.mediaCount,
|
||||
verifiedType: raw.verifiedType,
|
||||
protected: raw.protected,
|
||||
joinDate: parseTwitterDate(raw.createdAt),
|
||||
banner: getBanner(raw),
|
||||
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
|
||||
)
|
||||
|
||||
if raw.createdAt.len > 0:
|
||||
result.joinDate = parseTwitterDate(raw.createdAt)
|
||||
|
||||
if raw.pinnedTweetIdsStr.len > 0:
|
||||
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
|
||||
|
||||
@@ -72,21 +74,3 @@ proc parseHook*(s: string; i: var int; v: var User) =
|
||||
var u: RawUser
|
||||
parseHook(s, i, u)
|
||||
v = toUser u
|
||||
|
||||
proc parseUser*(json: string; username=""): User =
|
||||
handleErrors:
|
||||
case error.code
|
||||
of suspended: return User(username: username, suspended: true)
|
||||
of userNotFound: return
|
||||
else: echo "[error - parseUser]: ", error
|
||||
|
||||
result = json.fromJson(User)
|
||||
|
||||
proc parseUsers*(json: string; after=""): Result[User] =
|
||||
result = Result[User](beginning: after.len == 0)
|
||||
|
||||
# starting with '{' means it's an error
|
||||
if json[0] == '[':
|
||||
let raw = json.fromJson(seq[RawUser])
|
||||
for user in raw:
|
||||
result.content.add user.toUser
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
import options
|
||||
from ../../types import User
|
||||
import options, strutils
|
||||
from ../../types import User, VerifiedType
|
||||
|
||||
type
|
||||
GraphUser* = object
|
||||
data*: tuple[userResult: UserData]
|
||||
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
|
||||
|
||||
UserData* = object
|
||||
result*: UserResult
|
||||
|
||||
UserResult = object
|
||||
UserCore* = object
|
||||
name*: string
|
||||
screenName*: string
|
||||
createdAt*: string
|
||||
|
||||
UserBio* = object
|
||||
description*: string
|
||||
|
||||
UserAvatar* = object
|
||||
imageUrl*: string
|
||||
|
||||
Verification* = object
|
||||
verifiedType*: VerifiedType
|
||||
|
||||
Location* = object
|
||||
location*: string
|
||||
|
||||
Privacy* = object
|
||||
protected*: bool
|
||||
|
||||
UserResult* = object
|
||||
legacy*: User
|
||||
restId*: string
|
||||
isBlueVerified*: bool
|
||||
core*: UserCore
|
||||
avatar*: UserAvatar
|
||||
unavailableReason*: Option[string]
|
||||
reason*: Option[string]
|
||||
privacy*: Option[Privacy]
|
||||
profileBio*: Option[UserBio]
|
||||
verification*: Option[Verification]
|
||||
location*: Option[Location]
|
||||
|
||||
proc enumHook*(s: string; v: var VerifiedType) =
|
||||
v = try:
|
||||
parseEnum[VerifiedType](s)
|
||||
except:
|
||||
VerifiedType.none
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
type
|
||||
RawSession* = object
|
||||
kind*: string
|
||||
username*: string
|
||||
id*: string
|
||||
username*: string
|
||||
oauthToken*: string
|
||||
oauthTokenSecret*: string
|
||||
authToken*: string
|
||||
|
||||
4
src/experimental/types/tid.nim
Normal file
4
src/experimental/types/tid.nim
Normal file
@@ -0,0 +1,4 @@
|
||||
type
|
||||
TidPair* = object
|
||||
animationKey*: string
|
||||
verification*: string
|
||||
@@ -1,23 +0,0 @@
|
||||
import std/tables
|
||||
from ../../types import User
|
||||
|
||||
type
|
||||
Search* = object
|
||||
globalObjects*: GlobalObjects
|
||||
timeline*: Timeline
|
||||
|
||||
GlobalObjects = object
|
||||
users*: Table[string, User]
|
||||
|
||||
Timeline = object
|
||||
instructions*: seq[Instructions]
|
||||
|
||||
Instructions = object
|
||||
addEntries*: tuple[entries: seq[Entry]]
|
||||
|
||||
Entry = object
|
||||
entryId*: string
|
||||
content*: tuple[operation: Operation]
|
||||
|
||||
Operation = object
|
||||
cursor*: tuple[value, cursorType: string]
|
||||
@@ -1,4 +1,6 @@
|
||||
import std/[options, tables, times]
|
||||
|
||||
import std/[options, tables, times, logging]
|
||||
|
||||
import jsony
|
||||
from ../../types import VideoType, VideoVariant, User
|
||||
|
||||
@@ -22,6 +24,7 @@ type
|
||||
communityDetails
|
||||
mediaWithDetailsHorizontal
|
||||
hidden
|
||||
grokShare
|
||||
unknown
|
||||
|
||||
Component* = object
|
||||
@@ -42,6 +45,7 @@ type
|
||||
topicDetail*: tuple[title: Text]
|
||||
profileUser*: User
|
||||
shortDescriptionText*: string
|
||||
conversationPreview*: seq[GrokConversation]
|
||||
|
||||
MediaItem* = object
|
||||
id*: string
|
||||
@@ -76,6 +80,10 @@ type
|
||||
title*: Text
|
||||
category*: Text
|
||||
|
||||
GrokConversation* = object
|
||||
message*: string
|
||||
sender*: string
|
||||
|
||||
TypeField = Component | Destination | MediaEntity | AppStoreData
|
||||
|
||||
converter fromText*(text: Text): string = string(text)
|
||||
@@ -96,21 +104,22 @@ proc enumHook*(s: string; v: var ComponentType) =
|
||||
of "community_details": communityDetails
|
||||
of "media_with_details_horizontal": mediaWithDetailsHorizontal
|
||||
of "commerce_drop_details": hidden
|
||||
else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown
|
||||
of "grok_share": grokShare
|
||||
else: error "ERROR: Unknown enum value (ComponentType): ", s; unknown
|
||||
|
||||
proc enumHook*(s: string; v: var AppType) =
|
||||
v = case s
|
||||
of "android_app": androidApp
|
||||
of "iphone_app": iPhoneApp
|
||||
of "ipad_app": iPadApp
|
||||
else: echo "ERROR: Unknown enum value (AppType): ", s; androidApp
|
||||
else: error "ERROR: Unknown enum value (AppType): ", s; androidApp
|
||||
|
||||
proc enumHook*(s: string; v: var MediaType) =
|
||||
v = case s
|
||||
of "video": video
|
||||
of "photo": photo
|
||||
of "model3d": model3d
|
||||
else: echo "ERROR: Unknown enum value (MediaType): ", s; photo
|
||||
else: error "ERROR: Unknown enum value (MediaType): ", s; photo
|
||||
|
||||
proc parseHook*(s: string; i: var int; v: var DateTime) =
|
||||
var str: string
|
||||
|
||||
@@ -6,13 +6,18 @@ import types, utils, query
|
||||
const
|
||||
cards = "cards.twitter.com/cards"
|
||||
tco = "https://t.co"
|
||||
twitter = parseUri("https://twitter.com")
|
||||
twitter = parseUri("https://x.com")
|
||||
|
||||
let
|
||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
||||
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>"""
|
||||
imgurRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(i\.)?imgur\.com"
|
||||
imgurLinkRegex = re"""<a href="https://(i\.)?imgur.com([^"]+)">(i\.)?imgur\.com(\S+)</a>"""
|
||||
fandomRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))([a-z0-9-]+)\.fandom\.com"
|
||||
fandomLinkRegex = re"""<a href="https://([a-z0-9-]+)\.fandom\.com([^"]+)">([a-z0-9-]+)\.fandom\.com(\S+)</a>"""
|
||||
soundcloudRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(on\.|www\.)?soundcloud\.com"
|
||||
|
||||
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
|
||||
|
||||
@@ -33,10 +38,13 @@ proc getUrlPrefix*(cfg: Config): string =
|
||||
if cfg.useHttps: https & cfg.hostname
|
||||
else: "http://" & cfg.hostname
|
||||
|
||||
proc shortLink*(text: string; length=28): string =
|
||||
result = text.replace(wwwRegex, "")
|
||||
proc shorten*(text: string; length=28): string =
|
||||
result = text
|
||||
if result.len > length:
|
||||
result = result[0 ..< length] & "…"
|
||||
|
||||
proc shortLink*(text: string; length=28): string =
|
||||
result = text.replace(wwwRegex, "").shorten(length)
|
||||
|
||||
proc stripHtml*(text: string; shorten=false): string =
|
||||
var html = parseHtml(text)
|
||||
@@ -56,25 +64,44 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
||||
result = body
|
||||
|
||||
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
||||
result = result.replace(ytRegex, prefs.replaceYouTube)
|
||||
let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
|
||||
result = result.replace(ytRegex, youtubeHost)
|
||||
|
||||
if prefs.replaceTwitter.len > 0:
|
||||
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
|
||||
if tco in result:
|
||||
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
|
||||
result = result.replace(tco, https & twitterHost & "/t.co")
|
||||
if "x.com" in result:
|
||||
result = result.replace(xRegex, prefs.replaceTwitter)
|
||||
result = result.replace(xRegex, twitterHost)
|
||||
result = result.replacef(xLinkRegex, a(
|
||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
||||
twitterHost & "$2", href = https & twitterHost & "$1"))
|
||||
if "twitter.com" in result:
|
||||
result = result.replace(cards, prefs.replaceTwitter & "/cards")
|
||||
result = result.replace(twRegex, prefs.replaceTwitter)
|
||||
result = result.replace(cards, twitterHost & "/cards")
|
||||
result = result.replace(twRegex, twitterHost)
|
||||
result = result.replacef(twLinkRegex, a(
|
||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
||||
if "imgur.com" in result:
|
||||
result = result.replace(imgurRegex, prefs.replaceImgur)
|
||||
result = result.replacef(imgurLinkRegex, a(
|
||||
prefs.replaceImgur & "$4", href = https & prefs.replaceImgur & "$2"))
|
||||
|
||||
if prefs.replaceFandom.len > 0 and "fandom.com" in result:
|
||||
result = result.replace(fandomRegex, prefs.replaceFandom & "/$1")
|
||||
result = result.replacef(fandomLinkRegex, a(
|
||||
prefs.replaceFandom & "/$1$2", href = https & prefs.replaceFandom & "/$1$2"))
|
||||
|
||||
if prefs.replaceSoundCloud.len > 0 and "soundcloud.com" in result:
|
||||
result = result.replace(soundcloudRegex, prefs.replaceSoundCloud)
|
||||
result = result.replacef(re"""<a href="https://on\.soundcloud\.com([^"]+)">on\.soundcloud\.com(\S+)</a>""", a(
|
||||
prefs.replaceSoundCloud & "/on$2", href = https & prefs.replaceSoundCloud & "/on$1"))
|
||||
result = result.replacef(re"""<a href="https://(www\.)?soundcloud\.com([^"]+)">(www\.)?soundcloud\.com(\S+)</a>""", a(
|
||||
prefs.replaceSoundCloud & "$4", href = https & prefs.replaceSoundCloud & "$2"))
|
||||
|
||||
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(rdRegex, prefs.replaceReddit)
|
||||
if prefs.replaceReddit in result and "/gallery/" in result:
|
||||
let redditHost = strip(prefs.replaceReddit, chars={'/'})
|
||||
result = result.replace(rdShortRegex, redditHost & "/comments/")
|
||||
result = result.replace(rdRegex, redditHost)
|
||||
if redditHost in result and "/gallery/" in result:
|
||||
result = result.replace("/gallery/", "/comments/")
|
||||
|
||||
if absolute.len > 0 and "href" in result:
|
||||
|
||||
102
src/nitter.nim
102
src/nitter.nim
@@ -1,20 +1,48 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, strformat, logging
|
||||
import asyncdispatch, strformat, logging, terminal, times, strutils
|
||||
from net import Port
|
||||
from htmlgen import a
|
||||
from os import getEnv
|
||||
|
||||
import jester
|
||||
|
||||
import types, config, prefs, formatters, redis_cache, http_pool, auth
|
||||
import views/[general, about]
|
||||
import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
|
||||
import views/[general, about, search, homepage]
|
||||
import karax/[vdom]
|
||||
import routes/[
|
||||
preferences, timeline, status, media, search, rss, list, debug,
|
||||
unsupported, embed, resolver, router_utils]
|
||||
unsupported, embed, resolver, router_utils, follow]
|
||||
|
||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||
|
||||
type ColoredLogger = ref object of Logger
|
||||
|
||||
method log(logger: ColoredLogger, level: Level, args: varargs[string, `$`]) =
|
||||
if level < logger.levelThreshold: return
|
||||
|
||||
let color = case level
|
||||
of lvlFatal, lvlError: fgRed
|
||||
of lvlWarn: fgYellow
|
||||
of lvlInfo: fgGreen
|
||||
of lvlDebug: fgCyan
|
||||
else: fgWhite
|
||||
|
||||
let levelStr = case level
|
||||
of lvlFatal: "fatal"
|
||||
of lvlError: "error"
|
||||
of lvlWarn: "warn"
|
||||
of lvlInfo: "info"
|
||||
of lvlDebug: "debug"
|
||||
else: "other"
|
||||
|
||||
let timeStr = format(now(), "HH:mm:ss")
|
||||
stdout.styledWrite(fgWhite, "[", timeStr, "] ", color, levelStr, fgWhite, ": ")
|
||||
for arg in args:
|
||||
stdout.write(arg)
|
||||
stdout.write("\n")
|
||||
stdout.flushFile()
|
||||
|
||||
let
|
||||
configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
||||
(cfg, fullCfg) = getConfig(configPath)
|
||||
@@ -23,13 +51,21 @@ let
|
||||
|
||||
initSessionPool(cfg, sessionsPath)
|
||||
|
||||
if not cfg.enableDebug:
|
||||
# Silence Jester's query warning
|
||||
addHandler(newConsoleLogger())
|
||||
setLogFilter(lvlError)
|
||||
addHandler(new(ColoredLogger))
|
||||
|
||||
stdout.write &"Starting Nitter at {getUrlPrefix(cfg)}\n"
|
||||
stdout.flushFile
|
||||
let level = case cfg.logLevel.toLowerAscii
|
||||
of "debug": lvlDebug
|
||||
of "info": lvlInfo
|
||||
of "warn": lvlWarn
|
||||
of "error": lvlError
|
||||
of "fatal": lvlFatal
|
||||
of "none": lvlNone
|
||||
else:
|
||||
if cfg.enableDebug: lvlDebug else: lvlInfo
|
||||
|
||||
setLogFilter(level)
|
||||
|
||||
info &"Starting Nitter at {getUrlPrefix(cfg)}"
|
||||
|
||||
updateDefaultPrefs(fullCfg)
|
||||
setCacheTimes(cfg)
|
||||
@@ -37,13 +73,14 @@ setHmacKey(cfg.hmacKey)
|
||||
setProxyEncoding(cfg.base64Media)
|
||||
setMaxHttpConns(cfg.httpMaxConns)
|
||||
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||
setDisableTid(cfg.disableTid)
|
||||
initAboutPage(cfg.staticDir)
|
||||
|
||||
waitFor initRedisPool(cfg)
|
||||
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
|
||||
stdout.flushFile
|
||||
info &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}"
|
||||
|
||||
createUnsupportedRouter(cfg)
|
||||
createFollowRouter(cfg)
|
||||
createResolverRouter(cfg)
|
||||
createPrefRouter(cfg)
|
||||
createTimelineRouter(cfg)
|
||||
@@ -63,7 +100,41 @@ settings:
|
||||
|
||||
routes:
|
||||
get "/":
|
||||
resp renderMain(renderSearch(), request, cfg, themePrefs())
|
||||
let prefs = cookiePrefs()
|
||||
|
||||
if prefs.following.len > 0 or cfg.defaultFollowedAccounts.len > 0:
|
||||
let
|
||||
cursor = getCursor()
|
||||
accounts = if prefs.following.len > 0: prefs.following else: cfg.defaultFollowedAccounts
|
||||
isDefault = prefs.following.len == 0
|
||||
currentPath = if @"f".len > 0: "/?f=" & @"f" else: "/"
|
||||
|
||||
var homepageQuery = initQuery(params(request))
|
||||
if @"f".len == 0:
|
||||
homepageQuery.kind = tweets
|
||||
|
||||
homepageQuery.fromUser = accounts
|
||||
|
||||
let
|
||||
timeline = await getGraphTweetSearch(homepageQuery, cursor)
|
||||
html = if isDefault:
|
||||
renderDefaultTimeline(timeline, prefs, getPath())
|
||||
else:
|
||||
renderHomepageTimeline(timeline, prefs, getPath())
|
||||
|
||||
var users: seq[User]
|
||||
for username in accounts:
|
||||
try:
|
||||
let user = await getCachedUser(username)
|
||||
users.add(user)
|
||||
except:
|
||||
continue
|
||||
|
||||
let homepageHtml = renderHomepage(users, html, prefs, currentPath)
|
||||
|
||||
resp renderMain(homepageHtml, request, cfg, prefs, "Following")
|
||||
else:
|
||||
resp renderMain(renderSearch(), request, cfg, themePrefs())
|
||||
|
||||
get "/about":
|
||||
resp renderMain(renderAbout(), request, cfg, themePrefs())
|
||||
@@ -83,13 +154,13 @@ routes:
|
||||
resp Http404, showError("Page not found", cfg)
|
||||
|
||||
error InternalError:
|
||||
echo error.exc.name, ": ", error.exc.msg
|
||||
error error.exc.name, ": ", error.exc.msg
|
||||
const link = a("open a GitHub issue", href = issuesUrl)
|
||||
resp Http500, showError(
|
||||
&"An error occurred, please {link} with the URL you tried to visit.", cfg)
|
||||
|
||||
error BadClientError:
|
||||
echo error.exc.name, ": ", error.exc.msg
|
||||
error error.exc.name, ": ", error.exc.msg
|
||||
resp Http500, showError("Network error occurred, please try again.", cfg)
|
||||
|
||||
error RateLimitError:
|
||||
@@ -111,5 +182,6 @@ routes:
|
||||
extend preferences, ""
|
||||
extend resolver, ""
|
||||
extend embed, ""
|
||||
extend follow, ""
|
||||
extend debug, ""
|
||||
extend unsupported, ""
|
||||
|
||||
396
src/parser.nim
396
src/parser.nim
@@ -1,10 +1,10 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, options, times, math
|
||||
import strutils, options, times, math, tables
|
||||
import packedjson, packedjson/deserialiser
|
||||
import types, parserutils, utils
|
||||
import experimental/parser/unifiedcard
|
||||
|
||||
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
|
||||
proc parseGraphTweet(js: JsonNode): Tweet
|
||||
|
||||
proc parseUser(js: JsonNode; id=""): User =
|
||||
if js.isNull: return
|
||||
@@ -21,22 +21,47 @@ proc parseUser(js: JsonNode; id=""): User =
|
||||
tweets: js{"statuses_count"}.getInt,
|
||||
likes: js{"favourites_count"}.getInt,
|
||||
media: js{"media_count"}.getInt,
|
||||
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
|
||||
protected: js{"protected"}.getBool,
|
||||
protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
|
||||
sensitive: "sensitive" in js{"profile_interstitial_type"}.getStr("") or js{"possibly_sensitive"}.getBool,
|
||||
joinDate: js{"created_at"}.getTime
|
||||
)
|
||||
|
||||
if js{"is_blue_verified"}.getBool(false):
|
||||
result.verifiedType = blue
|
||||
|
||||
with verifiedType, js{"verified_type"}:
|
||||
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||
|
||||
result.expandUserEntities(js)
|
||||
|
||||
proc parseGraphUser(js: JsonNode): User =
|
||||
var user = js{"user_result", "result"}
|
||||
if user.isNull:
|
||||
user = ? js{"user_results", "result"}
|
||||
|
||||
if user.isNull:
|
||||
if js{"core"}.notNull and js{"legacy"}.notNull:
|
||||
user = js
|
||||
else:
|
||||
return
|
||||
|
||||
result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
|
||||
|
||||
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
|
||||
if result.verifiedType == none and user{"is_blue_verified"}.getBool(false):
|
||||
result.verifiedType = blue
|
||||
|
||||
# fallback to support UserMedia/recent GraphQL updates
|
||||
if result.username.len == 0:
|
||||
result.username = user{"core", "screen_name"}.getStr
|
||||
result.fullname = user{"core", "name"}.getStr
|
||||
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
|
||||
|
||||
if user{"is_blue_verified"}.getBool(false):
|
||||
result.verifiedType = blue
|
||||
|
||||
with verifiedType, user{"verification", "verified_type"}:
|
||||
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
||||
|
||||
proc parseGraphList*(js: JsonNode): List =
|
||||
if js.isNull: return
|
||||
|
||||
@@ -74,16 +99,24 @@ proc parsePoll(js: JsonNode): Poll =
|
||||
result.leader = result.values.find(max(result.values))
|
||||
result.votes = result.values.sum
|
||||
|
||||
proc parseGif(js: JsonNode): Gif =
|
||||
result = Gif(
|
||||
url: js{"video_info", "variants"}[0]{"url"}.getImageStr,
|
||||
thumb: js{"media_url_https"}.getImageStr
|
||||
)
|
||||
proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] =
|
||||
result = @[]
|
||||
for v in variants:
|
||||
let
|
||||
url = v{"url"}.getStr
|
||||
contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4"))
|
||||
bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0))
|
||||
|
||||
result.add VideoVariant(
|
||||
contentType: contentType,
|
||||
bitrate: bitrate,
|
||||
url: url,
|
||||
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
||||
)
|
||||
|
||||
proc parseVideo(js: JsonNode): Video =
|
||||
result = Video(
|
||||
thumb: js{"media_url_https"}.getImageStr,
|
||||
views: getVideoViewCount(js),
|
||||
available: true,
|
||||
title: js{"ext_alt_text"}.getStr,
|
||||
durationMs: js{"video_info", "duration_millis"}.getInt
|
||||
@@ -100,17 +133,62 @@ proc parseVideo(js: JsonNode): Video =
|
||||
with description, js{"additional_media_info", "description"}:
|
||||
result.description = description.getStr
|
||||
|
||||
for v in js{"video_info", "variants"}:
|
||||
let
|
||||
contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary"))
|
||||
url = v{"url"}.getStr
|
||||
result.variants = parseVideoVariants(js{"video_info", "variants"})
|
||||
|
||||
result.variants.add VideoVariant(
|
||||
contentType: contentType,
|
||||
bitrate: v{"bitrate"}.getInt,
|
||||
url: url,
|
||||
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
||||
)
|
||||
proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
|
||||
with jsMedia, js{"extended_entities", "media"}:
|
||||
for m in jsMedia:
|
||||
case m.getTypeName:
|
||||
of "photo":
|
||||
result.photos.add m{"media_url_https"}.getImageStr
|
||||
of "video":
|
||||
result.video = some(parseVideo(m))
|
||||
with user, m{"additional_media_info", "source_user"}:
|
||||
if user{"id"}.getInt > 0:
|
||||
result.attribution = some(parseUser(user))
|
||||
else:
|
||||
result.attribution = some(parseGraphUser(user))
|
||||
of "animated_gif":
|
||||
result.gif = some Gif(
|
||||
url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
|
||||
thumb: m{"media_url_https"}.getImageStr
|
||||
)
|
||||
else: discard
|
||||
|
||||
with url, m{"url"}:
|
||||
if result.text.endsWith(url.getStr):
|
||||
result.text.removeSuffix(url.getStr)
|
||||
result.text = result.text.strip()
|
||||
|
||||
proc parseMediaEntities(js: JsonNode; result: var Tweet) =
|
||||
with mediaEntities, js{"media_entities"}:
|
||||
for mediaEntity in mediaEntities:
|
||||
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
|
||||
case mediaInfo.getTypeName
|
||||
of "ApiImage":
|
||||
result.photos.add mediaInfo{"original_img_url"}.getImageStr
|
||||
of "ApiVideo":
|
||||
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
|
||||
result.video = some Video(
|
||||
available: status.getStr == "Available",
|
||||
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
|
||||
durationMs: mediaInfo{"duration_millis"}.getInt,
|
||||
variants: parseVideoVariants(mediaInfo{"variants"})
|
||||
)
|
||||
of "ApiGif":
|
||||
result.gif = some Gif(
|
||||
url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
|
||||
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr
|
||||
)
|
||||
else: discard
|
||||
|
||||
# Remove media URLs from text
|
||||
with mediaList, js{"legacy", "entities", "media"}:
|
||||
for url in mediaList:
|
||||
let expandedUrl = url.getExpandedUrl
|
||||
if result.text.endsWith(expandedUrl):
|
||||
result.text.removeSuffix(expandedUrl)
|
||||
result.text = result.text.strip()
|
||||
|
||||
proc parsePromoVideo(js: JsonNode): Video =
|
||||
result = Video(
|
||||
@@ -190,7 +268,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||
|
||||
for u in ? urls:
|
||||
if u{"url"}.getStr == result.url:
|
||||
result.url = u{"expanded_url"}.getStr
|
||||
result.url = u.getExpandedUrl(result.url)
|
||||
break
|
||||
|
||||
if kind in {videoDirectMessage, imageDirectMessage}:
|
||||
@@ -202,20 +280,26 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||
|
||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
if js.isNull: return
|
||||
|
||||
let time =
|
||||
if js{"created_at"}.notNull: js{"created_at"}.getTime
|
||||
else: js{"created_at_ms"}.getTimeFromMs
|
||||
|
||||
result = Tweet(
|
||||
id: js{"id_str"}.getId,
|
||||
threadId: js{"conversation_id_str"}.getId,
|
||||
replyId: js{"in_reply_to_status_id_str"}.getId,
|
||||
text: js{"full_text"}.getStr,
|
||||
time: js{"created_at"}.getTime,
|
||||
time: time,
|
||||
hasThread: js{"self_thread"}.notNull,
|
||||
sensitive: js{"possibly_sensitive"}.getBool,
|
||||
available: true,
|
||||
user: User(id: js{"user_id_str"}.getStr),
|
||||
stats: TweetStats(
|
||||
replies: js{"reply_count"}.getInt,
|
||||
retweets: js{"retweet_count"}.getInt,
|
||||
likes: js{"favorite_count"}.getInt,
|
||||
quotes: js{"quote_count"}.getInt
|
||||
views: js{"views_count"}.getInt
|
||||
)
|
||||
)
|
||||
|
||||
@@ -240,6 +324,12 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
result.retweet = some parseGraphTweet(rt)
|
||||
return
|
||||
|
||||
with reposts, js{"repostedStatusResults"}:
|
||||
with rt, reposts{"result"}:
|
||||
if "legacy" in rt:
|
||||
result.retweet = some parseGraphTweet(rt)
|
||||
return
|
||||
|
||||
if jsCard.kind != JNull:
|
||||
let name = jsCard{"name"}.getStr
|
||||
if "poll" in name:
|
||||
@@ -253,27 +343,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
||||
|
||||
result.expandTweetEntities(js)
|
||||
|
||||
with jsMedia, js{"extended_entities", "media"}:
|
||||
for m in jsMedia:
|
||||
case m{"type"}.getStr
|
||||
of "photo":
|
||||
result.photos.add m{"media_url_https"}.getImageStr
|
||||
of "video":
|
||||
result.video = some(parseVideo(m))
|
||||
with user, m{"additional_media_info", "source_user"}:
|
||||
if user{"id"}.getInt > 0:
|
||||
result.attribution = some(parseUser(user))
|
||||
else:
|
||||
result.attribution = some(parseGraphUser(user))
|
||||
of "animated_gif":
|
||||
result.gif = some(parseGif(m))
|
||||
else: discard
|
||||
|
||||
with url, m{"url"}:
|
||||
if result.text.endsWith(url.getStr):
|
||||
result.text.removeSuffix(url.getStr)
|
||||
result.text = result.text.strip()
|
||||
parseLegacyMediaEntities(js, result)
|
||||
|
||||
with jsWithheld, js{"withheld_in_countries"}:
|
||||
let withheldInCountries: seq[string] =
|
||||
@@ -289,91 +359,108 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
result.text.removeSuffix(" Learn more.")
|
||||
result.available = false
|
||||
|
||||
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
||||
proc parseGraphTweet(js: JsonNode): Tweet =
|
||||
if js.kind == JNull:
|
||||
return Tweet()
|
||||
|
||||
case js{"__typename"}.getStr
|
||||
case js.getTypeName:
|
||||
of "TweetUnavailable":
|
||||
return Tweet()
|
||||
of "TweetTombstone":
|
||||
with text, js{"tombstone", "richText"}:
|
||||
return Tweet(text: text.getTombstone)
|
||||
with text, js{"tombstone", "text"}:
|
||||
with text, select(js{"tombstone", "richText"}, js{"tombstone", "text"}):
|
||||
return Tweet(text: text.getTombstone)
|
||||
return Tweet()
|
||||
of "TweetPreviewDisplay":
|
||||
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
||||
of "TweetWithVisibilityResults":
|
||||
return parseGraphTweet(js{"tweet"}, isLegacy)
|
||||
return parseGraphTweet(js{"tweet"})
|
||||
else:
|
||||
discard
|
||||
|
||||
if not js.hasKey("legacy"):
|
||||
return Tweet()
|
||||
|
||||
var jsCard = copy(js{if isLegacy: "card" else: "tweet_card", "legacy"})
|
||||
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
|
||||
if jsCard.kind != JNull:
|
||||
var values = newJObject()
|
||||
for val in jsCard["binding_values"]:
|
||||
values[val["key"].getStr] = val["value"]
|
||||
jsCard["binding_values"] = values
|
||||
let legacyCard = jsCard{"legacy"}
|
||||
if legacyCard.kind != JNull:
|
||||
let bindingArray = legacyCard{"binding_values"}
|
||||
if bindingArray.kind == JArray:
|
||||
var bindingObj: seq[(string, JsonNode)]
|
||||
for item in bindingArray:
|
||||
bindingObj.add((item{"key"}.getStr, item{"value"}))
|
||||
# Create a new card object with flattened structure
|
||||
jsCard = %*{
|
||||
"name": legacyCard{"name"},
|
||||
"url": legacyCard{"url"},
|
||||
"binding_values": %bindingObj
|
||||
}
|
||||
|
||||
result = parseTweet(js{"legacy"}, jsCard)
|
||||
result.id = js{"rest_id"}.getId
|
||||
result.user = parseGraphUser(js{"core"})
|
||||
|
||||
if result.replyId == 0:
|
||||
result.replyId = js{"reply_to_results", "rest_id"}.getId
|
||||
|
||||
with count, js{"views", "count"}:
|
||||
result.stats.views = count.getStr("0").parseInt
|
||||
|
||||
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
||||
result.expandNoteTweetEntities(noteTweet)
|
||||
|
||||
parseMediaEntities(js, result)
|
||||
|
||||
if result.quote.isSome:
|
||||
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy))
|
||||
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
|
||||
|
||||
with quoted, js{"quotedPostResults", "result"}:
|
||||
result.quote = some(parseGraphTweet(quoted))
|
||||
|
||||
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
||||
for t in js{"content", "items"}:
|
||||
let entryId = t{"entryId"}.getStr
|
||||
for t in ? js{"content", "items"}:
|
||||
let entryId = t.getEntryId
|
||||
if "cursor-showmore" in entryId:
|
||||
let cursor = t{"item", "content", "value"}
|
||||
result.thread.cursor = cursor.getStr
|
||||
result.thread.hasMore = true
|
||||
elif "tweet" in entryId and "promoted" notin entryId:
|
||||
let
|
||||
isLegacy = t{"item"}.hasKey("itemContent")
|
||||
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
|
||||
else: ("content", "tweetResult")
|
||||
with tweet, t.getTweetResult("item"):
|
||||
result.thread.content.add parseGraphTweet(tweet)
|
||||
|
||||
with content, t{"item", contentKey}:
|
||||
result.thread.content.add parseGraphTweet(content{resultKey, "result"}, isLegacy)
|
||||
|
||||
if content{"tweetDisplayType"}.getStr == "SelfThread":
|
||||
let tweetDisplayType = select(
|
||||
t{"item", "content", "tweet_display_type"},
|
||||
t{"item", "itemContent", "tweetDisplayType"}
|
||||
)
|
||||
if tweetDisplayType.getStr == "SelfThread":
|
||||
result.self = true
|
||||
|
||||
proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
||||
with tweet, js{"data", "tweet_result", "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))
|
||||
|
||||
let
|
||||
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"}
|
||||
let instructions = ? select(
|
||||
js{"data", "timelineResponse", "instructions"},
|
||||
js{"data", "timeline_response", "instructions"},
|
||||
js{"data", "threaded_conversation_with_injections_v2", "instructions"}
|
||||
)
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i{"__typename"}.getStr == "TimelineAddEntries":
|
||||
if i.getTypeName == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
let entryId = e.getEntryId
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", contentKey, resultKey, "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult, not v2)
|
||||
let tweetResult = getTweetResult(e)
|
||||
if tweetResult.notNull:
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
tweet.id = entryId.getId
|
||||
|
||||
if $tweet.id == tweetId:
|
||||
result.tweet = tweet
|
||||
@@ -386,102 +473,143 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversati
|
||||
elif thread.content.len > 0:
|
||||
result.replies.content.add thread
|
||||
elif entryId.startsWith("tombstone"):
|
||||
let id = entryId.getId()
|
||||
let tweet = Tweet(
|
||||
id: parseBiggestInt(id),
|
||||
available: false,
|
||||
text: e{"content", contentKey, "tombstoneInfo", "richText"}.getTombstone
|
||||
)
|
||||
let
|
||||
content = select(e{"content", "content"}, e{"content", "itemContent"})
|
||||
tweet = Tweet(
|
||||
id: entryId.getId,
|
||||
available: false,
|
||||
text: content{"tombstoneInfo", "richText"}.getTombstone
|
||||
)
|
||||
|
||||
if id == tweetId:
|
||||
if $tweet.id == tweetId:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.replies.bottom = e{"content", contentKey, "value"}.getStr
|
||||
var cursorValue = select(
|
||||
e{"content", "value"},
|
||||
e{"content", "content", "value"},
|
||||
e{"content", "itemContent", "value"}
|
||||
)
|
||||
result.replies.bottom = cursorValue.getStr
|
||||
|
||||
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
||||
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
|
||||
with tweetResult, getTweetResult(e):
|
||||
var tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = e.getEntryId.getId
|
||||
result.add tweet
|
||||
return
|
||||
|
||||
for item in e{"content", "items"}:
|
||||
with tweetResult, item.getTweetResult("item"):
|
||||
var tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = item.getEntryId.getId
|
||||
result.add tweet
|
||||
|
||||
proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
|
||||
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||
|
||||
let instructions =
|
||||
if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"}
|
||||
else: ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||
|
||||
let instructions = ? select(
|
||||
js{"data", "list", "timeline_response", "timeline", "instructions"},
|
||||
js{"data", "user", "result", "timeline", "timeline", "instructions"},
|
||||
js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||
)
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i{"__typename"}.getStr == "TimelineAddEntries":
|
||||
if i{"moduleItems"}.notNull:
|
||||
for item in i{"moduleItems"}:
|
||||
with tweetResult, item.getTweetResult("item"):
|
||||
let tweet = parseGraphTweet(tweetResult)
|
||||
if not tweet.available:
|
||||
tweet.id = item.getEntryId.getId
|
||||
result.tweets.content.add tweet
|
||||
continue
|
||||
|
||||
if i{"entries"}.notNull:
|
||||
for e in i{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "content", "tweetResult", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult, false)
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
let entryId = e.getEntryId
|
||||
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
|
||||
for tweet in extractTweetsFromEntry(e):
|
||||
result.tweets.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"):
|
||||
result.tweets.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
|
||||
|
||||
if after.len == 0:
|
||||
if i.getTypeName == "TimelinePinEntry":
|
||||
let tweets = extractTweetsFromEntry(i{"entry"})
|
||||
if tweets.len > 0:
|
||||
var tweet = tweets[0]
|
||||
tweet.pinned = true
|
||||
result.pinned = some tweet
|
||||
|
||||
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
||||
result = @[]
|
||||
|
||||
let instructions =
|
||||
? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||
let instructions = select(
|
||||
js{"data", "user", "result", "timeline", "timeline", "instructions"},
|
||||
js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||
)
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
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())
|
||||
if i{"moduleItems"}.notNull:
|
||||
for item in i{"moduleItems"}:
|
||||
with tweetResult, item.getTweetResult("item"):
|
||||
let t = parseGraphTweet(tweetResult)
|
||||
if not t.available:
|
||||
t.id = item.getEntryId.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: ""
|
||||
let photo = extractGalleryPhoto(t)
|
||||
if photo.url.len > 0:
|
||||
result.add photo
|
||||
|
||||
if url.len > 0:
|
||||
result.add GalleryPhoto(url: url, tweetId: $t.id)
|
||||
if result.len == 16:
|
||||
return
|
||||
continue
|
||||
|
||||
if result.len == 16:
|
||||
break
|
||||
if i.getTypeName != "TimelineAddEntries":
|
||||
continue
|
||||
|
||||
for e in i{"entries"}:
|
||||
let entryId = e.getEntryId
|
||||
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
|
||||
for t in extractTweetsFromEntry(e):
|
||||
let photo = extractGalleryPhoto(t)
|
||||
if photo.url.len > 0:
|
||||
result.add photo
|
||||
|
||||
if result.len == 16:
|
||||
return
|
||||
|
||||
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 = select(
|
||||
js{"data", "search", "timeline_response", "timeline", "instructions"},
|
||||
js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
|
||||
)
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for instruction in instructions:
|
||||
let typ = instruction{"type"}.getStr
|
||||
let typ = getTypeName(instruction)
|
||||
if typ == "TimelineAddEntries":
|
||||
for e in instruction{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
let entryId = e.getEntryId
|
||||
when T is Tweets:
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetRes, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
with tweetRes, getTweetResult(e):
|
||||
let tweet = parseGraphTweet(tweetRes)
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
tweet.id = entryId.getId
|
||||
result.content.add tweet
|
||||
elif T is User:
|
||||
if entryId.startsWith("user"):
|
||||
|
||||
@@ -36,6 +36,12 @@ template `?`*(js: JsonNode): untyped =
|
||||
if j.isNull: return
|
||||
j
|
||||
|
||||
template select*(a, b: JsonNode): untyped =
|
||||
if a.notNull: a else: b
|
||||
|
||||
template select*(a, b, c: JsonNode): untyped =
|
||||
if a.notNull: a elif b.notNull: b else: c
|
||||
|
||||
template with*(ident, value, body): untyped =
|
||||
if true:
|
||||
let ident {.inject.} = value
|
||||
@@ -44,8 +50,7 @@ template with*(ident, value, body): untyped =
|
||||
template with*(ident; value: JsonNode; body): untyped =
|
||||
if true:
|
||||
let ident {.inject.} = value
|
||||
# value.notNull causes a compilation error for versions < 1.6.14
|
||||
if notNull(value): body
|
||||
if value.notNull: body
|
||||
|
||||
template getCursor*(js: JsonNode): string =
|
||||
js{"content", "operation", "cursor", "value"}.getStr
|
||||
@@ -54,6 +59,20 @@ template getError*(js: JsonNode): Error =
|
||||
if js.kind != JArray or js.len == 0: null
|
||||
else: Error(js[0]{"code"}.getInt)
|
||||
|
||||
proc getTweetResult*(js: JsonNode; root="content"): JsonNode =
|
||||
select(
|
||||
js{root, "content", "tweet_results", "result"},
|
||||
js{root, "itemContent", "tweet_results", "result"},
|
||||
js{root, "content", "tweetResult", "result"}
|
||||
)
|
||||
|
||||
template getTypeName*(js: JsonNode): string =
|
||||
js{"__typename"}.getStr(js{"type"}.getStr)
|
||||
|
||||
template getEntryId*(e: JsonNode): string =
|
||||
e{"entryId"}.getStr(e{"entry_id"}.getStr)
|
||||
|
||||
|
||||
template parseTime(time: string; f: static string; flen: int): DateTime =
|
||||
if time.len != flen: return
|
||||
parse(time, f, utc())
|
||||
@@ -64,29 +83,24 @@ proc getDateTime*(js: JsonNode): DateTime =
|
||||
proc getTime*(js: JsonNode): DateTime =
|
||||
parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
|
||||
|
||||
proc getId*(id: string): string {.inline.} =
|
||||
proc getTimeFromMs*(js: JsonNode): DateTime =
|
||||
let ms = js.getInt(0)
|
||||
if ms == 0: return
|
||||
let seconds = ms div 1000
|
||||
return fromUnix(seconds).utc()
|
||||
|
||||
proc getId*(id: string): int64 {.inline.} =
|
||||
let start = id.rfind("-")
|
||||
if start < 0: return id
|
||||
id[start + 1 ..< id.len]
|
||||
if start < 0:
|
||||
return parseBiggestInt(id)
|
||||
return parseBiggestInt(id[start + 1 ..< id.len])
|
||||
|
||||
proc getId*(js: JsonNode): int64 {.inline.} =
|
||||
case js.kind
|
||||
of JString: return parseBiggestInt(js.getStr("0"))
|
||||
of JString: return js.getStr("0").getId
|
||||
of JInt: return js.getBiggestInt()
|
||||
else: return 0
|
||||
|
||||
proc getEntryId*(js: JsonNode): string {.inline.} =
|
||||
let entry = js{"entryId"}.getStr
|
||||
if entry.len == 0: return
|
||||
|
||||
if "tweet" in entry or "sq-I-t" in entry:
|
||||
return entry.getId
|
||||
elif "tombstone" in entry:
|
||||
return js{"content", "item", "content", "tombstone", "tweet", "id"}.getStr
|
||||
else:
|
||||
echo "unknown entry: ", entry
|
||||
return
|
||||
|
||||
template getStrVal*(js: JsonNode; default=""): string =
|
||||
js{"string_value"}.getStr(default)
|
||||
|
||||
@@ -98,6 +112,9 @@ proc getImageStr*(js: JsonNode): string =
|
||||
template getImageVal*(js: JsonNode): string =
|
||||
js{"image_value", "url"}.getImageStr
|
||||
|
||||
template getExpandedUrl*(js: JsonNode; fallback=""): string =
|
||||
js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
|
||||
|
||||
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
|
||||
result = js{"website_url"}.getStrVal
|
||||
if kind == promoVideoConvo:
|
||||
@@ -157,19 +174,13 @@ proc getMp4Resolution*(url: string): int =
|
||||
# cannot determine resolution (e.g. m3u8/non-mp4 video)
|
||||
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] =
|
||||
result = js["indices"][0].getInt ..< js["indices"][1].getInt
|
||||
|
||||
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
|
||||
textLen: int; hideTwitter = false) =
|
||||
let
|
||||
url = js["expanded_url"].getStr
|
||||
url = js.getExpandedUrl
|
||||
slice = js.extractSlice
|
||||
|
||||
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
|
||||
@@ -230,7 +241,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
|
||||
ent = ? js{"entities"}
|
||||
|
||||
with urls, ent{"url", "urls"}:
|
||||
user.website = urls[0]{"expanded_url"}.getStr
|
||||
user.website = urls[0].getExpandedUrl
|
||||
|
||||
var replacements = newSeq[ReplaceSlice]()
|
||||
|
||||
@@ -260,7 +271,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
||||
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
|
||||
|
||||
if hasCard and u{"url"}.getStr == get(tweet.card).url:
|
||||
get(tweet.card).url = u{"expanded_url"}.getStr
|
||||
get(tweet.card).url = u.getExpandedUrl
|
||||
|
||||
with media, entities{"media"}:
|
||||
for m in media:
|
||||
@@ -319,3 +330,13 @@ proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
tweet.expandTextEntities(entities, text, textSlice)
|
||||
|
||||
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
|
||||
|
||||
proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
|
||||
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: ""
|
||||
|
||||
result = GalleryPhoto(url: url, tweetId: $t.id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import tables
|
||||
import tables, strutils, sequtils
|
||||
import types, prefs_impl
|
||||
from config import get
|
||||
from parsecfg import nil
|
||||
@@ -13,6 +13,8 @@ proc updateDefaultPrefs*(cfg: parsecfg.Config) =
|
||||
|
||||
proc getPrefs*(cookies: Table[string, string]): Prefs =
|
||||
result = defaultPrefs
|
||||
if "nitter_following" in cookies:
|
||||
result.following = cookies["nitter_following"].split(',').filterIt(it.len > 0)
|
||||
genCookiePrefs(cookies)
|
||||
|
||||
template getPref*(cookies: Table[string, string], pref): untyped =
|
||||
|
||||
@@ -54,6 +54,10 @@ genPrefs:
|
||||
theme(select, "Nitter"):
|
||||
"Theme"
|
||||
|
||||
verifiedBadge(select, "Show all"):
|
||||
"Verified badges"
|
||||
options: @["Show all", "Show official only", "Hide all"]
|
||||
|
||||
infiniteScroll(checkbox, false):
|
||||
"Infinite scrolling (experimental, requires JavaScript)"
|
||||
|
||||
@@ -78,6 +82,9 @@ genPrefs:
|
||||
squareAvatars(checkbox, false):
|
||||
"Square profile pictures"
|
||||
|
||||
hideNsfw(checkbox, true):
|
||||
"Hide NSFW content"
|
||||
|
||||
Media:
|
||||
mp4Playback(checkbox, true):
|
||||
"Enable mp4 video playback (only for gifs)"
|
||||
@@ -107,6 +114,18 @@ genPrefs:
|
||||
"Reddit -> Teddit/Libreddit"
|
||||
placeholder: "Teddit hostname"
|
||||
|
||||
replaceImgur(input, ""):
|
||||
"Imgur -> Rimgo"
|
||||
placeholder: "Rimgo hostname"
|
||||
|
||||
replaceFandom(input, ""):
|
||||
"Fandom -> Phantom"
|
||||
placeholder: "Phantom hostname"
|
||||
|
||||
replaceSoundCloud(input, ""):
|
||||
"SoundCloud -> SoundCloak"
|
||||
placeholder: "SoundCloak hostname"
|
||||
|
||||
iterator allPrefs*(): Pref =
|
||||
for k, v in prefList:
|
||||
for pref in v:
|
||||
@@ -206,6 +225,7 @@ macro genPrefsType*(): untyped =
|
||||
let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
|
||||
result = quote do:
|
||||
type `name` = object
|
||||
following*: seq[string]
|
||||
discard
|
||||
|
||||
for pref in allPrefs():
|
||||
|
||||
@@ -6,10 +6,9 @@ import types
|
||||
const
|
||||
validFilters* = @[
|
||||
"media", "images", "twimg", "videos",
|
||||
"native_video", "consumer_video", "pro_video",
|
||||
"native_video", "consumer_video", "spaces",
|
||||
"links", "news", "quote", "mentions",
|
||||
"replies", "retweets", "nativeretweets",
|
||||
"verified", "safe"
|
||||
"replies", "retweets", "nativeretweets"
|
||||
]
|
||||
|
||||
emptyQuery* = "include:nativeretweets"
|
||||
@@ -18,6 +17,11 @@ template `@`(param: string): untyped =
|
||||
if param in pms: pms[param]
|
||||
else: ""
|
||||
|
||||
proc validateNumber(value: string): string =
|
||||
if value.anyIt(not it.isDigit):
|
||||
return ""
|
||||
return value
|
||||
|
||||
proc initQuery*(pms: Table[string, string]; name=""): Query =
|
||||
result = Query(
|
||||
kind: parseEnum[QueryKind](@"f", tweets),
|
||||
@@ -26,7 +30,8 @@ proc initQuery*(pms: Table[string, string]; name=""): Query =
|
||||
excludes: validFilters.filterIt("e-" & it in pms),
|
||||
since: @"since",
|
||||
until: @"until",
|
||||
near: @"near"
|
||||
near: @"near",
|
||||
minLikes: validateNumber(@"min_faves")
|
||||
)
|
||||
|
||||
if name.len > 0:
|
||||
@@ -59,8 +64,11 @@ proc genQueryParam*(query: Query): string =
|
||||
if i < query.fromUser.high:
|
||||
param &= "OR "
|
||||
|
||||
if query.fromUser.len > 0 and query.kind in {posts, media}:
|
||||
param &= "filter:self_threads OR -filter:replies "
|
||||
if query.fromUser.len > 0:
|
||||
if query.kind in {posts, media}:
|
||||
param &= "filter:self_threads OR -filter:replies "
|
||||
elif query.kind == tweets and query.fromUser.len > 1:
|
||||
param &= "filter:self_threads OR -filter:replies "
|
||||
|
||||
if "nativeretweets" notin query.excludes:
|
||||
param &= "include:nativeretweets "
|
||||
@@ -79,7 +87,9 @@ proc genQueryParam*(query: Query): string =
|
||||
if query.until.len > 0:
|
||||
result &= " until:" & query.until
|
||||
if query.near.len > 0:
|
||||
result &= &" near:\"{query.near}\" within:15mi"
|
||||
result &= " near:\"" & query.near & "\""
|
||||
if query.minLikes.len > 0:
|
||||
result &= " min_faves:" & query.minLikes
|
||||
if query.text.len > 0:
|
||||
if result.len > 0:
|
||||
result &= " " & query.text
|
||||
@@ -105,6 +115,8 @@ proc genQueryUrl*(query: Query): string =
|
||||
params.add "until=" & query.until
|
||||
if query.near.len > 0:
|
||||
params.add "near=" & query.near
|
||||
if query.minLikes.len > 0:
|
||||
params.add "min_faves=" & query.minLikes
|
||||
|
||||
if params.len > 0:
|
||||
result &= params.join("&")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, times, strformat, strutils, tables, hashes
|
||||
import asyncdispatch, times, strformat, strutils, tables, hashes, logging
|
||||
import redis, redpool, flatty, supersnappy
|
||||
|
||||
import types, api
|
||||
@@ -59,8 +59,7 @@ proc initRedisPool*(cfg: Config) {.async.} =
|
||||
await r.configSet("hash-max-ziplist-entries", "1000")
|
||||
|
||||
except OSError:
|
||||
stdout.write "Failed to connect to Redis.\n"
|
||||
stdout.flushFile
|
||||
fatal "Failed to connect to Redis."
|
||||
quit(1)
|
||||
|
||||
template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000)
|
||||
@@ -112,7 +111,7 @@ template deserialize(data, T) =
|
||||
try:
|
||||
result = fromFlatty(uncompress(data), T)
|
||||
except:
|
||||
echo "Decompression failed($#): '$#'" % [astToStr(T), data]
|
||||
error "Decompression failed($#): '$#'" % [astToStr(T), data]
|
||||
|
||||
proc getUserId*(username: string): Future[string] {.async.} =
|
||||
let name = toLower(username)
|
||||
@@ -189,6 +188,6 @@ proc getCachedRss*(key: string): Future[Rss] {.async.} =
|
||||
let feed = await r.hGet(k, "rss")
|
||||
if feed.len > 0 and feed != redisNil:
|
||||
try: result.feed = uncompress feed
|
||||
except: echo "Decompressing RSS failed: ", feed
|
||||
except: error "Decompressing RSS failed: ", feed
|
||||
else:
|
||||
result.cursor.setLen 0
|
||||
|
||||
23
src/routes/follow.nim
Normal file
23
src/routes/follow.nim
Normal file
@@ -0,0 +1,23 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, sequtils
|
||||
import jester
|
||||
import router_utils
|
||||
import ".."/[types]
|
||||
|
||||
proc createFollowRouter*(cfg: Config) =
|
||||
router follow:
|
||||
post "/follow":
|
||||
let user = @"user"
|
||||
var prefs = cookiePrefs()
|
||||
if user.len > 0 and user notin prefs.following:
|
||||
prefs.following.add(user)
|
||||
setCookie("nitter_following", prefs.following.join(","), daysForward(360), path="/", httpOnly=true, secure=cfg.useHttps, sameSite=None)
|
||||
redirect(refPath())
|
||||
|
||||
post "/unfollow":
|
||||
let user = @"user"
|
||||
var prefs = cookiePrefs()
|
||||
if user.len > 0 and user in prefs.following:
|
||||
prefs.following.keepItIf(it != user)
|
||||
setCookie("nitter_following", prefs.following.join(","), daysForward(360), path="/", httpOnly=true, secure=cfg.useHttps, sameSite=None)
|
||||
redirect(refPath())
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import uri, strutils, httpclient, os, hashes, base64, re
|
||||
import uri, strutils, httpclient, os, hashes, base64, re, logging
|
||||
import asynchttpserver, asyncstreams, asyncfile, asyncnet
|
||||
|
||||
import jester
|
||||
@@ -38,7 +38,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
||||
let res = await client.get(url)
|
||||
if res.status != "200 OK":
|
||||
if res.status != "404 Not Found":
|
||||
echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url]
|
||||
warn "[media] Proxying failed, status: $1, url: $2" % [res.status, url]
|
||||
return Http404
|
||||
|
||||
let hashed = $hash(url)
|
||||
@@ -67,7 +67,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
|
||||
await request.client.send(data)
|
||||
data.setLen 0
|
||||
except HttpRequestError, ProtocolError, OSError:
|
||||
echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url]
|
||||
error "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url]
|
||||
result = Http404
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
@@ -32,7 +32,7 @@ proc createSearchRouter*(cfg: Config) =
|
||||
users = await getGraphUserSearch(query, getCursor())
|
||||
except InternalError:
|
||||
users = Result[User](beginning: true, query: query)
|
||||
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
|
||||
resp renderMain(renderUserSearch(users, prefs, getPath()), request, cfg, prefs, title)
|
||||
of tweets:
|
||||
let
|
||||
tweets = await getGraphTweetSearch(query, getCursor())
|
||||
|
||||
@@ -31,8 +31,6 @@ proc createStatusRouter*(cfg: Config) =
|
||||
resp $renderReplies(replies, prefs, getPath())
|
||||
|
||||
let conv = await getTweet(id, getCursor())
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||
var error = "Tweet not found"
|
||||
@@ -68,7 +66,7 @@ proc createStatusRouter*(cfg: Config) =
|
||||
|
||||
get "/@name/@s/@id/@m/?@i?":
|
||||
cond @"s" in ["status", "statuses"]
|
||||
cond @"m" in ["video", "photo"]
|
||||
cond @"m" in ["video", "photo", "history"]
|
||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||
|
||||
get "/@name/statuses/@id/?":
|
||||
@@ -76,6 +74,6 @@ proc createStatusRouter*(cfg: Config) =
|
||||
|
||||
get "/i/web/status/@id":
|
||||
redirect("/i/status/" & @"id")
|
||||
|
||||
|
||||
get "/@name/thread/@id/?":
|
||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||
|
||||
@@ -105,6 +105,12 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
get "/intent/user":
|
||||
respUserId()
|
||||
|
||||
get "/intent/follow/?":
|
||||
let username = request.params.getOrDefault("screen_name")
|
||||
if username.len == 0:
|
||||
resp Http400, showError("Missing screen_name parameter", cfg)
|
||||
redirect("/" & username)
|
||||
|
||||
get "/@name/?@tab?/?":
|
||||
cond '.' notin @"name"
|
||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||
|
||||
@@ -17,7 +17,7 @@ proc createUnsupportedRouter*(cfg: Config) =
|
||||
get "/@name/lists/?": feature()
|
||||
|
||||
get "/intent/?@i?":
|
||||
cond @"i" notin ["user"]
|
||||
cond @"i" notin ["user", "follow"]
|
||||
feature()
|
||||
|
||||
get "/i/@i?/?@j?":
|
||||
|
||||
@@ -37,3 +37,52 @@
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.follow-form {
|
||||
display: inline-block;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.follow-button {
|
||||
background-color: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 9999px;
|
||||
padding: 4px 12px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
transition: background-color 0.2s;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg_hover);
|
||||
}
|
||||
|
||||
&.unfollow {
|
||||
color: #e0245e;
|
||||
border-color: #e0245e;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(224, 36, 94, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tweet-name-row .follow-form {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.tweet-name-row .follow-button {
|
||||
padding: 2px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
53
src/sass/homepage.scss
Normal file
53
src/sass/homepage.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
.homepage-container {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.following-column,
|
||||
.timeline-users-column {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-users-column {
|
||||
.timeline-item {
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.profile-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.timeline-users-column > :first-child,
|
||||
.homepage-container .timeline-container > .timeline > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.homepage-container {
|
||||
max-width: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.following-column {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.timeline-users-column {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -61,23 +61,8 @@
|
||||
|
||||
@mixin search-resize($width, $rows) {
|
||||
@media(max-width: $width) {
|
||||
.search-toggles {
|
||||
grid-template-columns: repeat($rows, auto);
|
||||
}
|
||||
|
||||
#search-panel-toggle:checked ~ .search-panel {
|
||||
@if $rows == 6 {
|
||||
max-height: 200px !important;
|
||||
}
|
||||
@if $rows == 5 {
|
||||
max-height: 300px !important;
|
||||
}
|
||||
@if $rows == 4 {
|
||||
max-height: 300px !important;
|
||||
}
|
||||
@if $rows == 3 {
|
||||
max-height: 365px !important;
|
||||
}
|
||||
max-height: 80vh !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,5 @@ $shadow: rgba(0,0,0,.6);
|
||||
$shadow_dark: rgba(0,0,0,.2);
|
||||
|
||||
//fonts
|
||||
$font_0: Helvetica Neue;
|
||||
$font_1: Helvetica;
|
||||
$font_2: Arial;
|
||||
$font_3: sans-serif;
|
||||
$font_4: fontello;
|
||||
$font_stack: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
$font_icon: fontello;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@import 'inputs';
|
||||
@import 'timeline';
|
||||
@import 'search';
|
||||
@import 'homepage';
|
||||
|
||||
body {
|
||||
// colors
|
||||
@@ -50,8 +51,8 @@ body {
|
||||
|
||||
background-color: var(--bg_color);
|
||||
color: var(--fg_color);
|
||||
font-family: $font_0, $font_1, $font_2, $font_3;
|
||||
font-size: 14px;
|
||||
font-family: $font_stack;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -114,7 +115,7 @@ ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
padding-top: 50px;
|
||||
padding-top: 55px;
|
||||
margin: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -127,7 +128,6 @@ ul {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
margin-top: 10px;
|
||||
background-color: var(--bg_overlays);
|
||||
padding: 10px 15px;
|
||||
align-self: start;
|
||||
@@ -169,6 +169,14 @@ ul {
|
||||
}
|
||||
}
|
||||
|
||||
body.hide-verified-all .verified-icon {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.hide-verified-blue .verified-icon.blue {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media(max-width: 600px) {
|
||||
.preferences-container {
|
||||
max-width: 95vw;
|
||||
|
||||
@@ -14,6 +14,7 @@ button {
|
||||
|
||||
input[type="text"],
|
||||
input[type="date"],
|
||||
input[type="number"],
|
||||
select {
|
||||
@include input-colors;
|
||||
background-color: var(--bg_elements);
|
||||
@@ -24,7 +25,12 @@ select {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@@ -38,6 +44,17 @@ input[type="date"]::-webkit-inner-spin-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
display: none;
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-clear-button {
|
||||
margin-left: 17px;
|
||||
filter: grayscale(100%);
|
||||
@@ -136,8 +153,8 @@ input::-webkit-datetime-edit-year-field:focus {
|
||||
left: 2px;
|
||||
bottom: 0;
|
||||
font-size: 13px;
|
||||
font-family: $font_4;
|
||||
content: '\e803';
|
||||
font-family: $font_icon;
|
||||
content: '\e804';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,9 +179,22 @@ input::-webkit-datetime-edit-year-field:focus {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
.pref-select:after {
|
||||
content: '\e80b'; /* icon-down */
|
||||
font-family: $font_icon;
|
||||
font-size: 10px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 5px;
|
||||
color: var(--fg_color);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
max-width: 140px;
|
||||
|
||||
@@ -25,6 +25,14 @@ nav {
|
||||
align-items: center;
|
||||
flex-basis: 920px;
|
||||
height: 50px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.inner-nav .search-field {
|
||||
flex: 1;
|
||||
margin: 0 auto;
|
||||
max-width: 350px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
@@ -46,11 +54,11 @@ nav {
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
line-height: 50px;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
|
||||
&.right {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 5px;
|
||||
background-color: var(--bg_panel);
|
||||
|
||||
a {
|
||||
|
||||
@@ -9,70 +9,111 @@
|
||||
|
||||
.search-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
position: relative;
|
||||
|
||||
button {
|
||||
margin: 0 2px 0 0;
|
||||
height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pref-input {
|
||||
margin: 0 4px 0 0;
|
||||
flex-grow: 1;
|
||||
height: 23px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
height: calc(100% - 4px);
|
||||
width: calc(100% - 8px);
|
||||
}
|
||||
|
||||
> label {
|
||||
display: inline;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 23px;
|
||||
background-color: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
padding: 1px 6px 2px 6px;
|
||||
padding: 0 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
@include input-colors;
|
||||
}
|
||||
|
||||
@include create-toggle(search-panel, 200px);
|
||||
@include create-toggle(search-panel, 600px);
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
border: 1px solid var(--accent_border);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
transition: max-height 0.4s;
|
||||
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background-color: var(--bg_overlays);
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
border-radius: 0 0 4px 4px;
|
||||
z-index: 2000;
|
||||
padding: 5px 10px;
|
||||
box-sizing: border-box;
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: max-height 0.4s, opacity 0.4s, visibility 0.4s;
|
||||
|
||||
flex-grow: 1;
|
||||
font-weight: initial;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
|
||||
> div {
|
||||
line-height: 1.7em;
|
||||
line-height: 1.4em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: inline;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-right: unset;
|
||||
margin-bottom: unset;
|
||||
margin-left: 23px;
|
||||
margin-bottom: 2px;
|
||||
margin-left: 18px;
|
||||
margin-right: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
right: unset;
|
||||
left: -22px;
|
||||
left: -18px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.checkbox-container .checkbox:after {
|
||||
top: -4px;
|
||||
top: 0;
|
||||
left: 1px;
|
||||
bottom: unset;
|
||||
font-size: 13px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,11 +133,11 @@
|
||||
|
||||
.pref-input {
|
||||
display: block;
|
||||
padding-bottom: 5px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
input {
|
||||
height: 21px;
|
||||
margin-top: 1px;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,19 +145,36 @@
|
||||
.search-toggles {
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, auto);
|
||||
grid-column-gap: 10px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 5px 10px;
|
||||
}
|
||||
|
||||
#search-panel-toggle:checked ~ .search-panel {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
@include search-resize(820px, 5);
|
||||
@include search-resize(725px, 4);
|
||||
@include search-resize(600px, 6);
|
||||
@include search-resize(560px, 5);
|
||||
@include search-resize(480px, 4);
|
||||
@include search-resize(715px, 4);
|
||||
@include search-resize(700px, 5);
|
||||
@include search-resize(485px, 4);
|
||||
@include search-resize(410px, 3);
|
||||
}
|
||||
|
||||
@include search-resize(560px, 5);
|
||||
@include search-resize(480px, 4);
|
||||
@include search-resize(700px, 5);
|
||||
@include search-resize(485px, 4);
|
||||
@include search-resize(410px, 3);
|
||||
|
||||
@media(max-width: 600px) {
|
||||
.search-panel {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
border-top: 1px solid var(--accent_border);
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
.timeline-container {
|
||||
@include panel(100%, 600px);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
background-color: var(--bg_panel);
|
||||
background-color: transparent;
|
||||
|
||||
> div:not(:first-child) {
|
||||
border-top: 1px solid var(--border_grey);
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +26,25 @@
|
||||
button {
|
||||
float: unset;
|
||||
}
|
||||
|
||||
&.timeline-default {
|
||||
background-color: var(--bg_panel);
|
||||
border-bottom: 1px solid var(--bg_elements);
|
||||
margin-bottom: 5px;
|
||||
|
||||
.timeline-default-message {
|
||||
color: var(--fg_color);
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-banner img {
|
||||
@@ -159,4 +179,6 @@
|
||||
padding: .75em;
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin-top: 5px;
|
||||
background-color: var(--bg_panel);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.tweet-content {
|
||||
font-family: $font_3;
|
||||
font-family: $font_stack;
|
||||
line-height: 1.3em;
|
||||
pointer-events: all;
|
||||
display: inline;
|
||||
@@ -72,6 +72,7 @@
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.tweet-date a, .username, .show-more a {
|
||||
@@ -83,6 +84,7 @@
|
||||
margin-top: 5px;
|
||||
color: var(--grey);
|
||||
pointer-events: all;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.tweet-avatar {
|
||||
@@ -172,7 +174,8 @@
|
||||
|
||||
.replying-to {
|
||||
color: var(--fg_faded);
|
||||
margin: -2px 0 4px;
|
||||
margin: -2px 0 5px;
|
||||
font-size: smaller;
|
||||
|
||||
a {
|
||||
pointer-events: all;
|
||||
@@ -200,11 +203,22 @@
|
||||
|
||||
.tweet-stats {
|
||||
margin-bottom: -3px;
|
||||
padding-top: 5px;
|
||||
-webkit-user-select: none;
|
||||
|
||||
a {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.tweet-published {
|
||||
margin-left: auto;
|
||||
margin-top: 0;
|
||||
font-weight: 400;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.tweet-stat {
|
||||
padding-top: 5px;
|
||||
min-width: 1em;
|
||||
margin-right: 0.8em;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
|
||||
.card-description {
|
||||
margin: 0.3em 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.card-destination {
|
||||
|
||||
@@ -10,15 +10,10 @@
|
||||
}
|
||||
|
||||
.main-thread {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 5px;
|
||||
background-color: var(--bg_panel);
|
||||
}
|
||||
|
||||
.main-tweet, .replies {
|
||||
padding-top: 50px;
|
||||
margin-top: -50px;
|
||||
}
|
||||
|
||||
.main-tweet .tweet-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
@@ -31,13 +26,13 @@
|
||||
|
||||
.reply {
|
||||
background-color: var(--bg_panel);
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.thread-line {
|
||||
.timeline-item::before,
|
||||
&.timeline-item::before {
|
||||
background: var(--accent_dark);
|
||||
background: var(--dark_grey);
|
||||
content: '';
|
||||
position: relative;
|
||||
min-width: 3px;
|
||||
|
||||
62
src/tid.nim
Normal file
62
src/tid.nim
Normal file
@@ -0,0 +1,62 @@
|
||||
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
|
||||
import nimcrypto
|
||||
import experimental/parser/tid
|
||||
|
||||
randomize()
|
||||
|
||||
const defaultKeyword = "obfiowerehiring";
|
||||
const pairsUrl =
|
||||
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
|
||||
|
||||
var
|
||||
cachedPairs: seq[TidPair] = @[]
|
||||
lastCached = 0
|
||||
# refresh every hour
|
||||
ttlSec = 60 * 60
|
||||
|
||||
proc getPair(): Future[TidPair] {.async.} =
|
||||
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
|
||||
lastCached = int(epochTime())
|
||||
|
||||
let client = newAsyncHttpClient()
|
||||
defer: client.close()
|
||||
|
||||
let resp = await client.get(pairsUrl)
|
||||
if resp.status == $Http200:
|
||||
cachedPairs = parseTidPairs(await resp.body)
|
||||
|
||||
return sample(cachedPairs)
|
||||
|
||||
proc encodeSha256(text: string): array[32, byte] =
|
||||
let
|
||||
data = cast[ptr byte](addr text[0])
|
||||
dataLen = uint(len(text))
|
||||
digest = sha256.digest(data, dataLen)
|
||||
return digest.data
|
||||
|
||||
proc encodeBase64[T](data: T): string =
|
||||
return encode(data).replace("=", "")
|
||||
|
||||
proc decodeBase64(data: string): seq[byte] =
|
||||
return cast[seq[byte]](decode(data))
|
||||
|
||||
proc genTid*(path: string): Future[string] {.async.} =
|
||||
let
|
||||
pair = await getPair()
|
||||
|
||||
timeNow = int(epochTime() - 1682924400)
|
||||
timeNowBytes = @[
|
||||
byte(timeNow and 0xff),
|
||||
byte((timeNow shr 8) and 0xff),
|
||||
byte((timeNow shr 16) and 0xff),
|
||||
byte((timeNow shr 24) and 0xff)
|
||||
]
|
||||
|
||||
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
|
||||
hashBytes = encodeSha256(data)
|
||||
keyBytes = decodeBase64(pair.verification)
|
||||
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
|
||||
randomNum = byte(rand(256))
|
||||
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
|
||||
|
||||
return encodeBase64(tid)
|
||||
@@ -13,19 +13,13 @@ type
|
||||
TimelineKind* {.pure.} = enum
|
||||
tweets, replies, media
|
||||
|
||||
Api* {.pure.} = enum
|
||||
tweetDetail
|
||||
tweetResult
|
||||
search
|
||||
list
|
||||
listBySlug
|
||||
listMembers
|
||||
listTweets
|
||||
userRestId
|
||||
userScreenName
|
||||
userTweets
|
||||
userTweetsAndReplies
|
||||
userMedia
|
||||
ApiUrl* = object
|
||||
endpoint*: string
|
||||
params*: seq[(string, string)]
|
||||
|
||||
ApiReq* = object
|
||||
oauth*: ApiUrl
|
||||
cookie*: ApiUrl
|
||||
|
||||
RateLimit* = object
|
||||
limit*: int
|
||||
@@ -38,10 +32,11 @@ type
|
||||
|
||||
Session* = ref object
|
||||
id*: int64
|
||||
username*: string
|
||||
pending*: int
|
||||
limited*: bool
|
||||
limitedAt*: int
|
||||
apis*: Table[Api, RateLimit]
|
||||
apis*: Table[string, RateLimit]
|
||||
case kind*: SessionKind
|
||||
of oauth:
|
||||
oauthToken*: string
|
||||
@@ -98,6 +93,7 @@ type
|
||||
media*: int
|
||||
verifiedType*: VerifiedType
|
||||
protected*: bool
|
||||
sensitive*: bool
|
||||
suspended*: bool
|
||||
joinDate*: DateTime
|
||||
|
||||
@@ -116,7 +112,6 @@ type
|
||||
durationMs*: int
|
||||
url*: string
|
||||
thumb*: string
|
||||
views*: string
|
||||
available*: bool
|
||||
reason*: string
|
||||
title*: string
|
||||
@@ -137,6 +132,7 @@ type
|
||||
since*: string
|
||||
until*: string
|
||||
near*: string
|
||||
minLikes*: string
|
||||
sep*: string
|
||||
|
||||
Gif* = object
|
||||
@@ -197,7 +193,7 @@ type
|
||||
replies*: int
|
||||
retweets*: int
|
||||
likes*: int
|
||||
quotes*: int
|
||||
views*: int
|
||||
|
||||
Tweet* = ref object
|
||||
id*: int64
|
||||
@@ -208,6 +204,7 @@ type
|
||||
time*: DateTime
|
||||
reply*: seq[string]
|
||||
pinned*: bool
|
||||
sensitive*: bool
|
||||
hasThread*: bool
|
||||
available*: bool
|
||||
tombstone*: string
|
||||
@@ -279,8 +276,11 @@ type
|
||||
minTokens*: int
|
||||
enableRss*: bool
|
||||
enableDebug*: bool
|
||||
logLevel*: string
|
||||
proxy*: string
|
||||
proxyAuth*: string
|
||||
defaultFollowedAccounts*: seq[string]
|
||||
disableTid*: bool
|
||||
|
||||
rssCacheTime*: int
|
||||
listCacheTime*: int
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import uri, strutils, strformat
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils
|
||||
import ../utils, ../types, ../prefs, ../formatters
|
||||
import renderutils, search_panel
|
||||
import ../utils, ../types, ../prefs, ../formatters, ../query, tables
|
||||
|
||||
import jester
|
||||
|
||||
@@ -20,25 +20,27 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
||||
path = $(parseUri(req.path) ? filterParams(req.params))
|
||||
if "/status/" in path: path.add "#m"
|
||||
|
||||
let query = initQuery(req.params)
|
||||
|
||||
buildHtml(nav):
|
||||
tdiv(class="inner-nav"):
|
||||
tdiv(class="nav-item"):
|
||||
a(class="site-name", href="/"): text cfg.title
|
||||
a(href="/about"): text "(donate)"
|
||||
|
||||
a(href="/"): img(class="site-logo", src="/logo.png", alt="Logo")
|
||||
renderSearchPanel(query)
|
||||
|
||||
tdiv(class="nav-item right"):
|
||||
icon "search", title="Search", href="/search"
|
||||
if cfg.enableRss and rss.len > 0:
|
||||
icon "rss-feed", title="RSS Feed", href=rss
|
||||
icon "bird", title="Open in Twitter", href=canonical
|
||||
a(href="https://liberapay.com/zedeus"): verbatim lp
|
||||
icon "rss", title="RSS Feed", href=rss
|
||||
icon "bird", title="Open in X", href=canonical
|
||||
a(href="https://buymeacoffee.com/kuu7o"): verbatim lp
|
||||
icon "info", title="About", href="/about"
|
||||
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
||||
|
||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
||||
rss=""; canonical=""): VNode =
|
||||
rss=""; alternate=""): VNode =
|
||||
var theme = prefs.theme.toTheme
|
||||
if "theme" in req.params:
|
||||
theme = req.params["theme"].toTheme
|
||||
@@ -52,8 +54,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||
|
||||
buildHtml(head):
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=21")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
|
||||
|
||||
if theme.len > 0:
|
||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||
@@ -66,8 +68,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||
href=opensearchUrl)
|
||||
|
||||
if canonical.len > 0:
|
||||
link(rel="canonical", href=canonical)
|
||||
if alternate.len > 0:
|
||||
link(rel="alternate", href=alternate, title="View on X")
|
||||
|
||||
if cfg.enableRss and rss.len > 0:
|
||||
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||
@@ -119,20 +121,26 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
# this is last so images are also preloaded
|
||||
# if this is done earlier, Chrome only preloads one image for some reason
|
||||
link(rel="preload", type="font/woff2", `as`="font",
|
||||
href="/fonts/fontello.woff2?21002321", crossorigin="anonymous")
|
||||
href="/fonts/fontello.woff2?61663884", crossorigin="anonymous")
|
||||
|
||||
if prefs.hideNsfw:
|
||||
style:
|
||||
verbatim ".nsfw { display: none !important; }"
|
||||
|
||||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||
images: seq[string] = @[]; banner=""): string =
|
||||
|
||||
let canonical = getTwitterLink(req.path, req.params)
|
||||
let twitterLink = getTwitterLink(req.path, req.params)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||
rss, canonical)
|
||||
rss, twitterLink)
|
||||
|
||||
body:
|
||||
renderNavbar(cfg, req, rss, canonical)
|
||||
body(class=if prefs.verifiedBadge == "Hide all": "hide-verified-all"
|
||||
elif prefs.verifiedBadge == "Show official only": "hide-verified-blue"
|
||||
else: ""):
|
||||
renderNavbar(cfg, req, rss, twitterLink)
|
||||
|
||||
tdiv(class="container"):
|
||||
body
|
||||
|
||||
19
src/views/homepage.nim
Normal file
19
src/views/homepage.nim
Normal file
@@ -0,0 +1,19 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import timeline, profile
|
||||
import ".."/[types]
|
||||
|
||||
proc renderHomepage*(users: seq[User]; timeline: VNode; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="homepage-container")):
|
||||
tdiv(class="timeline-users-column"):
|
||||
for user in users:
|
||||
renderUser(user, prefs, path)
|
||||
|
||||
tdiv(class="timeline"):
|
||||
timeline
|
||||
|
||||
tdiv(class="following-column"):
|
||||
tdiv(class="profile-cards"):
|
||||
for user in users:
|
||||
renderUserCard(user, prefs, path)
|
||||
@@ -12,8 +12,9 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
||||
span(class="profile-stat-num"):
|
||||
text insertSep($num, ',')
|
||||
|
||||
proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="profile-card")):
|
||||
proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
|
||||
let class = if user.sensitive: "profile-card nsfw" else: "profile-card"
|
||||
buildHtml(tdiv(class=class)):
|
||||
tdiv(class="profile-card-info"):
|
||||
let
|
||||
url = getPicUrl(user.getUserPic())
|
||||
@@ -24,9 +25,12 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||
a(class="profile-card-avatar", href=url, target="_blank"):
|
||||
genImg(user.getUserPic(size))
|
||||
|
||||
tdiv(class="profile-card-tabs-name"):
|
||||
linkUser(user, class="profile-card-fullname")
|
||||
linkUser(user, class="profile-card-username")
|
||||
tdiv(class="profile-header"):
|
||||
tdiv(class="profile-card-tabs-name"):
|
||||
linkUser(user, class="profile-card-fullname")
|
||||
linkUser(user, class="profile-card-username")
|
||||
|
||||
renderFollowButton(user, prefs, path)
|
||||
|
||||
tdiv(class="profile-card-extra"):
|
||||
if user.bio.len > 0:
|
||||
@@ -109,7 +113,7 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
||||
|
||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||
tdiv(class=("profile-tab" & sticky)):
|
||||
renderUserCard(profile.user, prefs)
|
||||
renderUserCard(profile.user, prefs, path)
|
||||
if profile.photoRail.len > 0:
|
||||
renderPhotoRail(profile)
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true
|
||||
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
|
||||
|
||||
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
|
||||
buildHtml(tdiv(class="pref-group pref-input")):
|
||||
buildHtml(tdiv(class="pref-group pref-input pref-select")):
|
||||
label(`for`=pref): text label
|
||||
select(name=pref):
|
||||
for opt in options:
|
||||
@@ -89,6 +89,13 @@ proc genDate*(pref, state: string): VNode =
|
||||
input(name=pref, `type`="date", value=state)
|
||||
icon "calendar"
|
||||
|
||||
proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
|
||||
let p = placeholder
|
||||
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||
if label.len > 0:
|
||||
label(`for`=pref): text label
|
||||
input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
|
||||
|
||||
proc genImg*(url: string; class=""): VNode =
|
||||
buildHtml():
|
||||
img(src=getPicUrl(url), class=class, alt="", loading="lazy")
|
||||
@@ -100,3 +107,15 @@ proc getTabClass*(query: Query; tab: QueryKind): string =
|
||||
proc getAvatarClass*(prefs: Prefs): string =
|
||||
if prefs.squareAvatars: "avatar"
|
||||
else: "avatar round"
|
||||
|
||||
proc renderFollowButton*(user: User; prefs: Prefs; path: string): VNode =
|
||||
let
|
||||
isFollowing = user.username in prefs.following
|
||||
action = if isFollowing: "/unfollow" else: "/follow"
|
||||
text = if isFollowing: "Unfollow" else: "Follow"
|
||||
class = if isFollowing: "follow-button unfollow" else: "follow-button"
|
||||
|
||||
buildHtml(form(action=action, `method`="post", class="follow-form")):
|
||||
refererField path
|
||||
hiddenField("user", user.username)
|
||||
button(class=class, `type`="submit"): text text
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
|
||||
#end if
|
||||
#var text = stripHtml(tweet.text)
|
||||
##if unicode.runeLen(text) > 32:
|
||||
## text = unicode.runeSubStr(text, 0, 32) & "..."
|
||||
##end if
|
||||
#if unicode.runeLen(text) > 100:
|
||||
# text = unicode.runeSubStr(text, 0, 64) & "..."
|
||||
#end if
|
||||
#result &= xmltree.escape(text)
|
||||
#if result.len > 0: return
|
||||
#end if
|
||||
@@ -25,7 +25,7 @@
|
||||
#end proc
|
||||
#
|
||||
#proc getDescription(desc: string; cfg: Config): string =
|
||||
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||
#end proc
|
||||
#
|
||||
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
|
||||
@@ -51,16 +51,15 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
#let urlPrefix = getUrlPrefix(cfg)
|
||||
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
|
||||
<p>${text.replace("\n", "<br>\n")}</p>
|
||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||
# let quoteLink = getLink(get(tweet.quote))
|
||||
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
||||
#end if
|
||||
#if tweet.photos.len > 0:
|
||||
# for photo in tweet.photos:
|
||||
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
||||
# end for
|
||||
#elif tweet.video.isSome:
|
||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
||||
<a href="${urlPrefix}${tweet.getLink}">
|
||||
<br>Video<br>
|
||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
||||
</a>
|
||||
#elif tweet.gif.isSome:
|
||||
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
||||
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
||||
@@ -72,6 +71,20 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
||||
# end if
|
||||
#end if
|
||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||
# let quoteTweet = get(tweet.quote)
|
||||
# let quoteLink = urlPrefix & getLink(quoteTweet)
|
||||
<hr/>
|
||||
<blockquote>
|
||||
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
|
||||
<p>
|
||||
${renderRssTweet(quoteTweet, cfg)}
|
||||
</p>
|
||||
<footer>
|
||||
— <cite><a href="${quoteLink}">${quoteLink}</a>
|
||||
</footer>
|
||||
</blockquote>
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =
|
||||
@@ -84,6 +97,8 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
#
|
||||
# let retweet = if tweet.retweet.isSome: tweet.user.username else: ""
|
||||
# let tweet = if retweet.len > 0: tweet.retweet.get else: tweet
|
||||
# if not tweet.available: continue
|
||||
# end if
|
||||
# let link = getLink(tweet)
|
||||
# if link in links: continue
|
||||
# end if
|
||||
@@ -92,6 +107,18 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
<title>${getTitle(tweet, retweet)}</title>
|
||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
||||
# let desc = renderRssTweet(tweet, cfg).strip(chars={'\n'})
|
||||
# if retweet.len > 0:
|
||||
<description><![CDATA[
|
||||
<blockquote>
|
||||
<b>${tweet.user.fullname} (@${tweet.user.username})</b>
|
||||
<p>${desc}</p>
|
||||
<footer>— <cite><a href="${urlPrefix & link}">${urlPrefix & link}</a></footer>
|
||||
</blockquote>
|
||||
]]></description>
|
||||
# else:
|
||||
<description><![CDATA[${desc}]]></description>
|
||||
# end if
|
||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||
<guid>${urlPrefix & link}</guid>
|
||||
<link>${urlPrefix & link}</link>
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, sequtils, unicode, tables, options
|
||||
import strutils, options
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils, timeline
|
||||
import ".."/[types, query]
|
||||
|
||||
const toggles = {
|
||||
"nativeretweets": "Retweets",
|
||||
"media": "Media",
|
||||
"videos": "Videos",
|
||||
"news": "News",
|
||||
"verified": "Verified",
|
||||
"native_video": "Native videos",
|
||||
"replies": "Replies",
|
||||
"links": "Links",
|
||||
"images": "Images",
|
||||
"safe": "Safe",
|
||||
"quote": "Quotes",
|
||||
"pro_video": "Pro videos"
|
||||
}.toOrderedTable
|
||||
|
||||
proc renderSearch*(): VNode =
|
||||
buildHtml(tdiv(class="panel-container")):
|
||||
tdiv(class="search-bar"):
|
||||
form(`method`="get", action="/search", autocomplete="off"):
|
||||
hiddenField("f", "tweets")
|
||||
input(`type`="text", name="q", autofocus="",
|
||||
placeholder="Search...", dir="auto")
|
||||
button(`type`="submit"): icon "search"
|
||||
discard
|
||||
|
||||
proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||
let link = "/" & username
|
||||
@@ -51,43 +31,6 @@ proc renderSearchTabs*(query: Query): VNode =
|
||||
q.kind = users
|
||||
a(href=("?" & genQueryUrl(q))): text "Users"
|
||||
|
||||
proc isPanelOpen(q: Query): bool =
|
||||
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
|
||||
@[q.near, q.until, q.since].anyIt(it.len > 0))
|
||||
|
||||
proc renderSearchPanel*(query: Query): VNode =
|
||||
let user = query.fromUser.join(",")
|
||||
let action = if user.len > 0: &"/{user}/search" else: "/search"
|
||||
buildHtml(form(`method`="get", action=action,
|
||||
class="search-field", autocomplete="off")):
|
||||
hiddenField("f", "tweets")
|
||||
genInput("q", "", query.text, "Enter search...", class="pref-inline")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
input(id="search-panel-toggle", `type`="checkbox", checked=isPanelOpen(query))
|
||||
label(`for`="search-panel-toggle"): icon "down"
|
||||
|
||||
tdiv(class="search-panel"):
|
||||
for f in @["filter", "exclude"]:
|
||||
span(class="search-title"): text capitalize(f)
|
||||
tdiv(class="search-toggles"):
|
||||
for k, v in toggles:
|
||||
let state =
|
||||
if f == "filter": k in query.filters
|
||||
else: k in query.excludes
|
||||
genCheckbox(&"{f[0]}-{k}", v, state)
|
||||
|
||||
tdiv(class="search-row"):
|
||||
tdiv:
|
||||
span(class="search-title"): text "Time range"
|
||||
tdiv(class="date-range"):
|
||||
genDate("since", query.since)
|
||||
span(class="search-title"): text "-"
|
||||
genDate("until", query.until)
|
||||
tdiv:
|
||||
span(class="search-title"): text "Near"
|
||||
genInput("near", "", query.near, "Location...", autofocus=false)
|
||||
|
||||
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||
pinned=none(Tweet)): VNode =
|
||||
let query = results.query
|
||||
@@ -100,21 +43,42 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||
renderProfileTabs(query, query.fromUser.join(","))
|
||||
|
||||
if query.fromUser.len == 0 or query.kind == tweets:
|
||||
tdiv(class="timeline-header"):
|
||||
renderSearchPanel(query)
|
||||
discard
|
||||
|
||||
if query.fromUser.len == 0:
|
||||
renderSearchTabs(query)
|
||||
|
||||
renderTimelineTweets(results, prefs, path, pinned)
|
||||
|
||||
proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
tdiv(class="timeline-header"):
|
||||
form(`method`="get", action="/search", class="search-field", autocomplete="off"):
|
||||
hiddenField("f", "users")
|
||||
genInput("q", "", results.query.text, "Enter username...", class="pref-inline")
|
||||
button(`type`="submit"): icon "search"
|
||||
proc renderHomepageTabs*(query: Query): VNode =
|
||||
buildHtml(ul(class="tab")):
|
||||
li(class=if query.kind == tweets: "tab-item active" else: "tab-item"):
|
||||
a(href="/?f=tweets"): text "Tweets"
|
||||
|
||||
li(class=if query.kind == replies: "tab-item active" else: "tab-item"):
|
||||
a(href="/?f=replies"): text "Tweets & Replies"
|
||||
|
||||
proc renderHomepageTimeline*(results: Timeline; prefs: Prefs; path: string): VNode =
|
||||
let query = results.query
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
renderHomepageTabs(query)
|
||||
|
||||
renderTimelineTweets(results, prefs, path)
|
||||
|
||||
proc renderDefaultTimeline*(results: Timeline; prefs: Prefs; path: string): VNode =
|
||||
let query = results.query
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
tdiv(class="timeline-header timeline-default"):
|
||||
h2(class="timeline-default-message"):
|
||||
text "Follow people to populate your "
|
||||
strong: text "own"
|
||||
text " feed"
|
||||
|
||||
renderHomepageTabs(query)
|
||||
|
||||
renderTimelineTweets(results, prefs, path)
|
||||
|
||||
proc renderUserSearch*(results: Result[User]; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
renderSearchTabs(results.query)
|
||||
renderTimelineUsers(results, prefs)
|
||||
renderTimelineUsers(results, prefs, path)
|
||||
|
||||
58
src/views/search_panel.nim
Normal file
58
src/views/search_panel.nim
Normal file
@@ -0,0 +1,58 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, sequtils, unicode, tables
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils
|
||||
import ".."/[types]
|
||||
|
||||
const toggles = {
|
||||
"nativeretweets": "Retweets",
|
||||
"media": "Media",
|
||||
"videos": "Videos",
|
||||
"news": "News",
|
||||
"verified": "Verified",
|
||||
"native_video": "Native videos",
|
||||
"replies": "Replies",
|
||||
"links": "Links",
|
||||
"images": "Images",
|
||||
"safe": "Safe",
|
||||
"quote": "Quotes",
|
||||
"pro_video": "Pro videos"
|
||||
}.toOrderedTable
|
||||
|
||||
proc isPanelOpen(q: Query): bool =
|
||||
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.anyIt(it != "nativeretweets") or
|
||||
@[q.near, q.until, q.since].anyIt(it.len > 0))
|
||||
|
||||
proc renderSearchPanel*(query: Query): VNode =
|
||||
let user = query.fromUser.join(",")
|
||||
let action = if user.len > 0: &"/{user}/search" else: "/search"
|
||||
buildHtml(form(`method`="get", action=action,
|
||||
class="search-field", autocomplete="off")):
|
||||
hiddenField("f", "tweets")
|
||||
genInput("q", "", query.text, "Enter search...", class="pref-inline")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
input(id="search-panel-toggle", `type`="checkbox", checked=isPanelOpen(query))
|
||||
label(`for`="search-panel-toggle"): icon "down"
|
||||
|
||||
tdiv(class="search-panel"):
|
||||
for f in @["filter", "exclude"]:
|
||||
span(class="search-title"): text capitalize(f)
|
||||
tdiv(class="search-toggles"):
|
||||
for k, v in toggles:
|
||||
let state =
|
||||
if f == "filter": k in query.filters
|
||||
else: k in query.excludes
|
||||
genCheckbox(&"{f[0]}-{k}", v, state)
|
||||
|
||||
tdiv(class="search-row"):
|
||||
tdiv:
|
||||
span(class="search-title"): text "Time range"
|
||||
tdiv(class="date-range"):
|
||||
genDate("since", query.since)
|
||||
span(class="search-title"): text "-"
|
||||
genDate("until", query.until)
|
||||
tdiv:
|
||||
span(class="search-title"): text "Near"
|
||||
genInput("near", "", query.near, "Location...", autofocus=false)
|
||||
@@ -55,8 +55,9 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
|
||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||
index=i, last=(i == thread.high), showThread=show)
|
||||
|
||||
proc renderUser(user: User; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline-item")):
|
||||
proc renderUser*(user: User; prefs: Prefs; path: string): VNode =
|
||||
let class = if user.sensitive: "timeline-item nsfw" else: "timeline-item"
|
||||
buildHtml(tdiv(class=class, data-username=user.username)):
|
||||
a(class="tweet-link", href=("/" & user.username))
|
||||
tdiv(class="tweet-body profile-result"):
|
||||
tdiv(class="tweet-header"):
|
||||
@@ -66,6 +67,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
|
||||
tdiv(class="tweet-name-row"):
|
||||
tdiv(class="fullname-and-username"):
|
||||
linkUser(user, class="fullname")
|
||||
renderFollowButton(user, prefs, path)
|
||||
linkUser(user, class="username")
|
||||
|
||||
tdiv(class="tweet-content media-body", dir="auto"):
|
||||
@@ -78,7 +80,7 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
|
||||
|
||||
if results.content.len > 0:
|
||||
for user in results.content:
|
||||
renderUser(user, prefs)
|
||||
renderUser(user, prefs, path)
|
||||
if results.bottom.len > 0:
|
||||
renderMore(results.query, results.bottom)
|
||||
renderToTop()
|
||||
|
||||
@@ -178,14 +178,12 @@ func formatStat(stat: int): string =
|
||||
if stat > 0: insertSep($stat, ',')
|
||||
else: ""
|
||||
|
||||
proc renderStats(stats: TweetStats; views: string): VNode =
|
||||
proc renderStats(stats: TweetStats): VNode =
|
||||
buildHtml(tdiv(class="tweet-stats")):
|
||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
||||
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
||||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||||
if views.len > 0:
|
||||
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
||||
span(class="tweet-stat"): icon "views", formatStat(stats.views)
|
||||
|
||||
proc renderReply(tweet: Tweet): VNode =
|
||||
buildHtml(tdiv(class="replying-to")):
|
||||
@@ -273,8 +271,11 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
if index == -1 or last:
|
||||
divClass = "thread-last " & class
|
||||
|
||||
if tweet.sensitive:
|
||||
divClass.add " nsfw"
|
||||
|
||||
if not tweet.available:
|
||||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
|
||||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
|
||||
tdiv(class="unavailable-box"):
|
||||
if tweet.tombstone.len > 0:
|
||||
text tweet.tombstone
|
||||
@@ -296,12 +297,11 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
tweet = tweet.retweet.get
|
||||
retweet = fullTweet.user.fullname
|
||||
|
||||
buildHtml(tdiv(class=("timeline-item " & divClass))):
|
||||
buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
|
||||
if not mainTweet:
|
||||
a(class="tweet-link", href=getLink(tweet))
|
||||
|
||||
tdiv(class="tweet-body"):
|
||||
var views = ""
|
||||
renderHeader(tweet, retweet, pinned, prefs)
|
||||
|
||||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||||
@@ -325,10 +325,8 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
renderAlbum(tweet)
|
||||
elif tweet.video.isSome:
|
||||
renderVideo(tweet.video.get(), prefs, path)
|
||||
views = tweet.video.get().views
|
||||
elif tweet.gif.isSome:
|
||||
renderGif(tweet.gif.get(), prefs)
|
||||
views = "GIF"
|
||||
|
||||
if tweet.poll.isSome:
|
||||
renderPoll(tweet.poll.get())
|
||||
@@ -336,14 +334,13 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
if tweet.quote.isSome:
|
||||
renderQuote(tweet.quote.get(), prefs, path)
|
||||
|
||||
if mainTweet:
|
||||
p(class="tweet-published"): text &"{getTime(tweet)}"
|
||||
let published = if mainTweet: getTime(tweet) else: ""
|
||||
|
||||
if tweet.mediaTags.len > 0:
|
||||
renderMediaTags(tweet.mediaTags)
|
||||
|
||||
if not prefs.hideTweetStats:
|
||||
renderStats(tweet.stats, views)
|
||||
renderStats(tweet.stats)
|
||||
|
||||
if showThread:
|
||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
||||
|
||||
@@ -11,12 +11,7 @@ card = [
|
||||
['voidtarget/status/1094632512926605312',
|
||||
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
|
||||
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
|
||||
'gist.github.com', True],
|
||||
|
||||
['nim_lang/status/1082989146040340480',
|
||||
'Nim in 2018: A short recap',
|
||||
'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.',
|
||||
'nim-lang.org', True]
|
||||
'gist.github.com', True]
|
||||
]
|
||||
|
||||
no_thumb = [
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
#!/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]
|
||||
python3 tools/create_session_browser.py <username> <password> [totp_seed] [--append sessions.jsonl] [--headless]
|
||||
|
||||
Examples:
|
||||
# Output to terminal
|
||||
python3 tools/get_web_session.py myusername mypassword TOTP_BASE32_SECRET
|
||||
python3 tools/create_session_browser.py myusername mypassword TOTP_SECRET
|
||||
|
||||
# Append to sessions.jsonl
|
||||
python3 tools/get_web_session.py myusername mypassword TOTP_SECRET --append sessions.jsonl
|
||||
python3 tools/create_session_browser.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
|
||||
python3 tools/create_session_browser.py myusername mypassword TOTP_SECRET --headless
|
||||
|
||||
Output:
|
||||
{"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."}
|
||||
@@ -97,7 +94,7 @@ async def login_and_get_cookies(username, password, totp_seed=None, headless=Fal
|
||||
|
||||
async def main():
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: python3 twitter-auth.py username password [totp_seed] [--append sessions.jsonl] [--headless]')
|
||||
print('Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]')
|
||||
sys.exit(1)
|
||||
|
||||
username = sys.argv[1]
|
||||
328
tools/create_session_curl.py
Normal file
328
tools/create_session_curl.py
Normal file
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Requirements:
|
||||
pip install curl_cffi pyotp
|
||||
|
||||
Usage:
|
||||
python3 tools/create_session_curl.py <username> <password> [totp_seed] [--append sessions.jsonl]
|
||||
|
||||
Examples:
|
||||
# Output to terminal
|
||||
python3 tools/create_session_curl.py myusername mypassword TOTP_SECRET
|
||||
|
||||
# Append to sessions.jsonl
|
||||
python3 tools/create_session_curl.py myusername mypassword TOTP_SECRET --append sessions.jsonl
|
||||
|
||||
Output:
|
||||
{"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."}
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import pyotp
|
||||
from curl_cffi import requests
|
||||
|
||||
BEARER_TOKEN = "AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
|
||||
BASE_URL = "https://api.x.com/1.1/onboarding/task.json"
|
||||
GUEST_ACTIVATE_URL = "https://api.x.com/1.1/guest/activate.json"
|
||||
|
||||
# Subtask versions required by API
|
||||
SUBTASK_VERSIONS = {
|
||||
"action_list": 2, "alert_dialog": 1, "app_download_cta": 1,
|
||||
"check_logged_in_account": 2, "choice_selection": 3,
|
||||
"contacts_live_sync_permission_prompt": 0, "cta": 7, "email_verification": 2,
|
||||
"end_flow": 1, "enter_date": 1, "enter_email": 2, "enter_password": 5,
|
||||
"enter_phone": 2, "enter_recaptcha": 1, "enter_text": 5, "generic_urt": 3,
|
||||
"in_app_notification": 1, "interest_picker": 3, "js_instrumentation": 1,
|
||||
"menu_dialog": 1, "notifications_permission_prompt": 2, "open_account": 2,
|
||||
"open_home_timeline": 1, "open_link": 1, "phone_verification": 4,
|
||||
"privacy_options": 1, "security_key": 3, "select_avatar": 4,
|
||||
"select_banner": 2, "settings_list": 7, "show_code": 1, "sign_up": 2,
|
||||
"sign_up_review": 4, "tweet_selection_urt": 1, "update_users": 1,
|
||||
"upload_media": 1, "user_recommendations_list": 4,
|
||||
"user_recommendations_urt": 1, "wait_spinner": 3, "web_modal": 1
|
||||
}
|
||||
|
||||
|
||||
def get_base_headers(guest_token=None):
|
||||
"""Build base headers for API requests."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {BEARER_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "en-US",
|
||||
"X-Twitter-Client-Language": "en-US",
|
||||
"Origin": "https://x.com",
|
||||
"Referer": "https://x.com/",
|
||||
}
|
||||
if guest_token:
|
||||
headers["X-Guest-Token"] = guest_token
|
||||
return headers
|
||||
|
||||
|
||||
def get_cookies_dict(session):
|
||||
"""Extract cookies from session."""
|
||||
return session.cookies.get_dict() if hasattr(session.cookies, 'get_dict') else dict(session.cookies)
|
||||
|
||||
|
||||
def make_request(session, headers, flow_token, subtask_data, print_msg):
|
||||
"""Generic request handler for flow steps."""
|
||||
print(f"[*] {print_msg}...", file=sys.stderr)
|
||||
|
||||
payload = {
|
||||
"flow_token": flow_token,
|
||||
"subtask_inputs": [subtask_data] if isinstance(subtask_data, dict) else subtask_data
|
||||
}
|
||||
|
||||
response = session.post(BASE_URL, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
new_flow_token = data.get('flow_token')
|
||||
if not new_flow_token:
|
||||
raise Exception(f"Failed to get flow token: {print_msg}")
|
||||
|
||||
return new_flow_token, data
|
||||
|
||||
|
||||
def get_guest_token(session):
|
||||
"""Get guest token for unauthenticated requests."""
|
||||
print("[*] Getting guest token...", file=sys.stderr)
|
||||
response = session.post(GUEST_ACTIVATE_URL, headers={"Authorization": f"Bearer {BEARER_TOKEN}"})
|
||||
response.raise_for_status()
|
||||
|
||||
guest_token = response.json().get('guest_token')
|
||||
if not guest_token:
|
||||
raise Exception("Failed to obtain guest token")
|
||||
|
||||
print(f"[*] Got guest token: {guest_token}", file=sys.stderr)
|
||||
return guest_token
|
||||
|
||||
|
||||
def init_flow(session, guest_token):
|
||||
"""Initialize the login flow."""
|
||||
print("[*] Initializing login flow...", file=sys.stderr)
|
||||
|
||||
headers = get_base_headers(guest_token)
|
||||
payload = {
|
||||
"input_flow_data": {
|
||||
"flow_context": {
|
||||
"debug_overrides": {},
|
||||
"start_location": {"location": "manual_link"}
|
||||
},
|
||||
"subtask_versions": SUBTASK_VERSIONS
|
||||
}
|
||||
}
|
||||
|
||||
response = session.post(f"{BASE_URL}?flow_name=login", json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
flow_token = response.json().get('flow_token')
|
||||
if not flow_token:
|
||||
raise Exception("Failed to get initial flow token")
|
||||
|
||||
print("[*] Got initial flow token", file=sys.stderr)
|
||||
return flow_token, headers
|
||||
|
||||
|
||||
def submit_username(session, flow_token, headers, guest_token, username):
|
||||
"""Submit username."""
|
||||
headers = headers.copy()
|
||||
headers["X-Guest-Token"] = guest_token
|
||||
|
||||
subtask = {
|
||||
"subtask_id": "LoginEnterUserIdentifierSSO",
|
||||
"settings_list": {
|
||||
"setting_responses": [{
|
||||
"key": "user_identifier",
|
||||
"response_data": {"text_data": {"result": username}}
|
||||
}],
|
||||
"link": "next_link"
|
||||
}
|
||||
}
|
||||
|
||||
flow_token, data = make_request(session, headers, flow_token, subtask, "Submitting username")
|
||||
|
||||
# Check for denial (suspicious activity)
|
||||
if data.get('subtasks') and 'cta' in data['subtasks'][0]:
|
||||
error_msg = data['subtasks'][0]['cta'].get('primary_text', {}).get('text')
|
||||
if error_msg:
|
||||
raise Exception(f"Login denied: {error_msg}")
|
||||
|
||||
return flow_token
|
||||
|
||||
|
||||
def submit_password(session, flow_token, headers, guest_token, password):
|
||||
"""Submit password and detect if 2FA is needed."""
|
||||
headers = headers.copy()
|
||||
headers["X-Guest-Token"] = guest_token
|
||||
|
||||
subtask = {
|
||||
"subtask_id": "LoginEnterPassword",
|
||||
"enter_password": {"password": password, "link": "next_link"}
|
||||
}
|
||||
|
||||
flow_token, data = make_request(session, headers, flow_token, subtask, "Submitting password")
|
||||
|
||||
needs_2fa = any(s.get('subtask_id') == 'LoginTwoFactorAuthChallenge' for s in data.get('subtasks', []))
|
||||
if needs_2fa:
|
||||
print("[*] 2FA required", file=sys.stderr)
|
||||
|
||||
return flow_token, needs_2fa
|
||||
|
||||
|
||||
def submit_2fa(session, flow_token, headers, guest_token, totp_seed):
|
||||
"""Submit 2FA code."""
|
||||
if not totp_seed:
|
||||
raise Exception("2FA required but no TOTP seed provided")
|
||||
|
||||
code = pyotp.TOTP(totp_seed).now()
|
||||
print("[*] Generating 2FA code...", file=sys.stderr)
|
||||
|
||||
headers = headers.copy()
|
||||
headers["X-Guest-Token"] = guest_token
|
||||
|
||||
subtask = {
|
||||
"subtask_id": "LoginTwoFactorAuthChallenge",
|
||||
"enter_text": {"text": code, "link": "next_link"}
|
||||
}
|
||||
|
||||
flow_token, _ = make_request(session, headers, flow_token, subtask, "Submitting 2FA code")
|
||||
return flow_token
|
||||
|
||||
|
||||
def submit_js_instrumentation(session, flow_token, headers, guest_token):
|
||||
"""Submit JS instrumentation response."""
|
||||
headers = headers.copy()
|
||||
headers["X-Guest-Token"] = guest_token
|
||||
|
||||
subtask = {
|
||||
"subtask_id": "LoginJsInstrumentationSubtask",
|
||||
"js_instrumentation": {
|
||||
"response": '{"rf":{"a4fc506d24bb4843c48a1966940c2796bf4fb7617a2d515ad3297b7df6b459b6":121,"bff66e16f1d7ea28c04653dc32479cf416a9c8b67c80cb8ad533b2a44fee82a3":-1,"ac4008077a7e6ca03210159dbe2134dea72a616f03832178314bb9931645e4f7":-22,"c3a8a81a9b2706c6fec42c771da65a9597c537b8e4d9b39e8e58de9fe31ff239":-12},"s":"ZHYaDA9iXRxOl2J3AZ9cc23iJx-Fg5E82KIBA_fgeZFugZGYzRtf8Bl3EUeeYgsK30gLFD2jTQx9fAMsnYCw0j8ahEy4Pb5siM5zD6n7YgOeWmFFaXoTwaGY4H0o-jQnZi5yWZRAnFi4lVuCVouNz_xd2BO2sobCO7QuyOsOxQn2CWx7bjD8vPAzT5BS1mICqUWyjZDjLnRZJU6cSQG5YFIHEPBa8Kj-v1JFgkdAfAMIdVvP7C80HWoOqYivQR7IBuOAI4xCeLQEdxlGeT-JYStlP9dcU5St7jI6ExyMeQnRicOcxXLXsan8i5Joautk2M8dAJFByzBaG4wtrPhQ3QAAAZEi-_t7"}',
|
||||
"link": "next_link"
|
||||
}
|
||||
}
|
||||
|
||||
flow_token, _ = make_request(session, headers, flow_token, subtask, "Submitting JS instrumentation")
|
||||
return flow_token
|
||||
|
||||
|
||||
def complete_flow(session, flow_token, headers):
|
||||
"""Complete the login flow."""
|
||||
cookies = get_cookies_dict(session)
|
||||
|
||||
headers = headers.copy()
|
||||
headers["X-Twitter-Auth-Type"] = "OAuth2Session"
|
||||
if cookies.get('ct0'):
|
||||
headers["X-Csrf-Token"] = cookies['ct0']
|
||||
|
||||
subtask = {
|
||||
"subtask_id": "AccountDuplicationCheck",
|
||||
"check_logged_in_account": {"link": "AccountDuplicationCheck_false"}
|
||||
}
|
||||
|
||||
make_request(session, headers, flow_token, subtask, "Completing login flow")
|
||||
|
||||
|
||||
def extract_user_id(cookies_dict):
|
||||
"""Extract user ID from twid cookie."""
|
||||
twid = cookies_dict.get('twid', '').strip('"')
|
||||
|
||||
for prefix in ['u=', 'u%3D']:
|
||||
if prefix in twid:
|
||||
return twid.split(prefix)[1].split('&')[0].strip('"')
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def login_and_get_cookies(username, password, totp_seed=None):
|
||||
"""Authenticate with X.com and extract session cookies."""
|
||||
session = requests.Session(impersonate="chrome")
|
||||
|
||||
try:
|
||||
guest_token = get_guest_token(session)
|
||||
flow_token, headers = init_flow(session, guest_token)
|
||||
flow_token = submit_js_instrumentation(session, flow_token, headers, guest_token)
|
||||
flow_token = submit_username(session, flow_token, headers, guest_token, username)
|
||||
flow_token, needs_2fa = submit_password(session, flow_token, headers, guest_token, password)
|
||||
|
||||
if needs_2fa:
|
||||
flow_token = submit_2fa(session, flow_token, headers, guest_token, totp_seed)
|
||||
|
||||
complete_flow(session, flow_token, headers)
|
||||
|
||||
cookies_dict = get_cookies_dict(session)
|
||||
cookies_dict['username'] = username
|
||||
|
||||
user_id = extract_user_id(cookies_dict)
|
||||
if user_id:
|
||||
cookies_dict['id'] = user_id
|
||||
|
||||
print("[*] Successfully authenticated", file=sys.stderr)
|
||||
return cookies_dict
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: python3 create_session_curl.py username password [totp_seed] [--append sessions.jsonl]', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
username = sys.argv[1]
|
||||
password = sys.argv[2]
|
||||
totp_seed = None
|
||||
append_file = None
|
||||
|
||||
# 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
|
||||
else:
|
||||
print('[!] Error: --append requires a filename', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not arg.startswith('--'):
|
||||
if totp_seed is None:
|
||||
totp_seed = arg
|
||||
i += 1
|
||||
else:
|
||||
print(f'[!] Warning: Unknown argument: {arg}', file=sys.stderr)
|
||||
i += 1
|
||||
|
||||
try:
|
||||
cookies = login_and_get_cookies(username, password, totp_seed)
|
||||
|
||||
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)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as error:
|
||||
print(f'[!] Error: {error}', file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,2 +1,3 @@
|
||||
nodriver>=0.48.0
|
||||
pyotp
|
||||
curl_cffi
|
||||
|
||||
Reference in New Issue
Block a user