diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index c9f0392..20a15a0 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -7,57 +7,105 @@ on: branches: - master +concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE: zedeus/nitter + jobs: tests: uses: ./.github/workflows/run-tests.yml secrets: inherit - build-docker-amd64: + + build: needs: [tests] - runs-on: buildjet-2vcpu-ubuntu-2204 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + platform: linux/amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 + - name: Prepare platform name + run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + env: + platform: ${{ matrix.platform }} + + - uses: actions/checkout@v6 + - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: version: latest + - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and push AMD64 Docker image - uses: docker/build-push-action@v3 + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 with: 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 + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + provenance: false + sbom: false + + - name: Export digest + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 with: - fetch-depth: 0 + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + # Combine the per-arch digests into one multi-arch manifest so that + # `docker pull zedeus/nitter:latest` serves the right image on any CPU. + merge: + needs: [build] + runs-on: ubuntu-24.04 + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: version: latest + - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 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 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create \ + -t ${{ env.IMAGE }}:latest \ + -t ${{ env.IMAGE }}:latest-arm64 \ + -t ${{ env.IMAGE }}:${{ github.sha }} \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + + - name: Inspect image + run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.sha }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f4639a4..33dcba5 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,24 +20,24 @@ defaults: jobs: build-test: name: Build and test - runs-on: buildjet-2vcpu-ubuntu-2204 + runs-on: ubuntu-24.04 strategy: matrix: nim: ["2.0.x", "2.2.x", "devel"] steps: - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 + uses: actions/checkout@v6 - name: Cache Nimble Dependencies id: cache-nimble - uses: buildjet/cache@v4 + uses: actions/cache@v5 with: - path: ~/.nimble - key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }} + path: | + ~/.nimble/pkgcache + ~/.nimble/packages_official.json + key: ${{ matrix.nim }}-nimble-v6-${{ hashFiles('*.nimble') }} restore-keys: | - ${{ matrix.nim }}-nimble-v2- + ${{ matrix.nim }}-nimble-v6- - name: Setup Nim uses: jiro4989/setup-nim-action@v2 @@ -47,62 +47,106 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Build Project - run: nimble build -d:release -Y + run: nimble build -Y + + - name: Upload 2.2.x build artifact + if: matrix.nim == '2.2.x' + uses: actions/upload-artifact@v6 + with: + name: nitter-linux-nim-2.2.x-${{ github.sha }} + path: | + ./nitter + if-no-files-found: error integration-test: needs: [build-test] name: Integration test - runs-on: buildjet-2vcpu-ubuntu-2204 + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + services: + redis: + image: redis:7 + ports: + - 6379:6379 + steps: + - name: Install runtime deps + run: | + sudo apt-get install -y --no-install-recommends libsass-dev libpcre3 + - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Cache pipx (poetry) + uses: actions/cache@v5 with: - fetch-depth: 0 + path: | + ~/.local/pipx + ~/.local/bin + key: pipx-poetry-${{ runner.os }} + + - name: Install poetry + env: + PIPX_HOME: ~/.local/pipx + PIPX_BIN_DIR: ~/.local/bin + run: command -v poetry >/dev/null 2>&1 || pipx install poetry + + - name: Setup Python (3.14) with Poetry cache + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: poetry + cache-dependency-path: tests/poetry.lock + + - name: Install Python deps + working-directory: tests + run: poetry sync - name: Cache Nimble Dependencies - id: cache-nimble - uses: buildjet/cache@v4 + uses: actions/cache@v5 with: - path: ~/.nimble - key: devel-nimble-v2-${{ hashFiles('*.nimble') }} + path: | + ~/.nimble/pkgcache + ~/.nimble/packages_official.json + key: 2.2.x-nimble-v6-${{ 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 + 2.2.x-nimble-v6- - name: Setup Nim uses: jiro4989/setup-nim-action@v2 with: - nim-version: devel + nim-version: 2.2.x use-nightlies: true repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build Project - run: nimble build -d:release -Y + - name: Install Nimble dependencies + run: nimble install -y --depsOnly - - name: Install SeleniumBase and Chromedriver - run: | - pip install seleniumbase - seleniumbase install chromedriver + - name: Download 2.2.x build artifact + uses: actions/download-artifact@v4 + with: + name: nitter-linux-nim-2.2.x-${{ github.sha }} + path: . - - name: Start Redis Service - uses: supercharge/redis-github-action@1.5.0 + - name: Make nitter binary executable + run: chmod +x ./nitter - 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 + sed -i 's/maxRetries = 1/maxRetries = 3/g' nitter.conf + sed -i 's/hostname = "nitter.net"/hostname = "localhost:8080"/g' nitter.conf + + nim r tools/rendermd.nim + nim r tools/gencss.nim + echo '${{ secrets.SESSIONS }}' | head -n1 echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl - name: Run Tests run: | ./nitter & - pytest -n1 tests + cd tests + poetry run pytest -n2 --rs . diff --git a/.gitignore b/.gitignore index dbd2f6b..2e52163 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ nitter.conf guest_accounts.json* sessions.json* dump.rdb +*.bak +/tools/*.json* +nimbledeps/ +nimble.paths +nimble.develop diff --git a/Dockerfile b/Dockerfile index ab442ba..251b63a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:2.2.0-alpine-regular as nim +FROM nimlang/nim:2.2.6-alpine-regular as nim LABEL maintainer="setenforce@protonmail.com" RUN apk --no-cache add libsass-dev pcre @@ -15,7 +15,7 @@ RUN nimble build -d:danger -d:lto -d:strip --mm:refc \ FROM alpine:latest WORKDIR /src/ -RUN apk --no-cache add pcre ca-certificates +RUN apk --no-cache add pcre ca-certificates openssl COPY --from=nim /src/nitter/nitter ./ COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf COPY --from=nim /src/nitter/public ./public diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 deleted file mode 100644 index 46352c7..0000000 --- a/Dockerfile.arm64 +++ /dev/null @@ -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 diff --git a/README.md b/README.md index 05c2be4..86ebd47 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,26 @@ # Nitter -[![Test Matrix](https://github.com/zedeus/nitter/workflows/Tests/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/run-tests.yml) -[![Test Matrix](https://github.com/zedeus/nitter/workflows/Docker/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml) -[![License](https://img.shields.io/github/license/zedeus/nitter?style=flat)](#license) - > [!NOTE] -> Running a Nitter instance now requires real accounts, since Twitter removed the previous methods. \ -> This does not affect users. \ -> For instructions on how to obtain session tokens, see [Creating session tokens](https://github.com/zedeus/nitter/wiki/Creating-session-tokens). +> On 24 August 2026 cease and desist letters were sent by X Corp. demanding a permanent takedown of Nitter instances and the project's repository. A free and open source alternative Twitter front-end focused on privacy and performance. \ Inspired by the [Invidious](https://github.com/iv-org/invidious) project. +## Donations + +**Liberapay**: https://liberapay.com/zedeus
+**Patreon**: https://patreon.com/nitter
+**Ko-fi**: https://ko-fi.com/zedeus
+**BTC**: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55
+**ETH**: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460
+**XMR**: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
+**SOL**: FF5bheiD5AqPEdc3eyjymJ8AoMRF1hS78Ht6FiSZZF1t
+**$Nitter**: 4fSxCKc91ELQYVdv3tmHW8R15KoALPwEngyoQe1Xpump
+**ZEC**: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt + +## Features + - No JavaScript or ads - All requests go through the backend, client never talks to Twitter - Prevents Twitter from tracking your IP or JavaScript fingerprint @@ -23,17 +31,6 @@ Inspired by the [Invidious](https://github.com/iv-org/invidious) project. - Mobile support (responsive design) - AGPLv3 licensed, no proprietary instances permitted -
-Donations -Liberapay: https://liberapay.com/zedeus
-Patreon: https://patreon.com/nitter
-BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55
-ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460
-XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
-SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW
-ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt -
- ## Roadmap - Embeds @@ -104,9 +101,9 @@ along with the scss and md files. # su nitter $ git clone https://github.com/zedeus/nitter $ cd nitter -$ nimble build -d:danger --mm:refc -$ nimble scss -$ nimble md +$ nimble -l build -d:danger --mm:refc +$ nimble -l scss +$ nimble -l md $ cp nitter.example.conf nitter.conf ``` @@ -123,12 +120,23 @@ performance reasons. Page for the Docker image: https://hub.docker.com/r/zedeus/nitter -#### NOTE: For ARM64 support, please use the separate ARM64 docker image: [`zedeus/nitter:latest-arm64`](https://hub.docker.com/r/zedeus/nitter/tags). +#### NOTE: The published image is multi-arch — `zedeus/nitter:latest` runs natively on both `amd64` and `arm64`. To run Nitter with Docker, you'll need to install and run Redis separately before you can run the container. See below for how to also run Redis using Docker. +First create your config file. The Docker commands mount it into the container, +so it has to exist on the host beforehand. If you've cloned the repo: + +```bash +cp nitter.example.conf nitter.conf +``` + +If you're using the prebuilt image without a local clone, download +[`nitter.example.conf`](https://raw.githubusercontent.com/zedeus/nitter/master/nitter.example.conf) +and save it as `nitter.conf` instead. + To build and run Nitter in Docker: ```bash @@ -136,8 +144,6 @@ docker build -t nitter:latest . docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest ``` -Note: For ARM64, use this Dockerfile: [`Dockerfile.arm64`](https://github.com/zedeus/nitter/blob/master/Dockerfile.arm64). - A prebuilt Docker image is provided as well: ```bash @@ -151,8 +157,11 @@ Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run docker-compose up -d ``` -Note the Docker commands expect a `nitter.conf` file in the directory you run -them. +Note the Docker commands mount `nitter.conf` (and `sessions.jsonl` for +docker-compose) from the directory you run them in. If a mounted file doesn't +exist, Docker silently creates a directory in its place and the container fails +with `not a directory: Are you trying to mount a directory onto a file`. Remove +that directory and create the file as shown above. ### systemd @@ -197,3 +206,5 @@ lines). If you're running the Docker image, you can do this: Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org). You can email me at zedeus@pm.me if you wish to contact me personally. + +For legal inquiries, contact legal@poast.org diff --git a/docker-compose.yml b/compose.yml similarity index 98% rename from docker-compose.yml rename to compose.yml index 3d75751..72d5a96 100644 --- a/docker-compose.yml +++ b/compose.yml @@ -1,5 +1,3 @@ -version: "3" - services: nitter: diff --git a/config.nims b/config.nims index 4a7af27..3ee4842 100644 --- a/config.nims +++ b/config.nims @@ -11,3 +11,7 @@ warning("HoleEnumConv", off) hint("XDeclaredButNotUsed", off) hint("XCannotRaiseY", off) hint("User", off) +# begin Nimble config (version 2) +when withDir(thisDir(), system.fileExists("nimble.paths")): + include "nimble.paths" +# end Nimble config diff --git a/nitter.example.conf b/nitter.example.conf index bddb9a4..4e040f8 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -1,31 +1,42 @@ [Server] -hostname = "nitter.net" # for generating links, change this to your own domain/ip +hostname = "nitter.net" # for generating links, change this to your own domain/ip title = "nitter" address = "0.0.0.0" port = 8080 -https = false # disable to enable cookies when not using https +https = false # disable to enable cookies when not using https httpMaxConnections = 100 staticDir = "./public" [Cache] -listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) -rssMinutes = 10 # how long to cache rss queries -redisHost = "localhost" # Change to "nitter-redis" if using docker-compose +listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) +rssMinutes = 10 # how long to cache rss queries +redisHost = "localhost" # Change to "nitter-redis" if using docker-compose redisPort = 6379 redisPassword = "" -redisConnections = 20 # minimum open connections in pool +redisConnections = 20 # minimum open connections in pool redisMaxConnections = 30 # new connections are opened when none are available, but if the pool size # goes above this, they're closed when released. don't worry about this unless # you receive tons of requests per second [Config] -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) -proxy = "" # http/https url, SOCKS proxies are not supported +hmacKey = "secretkey" # CHANGE THIS to a unique random value (e.g. `openssl rand -hex 32`); signs media urls +base64Media = false # use base64 encoding for proxied media urls +enableRSS = true # master switch, set to false to disable all RSS feeds +enableRSSUserTweets = true # /@user/rss +enableRSSUserReplies = true # /@user/with_replies/rss +enableRSSUserMedia = true # /@user/media/rss +enableRSSUserArticles = true # /@user/articles/rss +enableRSSSearch = true # /search/rss and /@user/search/rss +enableRSSList = true # list RSS feeds +enableDebug = false # enable request logs and debug endpoints (/.sessions) +proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" +apiProxy = "" # nitter-proxy host, e.g. localhost:7000 +disableTid = false # enable this if cookie-based auth is failing +maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions +maxRetries = 1 # max number of retries on rate limit errors +retryDelayMs = 150 # delay in ms between retries # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] diff --git a/nitter.nimble b/nitter.nimble index 7ff8196..b36f498 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -11,24 +11,23 @@ bin = @["nitter"] # Dependencies requires "nim >= 2.0.0" -requires "jester#baca3f" -requires "karax#5cf360c" -requires "sass#7dfdd03" -requires "nimcrypto#a079df9" -requires "markdown#158efe3" +requires "jester == 0.6.0" +requires "karax == 1.5.0" +requires "sass == 0.2.0" +requires "nimcrypto == 0.7.3" +requires "markdown == 0.8.8" requires "packedjson#9e6fbb6" -requires "supersnappy#6c94198" -requires "redpool#8b7c1db" -requires "https://github.com/zedeus/redis#d0a0e6f" -requires "zippy#ca5989a" -requires "flatty#e668085" -requires "jsony#1de1f08" -requires "oauth#b8c163b" +requires "supersnappy == 2.1.4" +requires "redpool == 0.2.2" +requires "zippy == 0.10.19" +requires "flatty == 0.4.0" +requires "jsony == 1.1.6" +requires "oauth == 0.11" # Tasks task scss, "Generate css": - exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss" + exec "nim r --hint[Processing]:off tools/gencss" task md, "Render md": - exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd" + exec "nim r --hint[Processing]:off tools/rendermd" diff --git a/public/css/fontello.css b/public/css/fontello.css index 2453575..8f9abad 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,53 +1,153 @@ @font-face { - font-family: 'fontello'; - 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-family: "fontello"; + src: url("/fonts/fontello.eot?59696369"); + src: + url("/fonts/fontello.eot?59696369#iefix") format("embedded-opentype"), + url("/fonts/fontello.woff2?59696369") format("woff2"), + url("/fonts/fontello.woff?59696369") format("woff"), + url("/fonts/fontello.ttf?59696369") format("truetype"), + url("/fonts/fontello.svg?59696369#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; speak: never; - + display: inline-block; text-decoration: inherit; width: 1em; + margin-right: 0.2em; text-align: center; /* For safety - reset parent styles, that can break glyph codes*/ font-variant: normal; text-transform: none; - + /* fix buttons height, for twitter bootstrap */ line-height: 1em; - + /* Font smoothing. That was taken from TWBS */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -.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: '\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'; } /* '' */ +.icon-group:before { + content: "\e0c3"; +} + +/* '' */ +.icon-views:before { + content: "\e800"; +} + +/* '' */ +.icon-heart:before { + content: "\e801"; +} + +/* '' */ +.icon-quote:before { + content: "\e802"; +} + +/* '' */ +.icon-comment:before { + content: "\e803"; +} + +/* '' */ +.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: "\e80c"; +} + +/* '' */ +.icon-search:before { + content: "\e80d"; +} + +/* '' */ +.icon-pin:before { + content: "\e80e"; +} + +/* '' */ +.icon-cog:before { + content: "\e80f"; +} + +/* '' */ +.icon-rss:before { + content: "\e810"; +} + +/* '' */ +.icon-ok:before { + content: "\e811"; +} + +/* '' */ +.icon-attention-circled:before { + content: "\e812"; +} + +/* '' */ +.icon-download-alt:before { + content: "\e813"; +} + +/* '' */ +.icon-circle:before { + content: "\f111"; +} + +/* '' */ +.icon-info:before { + content: "\f128"; +} + +/* '' */ +.icon-bird:before { + content: "\f309"; +} + +/* '' */ diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index 2b2982a..a5b8b1c 100644 Binary files a/public/fonts/fontello.eot and b/public/fonts/fontello.eot differ diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index 2a64343..5dc65a8 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -1,11 +1,13 @@ -Copyright (C) 2025 by original authors @ fontello.com +Copyright (C) 2026 by original authors @ fontello.com + + @@ -14,8 +16,6 @@ - - @@ -40,6 +40,14 @@ + + + + + + + + diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index ef775f8..a2b972e 100644 Binary files a/public/fonts/fontello.ttf and b/public/fonts/fontello.ttf differ diff --git a/public/fonts/fontello.woff b/public/fonts/fontello.woff index 63c3c23..65508ca 100644 Binary files a/public/fonts/fontello.woff and b/public/fonts/fontello.woff differ diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 index b7541f0..a8d96da 100644 Binary files a/public/fonts/fontello.woff2 and b/public/fonts/fontello.woff2 differ diff --git a/public/js/embedResize.js b/public/js/embedResize.js new file mode 100644 index 0000000..3fb05a0 --- /dev/null +++ b/public/js/embedResize.js @@ -0,0 +1,34 @@ +(function () { + var embed = document.querySelector(".embed-wrapper, .embed-video"); + if (!embed) return; + + var video = embed.querySelector("video"); + if (video) { + video.onplay = function () { + embed.classList.add("video-playing"); + }; + video.onpause = video.onended = function () { + embed.classList.remove("video-playing"); + }; + } + + var lastHeight = 0; + + function sendHeight() { + var h = embed.offsetHeight; + if (h !== lastHeight && h > 0) { + lastHeight = h; + window.parent.postMessage(["resizeIframe", { h: h }], "*"); + } + } + + // MessageChannel height request (used by oEmbed) + window.addEventListener("message", function (e) { + if (e.source === window.parent && e.ports && e.ports[0]) { + e.ports[0].postMessage(embed.offsetHeight); + } + }); + + window.addEventListener("load", sendHeight); + new ResizeObserver(sendHeight).observe(embed); +})(); diff --git a/public/js/hlsPlayback.js b/public/js/hlsPlayback.js index 5cd46a6..5970011 100644 --- a/public/js/hlsPlayback.js +++ b/public/js/hlsPlayback.js @@ -1,25 +1,30 @@ // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // SPDX-License-Identifier: AGPL-3.0-only -function playVideo(overlay) { - const video = overlay.parentElement.querySelector('video'); - const url = video.getAttribute("data-url"); - video.setAttribute("controls", ""); +function playMedia(overlay, tagName) { + const media = overlay.parentElement.querySelector(tagName); + const url = media.getAttribute("data-url"); + const startTime = parseFloat(media.getAttribute("data-start") || "0"); + media.setAttribute("controls", ""); overlay.style.display = "none"; if (Hls.isSupported()) { var hls = new Hls({autoStartLoad: false}); hls.loadSource(url); - hls.attachMedia(video); + hls.attachMedia(media); hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.loadLevel = hls.levels.length - 1; - hls.startLoad(); - video.play(); + hls.startLoad(startTime); + media.play(); }); - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = url; - video.addEventListener('canplay', function() { - video.play(); + } else if (media.canPlayType('application/vnd.apple.mpegurl')) { + media.src = url; + media.addEventListener('canplay', function() { + if (startTime > 0) media.currentTime = startTime; + media.play(); }); } } + +function playVideo(overlay) { playMedia(overlay, 'video'); } +function playAudio(overlay) { playMedia(overlay, 'audio'); } // @license-end diff --git a/public/js/infiniteScroll.js b/public/js/infiniteScroll.js index be27e0c..f79912f 100644 --- a/public/js/infiniteScroll.js +++ b/public/js/infiniteScroll.js @@ -1,77 +1,225 @@ // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // SPDX-License-Identifier: AGPL-3.0-only + function insertBeforeLast(node, elem) { - node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); + node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); } function getLoadMore(doc) { - return doc.querySelector(".show-more:not(.timeline-item)"); + return doc.querySelector(".show-more:not(.timeline-item)"); } -function isDuplicate(item, itemClass) { - const tweet = item.querySelector(".tweet-link"); - if (tweet == null) return false; - const href = tweet.getAttribute("href"); - return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null; +function getHrefs(selector) { + return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href"))); } -window.onload = function () { - const url = window.location.pathname; - const isTweet = url.indexOf("/status/") !== -1; - const containerClass = isTweet ? ".replies" : ".timeline"; - const itemClass = containerClass + " > div:not(.top-ref)"; +function getTweetId(item) { + const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/); + return m ? m[1] : ""; +} - var html = document.querySelector("html"); - var container = document.querySelector(containerClass); - var loading = false; +function isDuplicate(item, hrefs) { + return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href")); +} - function handleScroll(failed) { - if (loading) return; +const GAP = 10; - if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { - loading = true; - var loadMore = getLoadMore(document); - if (loadMore == null) return; +class Masonry { + constructor(container) { + this.container = container; + const colSizes = { + small: w => Math.max(130, w * 0.11), + medium: w => Math.max(190, Math.min(350, w * 0.22)), + large: w => Math.max(350, Math.min(480, w * 0.22)), + }; + const size = container.dataset.colSize || "medium"; + this._targetWidth = colSizes[size] || colSizes.medium; + this.colHeights = []; + this.colCounts = []; + this.colCount = 0; + this._lastWidth = 0; + this._colWidthCache = 0; + this._items = []; + this._revealTimer = null; + this.container.classList.add("masonry-active"); - loadMore.children[0].text = "Loading..."; + let resizeTimer; + window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => this._rebuild(), 50); + }); - var url = new URL(loadMore.children[0].href); - url.searchParams.append("scroll", "true"); + // Re-sync positions whenever images finish loading and items grow taller. + // Must be set up before _rebuild() so initial items get observed on first pass. + let syncTimer; + this._observer = window.ResizeObserver ? new ResizeObserver(() => { + clearTimeout(syncTimer); + syncTimer = setTimeout(() => this.syncHeights(), 100); + }) : null; - fetch(url.toString()).then(function (response) { - if (response.status === 404) throw "error"; + this._rebuild(); + } - return response.text(); - }).then(function (html) { - var parser = new DOMParser(); - var doc = parser.parseFromString(html, "text/html"); - loadMore.remove(); + // Reveal all items and gallery siblings (show-more, top-ref). Idempotent. + _revealAll() { + clearTimeout(this._revealTimer); + for (const item of this._items) item.classList.add("masonry-visible"); + for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer")) + el.classList.add("masonry-visible"); + } - 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); - } + // Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images. + _pickCol() { + return this.colHeights.reduce((min, h, i) => { + const m = this.colHeights[min]; + return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min; + }, 0); + } - 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; - } + // Position items using current column state. Updates colHeights, colCounts, container height. + _position(items, heights, colWidth) { + for (let i = 0; i < items.length; i++) { + const col = this._pickCol(); + items[i].style.left = `${col * (colWidth + GAP)}px`; + items[i].style.top = `${this.colHeights[col]}px`; + this.colHeights[col] += heights[i] + GAP; + this.colCounts[col]++; + } + this.container.style.height = `${Math.max(0, ...this.colHeights)}px`; + } - loading = false; - handleScroll((failed || 0) + 1); - }); - } + // Full reset and re-place all items. + _place(items, heights, n, colWidth) { + this.colHeights = new Array(n).fill(0); + this.colCounts = new Array(n).fill(0); + this.colCount = n; + this._position(items, heights, colWidth); + } + + _rebuild() { + const w = this.container.clientWidth; + const n = Math.max(1, Math.floor(w / this._targetWidth(w))); + if (n === this.colCount && w === this._lastWidth) return; + + const isFirst = this.colCount === 0; + + if (isFirst) { + this._items = [...this.container.querySelectorAll(".timeline-item")]; } - window.addEventListener("scroll", () => handleScroll()); -}; + // Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings). + this._items.sort((a, b) => { + const idA = getTweetId(a), idB = getTweetId(b); + if (idA.length !== idB.length) return idB.length - idA.length; + return idB < idA ? -1 : idB > idA ? 1 : 0; + }); + + // Pre-set widths BEFORE reading heights so measurements reflect the new column width. + const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n); + for (const item of this._items) item.style.width = `${colWidth}px`; + + this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth); + this._lastWidth = w; + + if (isFirst) { + if (this._observer) this._items.forEach(item => this._observer.observe(item)); + // Reveal immediately if all images are cached, else wait for syncHeights. + const hasUnloaded = this._items.some(item => + [...item.querySelectorAll("img")].some(img => !img.complete)); + if (hasUnloaded) { + this._revealTimer = setTimeout(() => this._revealAll(), 1000); + } else { + this._revealAll(); + } + } + } + + // Re-read actual heights and re-place all items. Fixes drift after images load. + syncHeights() { + this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache); + this._revealAll(); + } + + // Batch-add items in three phases to avoid O(N) reflows: + // 1. writes: set widths, append all — no reads, no reflows + // 2. one read: batch offsetHeight + // 3. writes: assign columns, set left/top + addAll(newItems) { + if (!newItems.length) return; + const colWidth = this._colWidthCache; + + for (const item of newItems) { + item.style.width = `${colWidth}px`; + this.container.appendChild(item); + } + + this._position(newItems, newItems.map(item => item.offsetHeight), colWidth); + this._items.push(...newItems); + + if (this._observer) newItems.forEach(item => this._observer.observe(item)); + } +} + +document.addEventListener("DOMContentLoaded", function () { + const isTweet = location.pathname.includes("/status/"); + const containerClass = isTweet ? ".replies" : ".timeline"; + const itemClass = containerClass + " > div:not(.top-ref)"; + const html = document.documentElement; + const container = document.querySelector(containerClass); + const masonryEl = container?.querySelector(".gallery-masonry"); + const masonry = masonryEl ? new Masonry(masonryEl) : null; + let loading = false; + + function handleScroll(failed) { + if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return; + + const loadMore = getLoadMore(document); + if (!loadMore) return; + loading = true; + loadMore.children[0].text = "Loading..."; + + const url = new URL(loadMore.children[0].href); + url.searchParams.append("scroll", "true"); + + fetch(url) + .then(r => { + if (r.status > 299) throw new Error("error"); + return r.text(); + }) + .then(responseText => { + const doc = new DOMParser().parseFromString(responseText, "text/html"); + loadMore.remove(); + + if (masonry) { + masonry.syncHeights(); + const newMasonry = doc.querySelector(".gallery-masonry"); + if (newMasonry) { + const knownHrefs = getHrefs(".gallery-masonry .tweet-link"); + masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs))); + } + } else { + const knownHrefs = getHrefs(`${itemClass} .tweet-link`); + for (const item of doc.querySelectorAll(itemClass)) { + if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue; + isTweet ? container.appendChild(item) : insertBeforeLast(container, item); + } + } + + loading = false; + const newLoadMore = getLoadMore(doc); + if (newLoadMore) { + isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore); + if (masonry) newLoadMore.classList.add("masonry-visible"); + } + }) + .catch(err => { + console.warn("Something went wrong.", err); + if (failed > 3) { loadMore.children[0].text = "Error"; return; } + loading = false; + handleScroll((failed || 0) + 1); + }); + } + + window.addEventListener("scroll", () => handleScroll()); +}); // @license-end diff --git a/public/js/widgets.js b/public/js/widgets.js new file mode 100644 index 0000000..7bb283a --- /dev/null +++ b/public/js/widgets.js @@ -0,0 +1,221 @@ +/** + * Drop-in replacement for Twitter's widgets.js + * Redirects twitter-tweet blockquotes to Nitter embeds + */ +(function () { + "use strict"; + + if (window.__nitterWidgets) return; + window.__nitterWidgets = true; + + var NITTER = new URL(document.currentScript.src).origin; + + var TWEET_RE = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; + var SELECTOR = "blockquote.twitter-tweet, blockquote.twitter-video"; + + var readyCallbacks = []; + var eventCallbacks = {}; + var isReady = false; + + function safeCall(fn, arg) { + try { + fn(arg); + } catch (e) { } + } + + function fireEvent(name, data) { + (eventCallbacks[name] || []).forEach(function (cb) { + safeCall(cb, data); + }); + } + + function parseTweetUrl(url) { + if (!url) return null; + var m = TWEET_RE.exec(url); + if (m) return { user: m[1], id: m[2] }; + m = url.match(/(\d{15,})/); + return m ? { user: null, id: m[1] } : null; + } + + function createIframe(tweet, opts) { + var url; + if (opts.videoOnly) { + url = NITTER + "/i/videos/tweet/" + tweet.id; + } else { + var path = tweet.user ? "/" + tweet.user : "/i"; + url = NITTER + path + "/status/" + tweet.id + "/embed"; + if (opts.theme) { + var theme = + opts.theme === "dark" + ? "nitter" + : opts.theme === "light" + ? "twitter" + : opts.theme; + url += "?theme=" + encodeURIComponent(theme); + } + } + + var iframe = document.createElement("iframe"); + iframe.src = url; + iframe.className = "nitter-embed-frame"; + iframe.loading = "lazy"; + iframe.setAttribute("allowtransparency", "true"); + iframe.setAttribute("frameborder", "0"); + iframe.setAttribute("scrolling", "no"); + if (opts.videoOnly) iframe.setAttribute("allowfullscreen", "true"); + + var width = opts.width || 550; + var margin = + opts.align === "center" + ? "10px auto" + : opts.align === "right" + ? "10px 0 10px auto" + : "10px 0"; + iframe.style.cssText = + "width:100%;max-width:" + + width + + "px;height:300px;" + + "border:none;display:block;margin:" + + margin; + + iframe.addEventListener("load", function () { + fireEvent("rendered", { target: iframe }); + }); + + return iframe; + } + + function processBlockquote(bq) { + if (bq.dataset.nitterProcessed) return false; + bq.dataset.nitterProcessed = "true"; + + var tweet = null; + var links = bq.querySelectorAll("a[href]"); + for (var i = 0; i < links.length && !tweet; i++) { + tweet = parseTweetUrl(links[i].href); + } + if (!tweet) return false; + + var d = bq.dataset; + var iframe = createIframe(tweet, { + width: d.mediaMaxWidth || d.width, + align: d.align, + theme: d.theme, + videoOnly: d.mediaMaxWidth !== undefined, + }); + + bq.style.display = "none"; + bq.parentNode.insertBefore(iframe, bq.nextSibling); + return true; + } + + function processEmbeds(root) { + var bqs = (root || document).querySelectorAll( + SELECTOR + ":not([data-nitter-processed])", + ); + for (var i = 0; i < bqs.length; i++) processBlockquote(bqs[i]); + } + + function handleResize(e) { + if (!Array.isArray(e.data) || e.data[0] !== "resizeIframe") return; + var h = e.data[1] && e.data[1].h; + if (!h || h <= 0 || h > 10000) return; // Cap at 10000px for sanity + + var frames = document.querySelectorAll("iframe.nitter-embed-frame"); + for (var i = 0; i < frames.length; i++) { + if (frames[i].contentWindow === e.source) { + frames[i].style.height = h + "px"; + return; + } + } + } + + function observeDOM() { + if (!window.MutationObserver || !document.body) return; + + function matches(el) { + return el.matches(SELECTOR) || el.querySelector(SELECTOR); + } + + new MutationObserver(function (muts) { + var found = muts.some(function (mut) { + return Array.prototype.some.call(mut.addedNodes, function (n) { + return n.nodeType === 1 && matches(n); + }); + }); + if (found) processEmbeds(); + }).observe(document.body, { childList: true, subtree: true }); + } + + function embedTweet(id, container, opts) { + if (!container) return Promise.reject("No container"); + var iframe = createIframe({ id: id, user: null }, opts || {}); + container.appendChild(iframe); + return Promise.resolve(iframe); + } + + var prevTwttr = window.twttr; + window.twttr = { + widgets: { + load: processEmbeds, + createTweet: embedTweet, + createTweetEmbed: embedTweet, + createVideo: embedTweet, + loaded: true, + }, + events: { + bind: function (name, cb) { + if (typeof cb !== "function") return; + if (!eventCallbacks[name]) eventCallbacks[name] = []; + eventCallbacks[name].push(cb); + }, + unbind: function (name, cb) { + if (!eventCallbacks[name]) return; + eventCallbacks[name] = cb + ? eventCallbacks[name].filter(function (f) { + return f !== cb; + }) + : []; + }, + }, + ready: function (cb) { + if (typeof cb !== "function") return; + if (isReady) cb(window.twttr); + else readyCallbacks.push(cb); + }, + _e: [], + }; + + // Process callbacks queued before load (twttr._e pattern) + if (prevTwttr && prevTwttr._e) { + prevTwttr._e.forEach(function (cb) { + safeCall(cb); + }); + } + + // Remove any Twitter scripts that snuck through + document + .querySelectorAll( + 'script[src*="platform.twitter.com"], script[src*="platform.x.com"]', + ) + .forEach(function (s) { + s.remove(); + }); + + function init() { + window.addEventListener("message", handleResize); + processEmbeds(); + observeDOM(); + isReady = true; + readyCallbacks.forEach(function (cb) { + safeCall(cb, window.twttr); + }); + readyCallbacks = []; + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/src/api.nim b/src/api.nim index ef3a0f9..555a699 100644 --- a/src/api.nim +++ b/src/api.nim @@ -1,8 +1,8 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables +import asyncdispatch, httpclient, strutils, sequtils, sugar import packedjson -import types, query, formatters, consts, apiutils, parser -import experimental/parser as newParser +import types, query, formatters, consts, apiutils, parser, utils +import experimental/parser # Helper to generate params object for GraphQL requests proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = @@ -11,88 +11,188 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = if fieldToggles.len > 0: result.add ("fieldToggles", fieldToggles) -proc mediaUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVariables = userMediaVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphUserMedia ? genParams(cookieVariables), - oauthUrl: graphUserMediaV2 ? genParams(oauthVariables) +proc apiUrl(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiUrl = + return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles), skipTid: skipTid) + +proc apiReq(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiReq = + let url = apiUrl(endpoint, variables, fieldToggles, skipTid) + return ApiReq(cookie: url, oauth: url) + +proc cursorParam(after: string): string = + ## JSON-escape the user-supplied cursor so it cannot break out of the GraphQL + ## variables object (same input-validation class as the #1411 media SSRF). + if after.len > 0: "\"cursor\":" & $(%after) & "," else: "" + +proc mediaUrl(id, cursor: string): ApiReq = + result = ApiReq( + cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, "100"]), + oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, "100"]) ) -proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVariables = userTweetsVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] - result = SessionAwareUrl( - # cookieUrl: graphUserTweets ? genParams(cookieVariables, fieldToggles), - oauthUrl: graphUserTweetsV2 ? genParams(oauthVariables) - ) - # might change this in the future pending testing - result.cookieUrl = result.oauthUrl +proc userTweetsUrl(id: string; cursor: string): ApiReq = + return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles) -proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVariables = userTweetsAndRepliesVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles), - oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables) +proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = + result = ApiReq( + cookie: apiUrl(graphUserTweetsAndReplies, userTweetsAndRepliesVars % [id, cursor], userTweetsFieldToggles), + oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true) ) -proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVariables = tweetDetailVariables % [id, cursor] - oauthVariables = tweetVariables % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles), - oauthUrl: graphTweet ? genParams(oauthVariables) +proc userArticlesUrl(id: string; cursor: string): ApiReq = + result = ApiReq( + cookie: apiUrl(graphUserArticles, userArticlesVars % [id, cursor], userTweetsFieldToggles), + oauth: apiUrl(graphUserArticlesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles) + ) + +proc tweetDetailUrl(id, cursor: string; mode = Relevance): ApiReq = + return apiReq(graphTweet, tweetVars % [id, cursor, $mode]) + # 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": username, "withGrokTranslatedBio": false}) + result = ApiReq( + cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles), + oauth: apiUrl(graphUserV2, $(%*{"screen_name": username})) ) proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return - let - url = graphUser ? genParams("""{"screen_name": "$1"}""" % username) - js = await fetchRaw(url, 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 - url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id) - js = await fetchRaw(url, Api.userRestId) + url = apiReq(graphUserById, userByRestIdVars % id) + js = await fetchRaw(url) result = parseGraphUser(js) +proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} = + if username.len == 0: return + let + url = apiReq(graphAboutAccount, $(%*{"screenName": username})) + js = await fetch(url) + result = parseAboutAccount(js) + +proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq = + let url = ApiUrl(endpoint: endpoint, params: params) + ApiReq(cookie: url, oauth: url) + +proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} = + if id.len == 0: return + let + req = apiReq(graphBroadcast, $(%*{"id": id})) + js = await fetch(req) + result = parseBroadcastInfo(js) + +proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} = + if mediaKey.len == 0: return + let + streamReq = restReq(restLiveStream & mediaKey) + streamJs = await fetch(streamReq) + result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr( + streamJs{"source", "location"}.getStr) + +proc getAudioSpace*(id: string): Future[AudioSpace] {.async.} = + if id.len == 0: return + let + variables = %*{ + "id": id, + "isMetatagsQuery": false, + "withReplays": true, + "withListeners": true + } + req = apiReq(graphAudioSpace, $variables) + js = await fetch(req) + result = parseAudioSpace(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: "" - js = case kind - of TimelineKind.tweets: - await fetch(userTweetsUrl(id, cursor), Api.userTweets) - of TimelineKind.replies: - await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies) - of TimelineKind.media: - await fetch(mediaUrl(id, cursor), Api.userMedia) + cursor = cursorParam(after) + url = case kind + of TimelineKind.tweets: userTweetsUrl(id, cursor) + of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) + of TimelineKind.media: mediaUrl(id, cursor) + of TimelineKind.articles: userArticlesUrl(id, cursor) + js = await fetch(url) result = parseGraphTimeline(js, after) +proc getGraphCommunity*(id: string): Future[Community] {.async.} = + if id.len == 0: return + let + url = apiReq(graphCommunity, $(%*{"communityId": id})) + js = await fetch(url) + result = parseGraphCommunity(js) + +proc getGraphCommunityTweets*(id: string; rankingMode: string; after=""): Future[Timeline] {.async.} = + if id.len == 0: return + let + cursor = cursorParam(after) + url = apiReq(graphCommunityTweets, communityTweetsVars % [id, cursor, rankingMode]) + js = await fetch(url) + result = parseGraphCommunityTimeline(js, after) + +proc getGraphCommunityMedia*(id: string; after=""): Future[Timeline] {.async.} = + if id.len == 0: return + let + cursor = cursorParam(after) + url = apiReq(graphCommunityMedia, communityMediaVars % [id, cursor]) + js = await fetch(url) + result = parseGraphCommunityTimeline(js, after) + +proc communitySliceReq(endpoint, variables: string): ApiReq = + let url = ApiUrl(endpoint: endpoint, params: @[("variables", variables)]) + ApiReq(cookie: url, oauth: url) + +proc getGraphCommunityMembers*(id: string; after=""): Future[Result[User]] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: $(%after) else: "null" + url = communitySliceReq(graphCommunityMembers, communityMembersVars % [id, cursor]) + js = await fetch(url) + result = parseGraphCommunityMembers(js, after) + +proc getGraphCommunityModerators*(id: string): Future[Result[User]] {.async.} = + if id.len == 0: return + let + url = communitySliceReq(graphCommunityModerators, communityMembersVars % [id, "null"]) + js = await fetch(url) + result = parseGraphCommunityMembers(js) + +proc getGraphCommunityHashtags*(id, hashtag: string; after=""): Future[Timeline] {.async.} = + if id.len == 0 or hashtag.len == 0: return + let + safeTag = multiReplace(hashtag, ("\"", ""), ("\\", "")) + cursor = cursorParam(after) + url = apiReq(graphCommunityHashtags, communityHashtagsVars % [id, cursor, safeTag]) + js = await fetch(url) + result = parseGraphCommunityTimeline(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: "" - url = graphListTweets ? genParams(restIdVariables % [id, cursor]) - result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets + cursor = cursorParam(after) + url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"]) + js = await fetch(url) + result = parseGraphTimeline(js, after).tweets proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list} - url = graphListBySlug ? genParams($variables) - result = parseGraphList(await fetch(url, Api.listBySlug)) + url = apiReq(graphListBySlug, $variables) + js = await fetch(url) + result = parseGraphList(js) proc getGraphList*(id: string): Future[List] {.async.} = - let - url = graphListById ? genParams("""{"listId": "$1"}""" % id) - result = parseGraphList(await fetch(url, Api.list)) + let + url = apiReq(graphListById, $(%*{"listId": id})) + js = await fetch(url) + result = parseGraphList(js) proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = if list.id.len == 0: return @@ -106,81 +206,161 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} } if after.len > 0: variables["cursor"] = % after - let url = graphListMembers ? genParams($variables) - result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) + let + url = apiReq(graphListMembers, $variables) + js = await fetchRaw(url) + result = parseGraphListMembers(js, after) + +proc getGraphUserConnections(userId: string; endpoint: string; kind: QueryKind; + after=""): Future[Result[User]] {.async.} = + if userId.len == 0: return + var variables = %*{ + "userId": userId, + "count": 20, + "includePromotedContent": false, + "withGrokTranslatedBio": true + } + if after.len > 0: + variables["cursor"] = %after + let + url = apiReq(endpoint, $variables) + js = await fetchRaw(url) + result = parseGraphFollowers(js, after, kind) + +proc getGraphFollowers*(userId: string; after=""): Future[Result[User]] {.async.} = + result = await getGraphUserConnections(userId, graphFollowers, followers, after) + +proc getGraphFollowing*(userId: string; after=""): Future[Result[User]] {.async.} = + result = await getGraphUserConnections(userId, graphFollowing, following, 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": id})) + js = await fetch(url) result = parseGraphTweetResult(js) -proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = +proc getTweetByRestId*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail) + url = apiReq(graphTweetResultByRestId, tweetByRestIdVars % id, articleFieldToggles) + js = await fetch(url) + result = parseTweetByRestId(js) + +proc getGraphTweet(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = + if id.len == 0: return + let + cursor = cursorParam(after) + js = await fetch(tweetDetailUrl(id, cursor, mode)) result = parseGraphConversation(js, id) -proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = - result = (await getGraphTweet(id, after)).replies +proc getReplies*(id, after: string; mode = Relevance): Future[Result[Chain]] {.async.} = + result = (await getGraphTweet(id, after, mode)).replies result.beginning = after.len == 0 -proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = - result = await getGraphTweet(id) +proc getTweet*(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = + result = await getGraphTweet(id, mode=mode) if after.len > 0: - result.replies = await getReplies(id, after) + result.replies = await getReplies(id, after, mode) + +proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} = + if id.len == 0: return + let + url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id) + js = await fetch(url) + result = parseGraphEditHistory(js, id) proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = - let q = genQueryParam(query) + # workaround for #1372 + let maxId = + if not after.startsWith("maxid:"): "" + else: validateNumber(after[6..^1]) + + let q = genQueryParam(query, maxId) if q.len == 0 or q == emptyQuery: return Timeline(query: query, beginning: true) + let product = + case query.kind + of top: "Top" + # profile media feeds (RSS, multi-user timelines) must stay chronological + of media: (if query.fromUser.len == 0: "Media" else: "Latest") + else: "Latest" + var variables = %*{ "rawQuery": q, - "query_source": "typedQuery", "count": 20, - "product": "Latest", - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false + "querySource": "typed_query", + "product": product, + "withGrokTranslatedBio":true, + "withQuickPromoteEligibilityTweetFields":false } - if after.len > 0: + + if after.len > 0 and maxId.len == 0: variables["cursor"] = % after - let url = graphSearchTimeline ? genParams($variables) - 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.} = + # when no more items are available the API just returns the last page in + # full. this detects that and clears the page instead. + let prefix = min(64, min(after.len, result.bottom.len)) + if prefix > 0 and maxId.len == 0 and + after[0.. 0: variables["cursor"] = % after - result.beginning = false - let url = graphSearchTimeline ? genParams($variables) - result = parseGraphSearch[User](await fetch(url, Api.search), after) + let + url = apiReq(graphSearchTimeline, $variables) + js = await fetch(url) + result = parseGraphSearch[T](js, after) result.query = query +proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] = + getGraphProductSearch[User](query, "People", after) + +proc getGraphListSearch*(query: Query; after=""): Future[Result[ListSearchResult]] = + getGraphProductSearch[ListSearchResult](query, "Lists", after) + proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return - let js = await fetch(mediaUrl(id, ""), Api.userMedia) + let js = await fetch(mediaUrl(id, "")) result = parseGraphPhotoRail(js) +proc getGraphArticle*(id: string): Future[Article] {.async.} = + if id.len == 0: return + let + url = apiReq(graphTweetResultByRestId, articleVars % id, articleFieldToggles) + json = await fetchRaw(url) + result = parseGraphArticle(json) + +proc getGraphTweetResults*(ids: seq[string]): Future[seq[Tweet]] {.async.} = + if ids.len == 0: return + let + idsJson = "[" & ids.mapIt("\"" & it & "\"").join(",") & "]" + url = apiReq(graphTweetResultsByRestIds, articleBatchVars % idsJson, articleFieldToggles) + js = await fetch(url) + result = parseGraphTweetResults(js) + proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = let client = newAsyncHttpClient(maxRedirects=0) try: diff --git a/src/apiutils.nim b/src/apiutils.nim index defffd1..b2aad58 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,16 +1,62 @@ # SPDX-License-Identifier: AGPL-3.0-only import httpclient, asyncdispatch, options, strutils, uri, times, math, tables -import jsony, packedjson, zippy, oauth1 -import types, auth, consts, parserutils, http_pool +import jsony, packedjson, zippy, oauth/oauth1 +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} + npCache = "x-np-cache" + errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest} -var pool: HttpPool +proc isCloudflareHtml*(body: string): bool = + ## Detect Cloudflare HTML error pages returned instead of JSON + if body.len < 14 or body[0] != '<': return false + body[0 ..< 14].toLowerAscii() == " from Cloudflare HTML for log diagnostics + let start = body.find("") + if start < 0: return "unknown" + let contentStart = start + 7 + let stop = body.find("", contentStart) + if stop < 0: return "unknown" + body[contentStart ..< stop].splitWhitespace().join(" ") + +var + pool: HttpPool + disableTid: bool + apiProxy: string + maxRetries: int + retryDelayMs: int + +proc setDisableTid*(disable: bool) = + disableTid = disable + +proc setMaxRetries*(n: int) = + maxRetries = n + +proc setRetryDelayMs*(ms: int) = + retryDelayMs = ms + +proc setApiProxy*(url: string) = + apiProxy = "" + if url.len > 0: + apiProxy = url.strip(chars={'/'}) & "/" + if "http" notin apiProxy: + apiProxy = "http://" & apiProxy + +proc toUrl*(req: ApiReq; sessionKind: SessionKind): Uri = + let url = case sessionKind + of oauth: req.oauth + of cookie: req.cookie + let base = case sessionKind + of oauth: "https://api.x.com" + of cookie: "https://x.com/i/api" + let prefix = if url.endpoint.startsWith("1.1/"): "" else: "graphql/" + parseUri(base) / (prefix & url.endpoint) ? url.params proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = let @@ -32,31 +78,41 @@ 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, skipTid: bool): 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", + "accept": "*/*", "accept-encoding": "gzip", "accept-language": "en-US,en;q=0.9", - "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" - }) + "content-type": "application/json", + "origin": "https://x.com", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", + "x-twitter-active-user": "yes", + "x-twitter-client-language": "en", + "priority": "u=1, i" + }, titleCase=true) case session.kind of SessionKind.oauth: - result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) + 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) + result["referer"] = "https://x.com/" + result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24"""" + result["sec-ch-ua-mobile"] = "?0" + result["sec-ch-ua-platform"] = "Windows" + result["sec-fetch-dest"] = "empty" + result["sec-fetch-mode"] = "cors" + result["sec-fetch-site"] = "same-origin" + if disableTid or skipTid or "/1.1/" in url.path: + result["authorization"] = bearerToken2 + else: + result["authorization"] = bearerToken + result["x-client-transaction-id"] = await genTid(url.path) -proc getAndValidateSession*(api: Api): Future[Session] {.async.} = - result = await getSession(api) +proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} = + result = await getSession(req) case result.kind of SessionKind.oauth: if result.oauthToken.len == 0: @@ -73,9 +129,18 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: var resp: AsyncResponse - pool.use(genHeaders(session, $url)): + let skipTid = case session.kind + of oauth: req.oauth.skipTid + of cookie: req.cookie.skipTid + let headers = await genHeaders(session, url, skipTid) + + pool.use(headers): template getContent = - resp = await c.get($url) + # TODO: this is a temporary simple implementation + if apiProxy.len > 0 and "/1.1/" notin url.path: + resp = await c.get(($url).replace("https://", apiProxy)) + else: + resp = await c.get($url) result = await resp.body getContent() @@ -84,38 +149,45 @@ template fetchImpl(result, fetchBody) {.dirty.} = badClient = true raise newException(BadClientError, "Bad client") - if resp.headers.hasKey(rlRemaining): + if resp.status == $Http404 and result.len == 0: + echo "[sessions] transient 404 (empty body), retrying: ", url.path, ", session: ", session.pretty + raise rateLimitError() + + let cacheStatus = resp.headers.getOrDefault(npCache) + if cacheStatus notin ["HIT", "STALE"] and resp.headers.hasKey(rlRemaining): let remaining = parseInt(resp.headers[rlRemaining]) reset = parseInt(resp.headers[rlReset]) limit = parseInt(resp.headers[rlLimit]) - session.setRateLimit(api, remaining, reset, limit) + session.setRateLimit(req, remaining, reset, limit) if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": result = uncompress(result, dfGzip) + if isCloudflareHtml(result): + echo "[cloudflare] ", resp.status, " (", cfTitle(result), "), API: ", url.path, ", session: ", session.pretty + raise rateLimitError() + if result.startsWith("{\"errors"): let errors = result.fromJson(Errors) if errors notin errorsToSkip: - echo "Fetch error, API: ", api, ", errors: ", errors + echo "Fetch error, API: ", url.path, ", errors: ", errors, ", session: ", session.pretty 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.pretty - session.apis[api].remaining = 0 - # rate limit hit, resets after the 15 minute window + echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty raise rateLimitError() fetchBody if resp.status == $Http400: - echo "ERROR 400, ", api, ": ", result + echo "ERROR 400, ", url.path, ": ", result, ", session: ", session.pretty raise newException(InternalError, $url) except InternalError as e: raise e @@ -130,48 +202,57 @@ template fetchImpl(result, fetchBody) {.dirty.} = finally: release(session) -template retry(bod) = - try: - bod - except RateLimitError: - echo "[sessions] Rate limited, retrying ", api, " request..." - bod +template retry(bod) {.dirty.} = + var session: Session + var retrySuccess = false + for i in 0 ..< maxRetries: + try: + session = nil + bod + retrySuccess = true + break + except RateLimitError: + let api = if session.isNil: req.cookie.endpoint + else: req.endpoint(session) + if session.isNil: + echo "[sessions] Rate limited, retrying ", api, + " request (", i, "/", maxRetries, ")..." + else: + echo "[sessions] Rate limited, retrying ", api, + " request (", i, "/", maxRetries, ")..., session: ", session.pretty + session = nil + if retryDelayMs > 0: + await sleepAsync(retryDelayMs) + if not retrySuccess: + raise rateLimitError() -proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} = +proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = retry: - var - body: string - session = await getAndValidateSession(api) + var body: string + session = await getAndValidateSession(req) - when url is SessionAwareUrl: - let url = case session.kind - of SessionKind.oauth: url.oauthUrl - of SessionKind.cookie: url.cookieUrl + let url = req.toUrl(session.kind) fetchImpl body: if body.startsWith('{') or body.startsWith('['): result = parseJson(body) else: - echo resp.status, ": ", body, " --- url: ", url + echo resp.status, ": ", body, " --- url: ", url, ", session: ", session.pretty result = newJNull() let error = result.getError if error != null and error notin errorsToSkip: - echo "Fetch error, API: ", api, ", error: ", error + echo "Fetch error, API: ", url.path, ", error: ", error, ", session: ", session.pretty if error in {expiredToken, badToken, locked}: invalidate(session) raise rateLimitError() -proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} = +proc fetchRaw*(req: ApiReq): Future[string] {.async.} = retry: - var session = await getAndValidateSession(api) - - when url is SessionAwareUrl: - let url = case session.kind - of SessionKind.oauth: url.oauthUrl - of SessionKind.cookie: url.cookieUrl + 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 + echo resp.status, ": ", result, " --- url: ", url, ", session: ", session.pretty result.setLen(0) diff --git a/src/auth.nim b/src/auth.nim index 734b43e..259c360 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -1,20 +1,28 @@ #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] +import types, consts import experimental/parser/session -# max requests at a time per session to avoid race conditions -const - maxConcurrentReqs = 2 - hourInSeconds = 60 * 60 +const hourInSeconds = 60 * 60 var sessionPool: seq[Session] enableLogging = false + # max requests at a time per session to avoid race conditions + maxConcurrentReqs = 2 + +proc setMaxConcurrentReqs*(reqs: int) = + if reqs > 0: + maxConcurrentReqs = reqs template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") +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 "" @@ -42,6 +50,8 @@ proc getSessionPoolHealth*(): JsonNode = oldest = now.int64 newest = 0'i64 average = 0'i64 + oauthTotal, cookieTotal = 0 + oauthLimited, cookieLimited = 0 for session in sessionPool: let created = snowflakeToEpoch(session.id) @@ -51,8 +61,15 @@ proc getSessionPoolHealth*(): JsonNode = oldest = created average += created + case session.kind + of oauth: inc oauthTotal + of cookie: inc cookieTotal + if session.limited: limited.incl session.id + case session.kind + of oauth: inc oauthLimited + of cookie: inc cookieLimited for api in session.apis.keys: let @@ -76,6 +93,8 @@ proc getSessionPoolHealth*(): JsonNode = "sessions": %*{ "total": sessionPool.len, "limited": limited.card, + "oauth": %*{"total": oauthTotal, "limited": oauthLimited}, + "cookie": %*{"total": cookieTotal, "limited": cookieLimited}, "oldest": $fromUnix(oldest), "newest": $fromUnix(newest), "average": $fromUnix(average) @@ -92,6 +111,7 @@ proc getSessionPoolDebug*(): JsonNode = for session in sessionPool: let sessionJson = %*{ + "kind": $session.kind, "apis": newJObject(), "pending": session.pending, } @@ -122,11 +142,12 @@ 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.pretty @@ -140,8 +161,8 @@ 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 @@ -156,24 +177,29 @@ 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 + if result.isNil: + log "no sessions available for API: ", req.cookie.endpoint + else: + log "no sessions available for API: ", req.endpoint(result), ", last tried: ", result.pretty raise noSessionsError() -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, ", ", 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: diff --git a/src/config.nim b/src/config.nim index 1b05ffe..b46a979 100644 --- a/src/config.nim +++ b/src/config.nim @@ -13,6 +13,8 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = proc getConfig*(path: string): (Config, parseCfg.Config) = var cfg = loadConfig(path) + let masterRss = cfg.get("Config", "enableRSS", true) + let conf = Config( # Server address: cfg.get("Server", "address", "0.0.0.0"), @@ -37,10 +39,20 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = hmacKey: cfg.get("Config", "hmacKey", "secretkey"), base64Media: cfg.get("Config", "base64Media", false), minTokens: cfg.get("Config", "tokenCount", 10), - enableRss: cfg.get("Config", "enableRSS", true), + enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true), + enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true), + enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true), + enableRSSUserArticles: masterRss and cfg.get("Config", "enableRSSUserArticles", true), + enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true), + enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true), enableDebug: cfg.get("Config", "enableDebug", false), proxy: cfg.get("Config", "proxy", ""), - proxyAuth: cfg.get("Config", "proxyAuth", "") + proxyAuth: cfg.get("Config", "proxyAuth", ""), + apiProxy: cfg.get("Config", "apiProxy", ""), + disableTid: cfg.get("Config", "disableTid", false), + maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2), + maxRetries: cfg.get("Config", "maxRetries", 1), + retryDelayMs: cfg.get("Config", "retryDelayMs", 150) ) return (conf, cfg) diff --git a/src/consts.nim b/src/consts.nim index 792a519..c88ae68 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -1,116 +1,108 @@ # 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* = "Gb-d6r0vxPOADdG62OEBpQ/UserByScreenName" + graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery" + graphUserById* = "xvmVfRLmnr1alc5f2dib0Q/UserByRestId" + graphUserTweetsV2* = "LE3eTyeqhBh2g-fX85O2eQ/UserWithProfileTweetsQueryV2" + graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2" + graphUserTweets* = "SXVCYB8XHSS25nzIljNtZA/UserTweets" + graphUserTweetsAndReplies* = "qUpkZU6eN8MbtQb7rC_pYg/UserTweetsAndReplies" + graphUserMedia* = "VyudDWQnr9vJNw7GasFz2g/UserMedia" + graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2" + graphUserArticles* = "ZmMjUyrTpwYfTGAdylEyMw/UserArticlesTweets" + graphUserArticlesV2* = "PsGixN38UZz2RheyayNB5Q/UserProfileArticlesTimelineQuery" + graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline" + graphTweetDetail* = "XMOz5h24KAZ86qKffKTLdQ/TweetDetail" + graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery" + graphTweetEditHistory* = "1izbuOcH_QpuMcyCxOXkAg/TweetEditHistory" + graphSearchTimeline* = "hyPfJYJ_XAtDYoslQc-Rgg/SearchTimeline" - graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" - graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" - graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" - graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" - graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" - graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" - graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" - graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" - graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" - graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" - graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" - graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" - graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" - graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" - graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" - graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" + graphListById* = "niz0TtOxL2zIcbq6_NQiNw/ListByRestId" + graphListBySlug* = "RqkWNDQpOntlxNtJa4RIoQ/ListBySlug" + graphListMembers* = "8rYmkvWQe9jRRZdy_-vkGA/ListMembers" + graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline" + graphAboutAccount* = "TzOG2twZEfhr9KmClvVVqA/AboutAccountQuery" + + graphCommunity* = "-ElI1vg3dYbttVMhBhGdLw/CommunityQuery" + graphCommunityTweets* = "EwftYyqQemkckQ0wzGM6uw/CommunityTweetsTimeline" + graphCommunityMedia* = "ESJtwnI_apuGesbJncpc0Q/CommunityMediaTimeline" + graphCommunityMembers* = "woAp_YdzAdqnWDrqLTNpAw/membersSliceTimeline_Query" + graphCommunityModerators* = "0oYT9GRiWUhrz5xoqFE9uw/moderatorsSliceTimeline_Query" + graphCommunityHashtags* = "D5EqomOIWeJnSkMhL-FLew/CommunityHashtagsTimeline" + + graphTweetResultByRestId* = "GZsN2Pc4knAoit6pXa4HSA/TweetResultByRestId" + graphTweetResultsByRestIds* = "Pho4sg8jLcrVlMeclMayrg/TweetResultsByRestIds" + + graphBroadcast* = "RG6wSogandh6WPIzxW9aag/BroadcastQuery" + graphAudioSpace* = "Bh0L6azTQoMs9rJKeCF4wQ/AudioSpaceById" + restLiveStream* = "1.1/live_video_stream/status/" + + graphFollowers* = "JNyQdTISpzCkj_1fqxDvFg/Followers" + graphFollowing* = "qGZZDF3mp91q7X22s3HxpA/Following" 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": 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": true, - "longform_notetweets_rich_text_read_enabled": true, - "longform_notetweets_richtext_consumption_enabled": true, - "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": true, - "responsive_web_media_download_video_enabled": false, - "responsive_web_text_conversations_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": true, - "subscriptions_verification_info_enabled": true, - "subscriptions_verification_info_reason_enabled": true, - "subscriptions_verification_info_verified_since_enabled": true, - "super_follow_badge_privacy_enabled": false, - "super_follow_exclusive_tweet_notifications_enabled": false, - "super_follow_tweet_api_enabled": false, - "super_follow_user_api_enabled": false, - "tweet_awards_web_tipping_enabled": false, - "tweet_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, + "rweb_video_screen_enabled": false, + "rweb_cashtags_enabled": true, + "profile_label_improvements_pcf_label_in_post_enabled": true, + "responsive_web_profile_redirect_enabled": false, + "rweb_tipjar_consumption_enabled": false, "verified_phone_label_enabled": false, - "vibe_api_enabled": false, - "view_counts_everywhere_api_enabled": true, + "creator_subscriptions_tweet_preview_api_enabled": true, + "responsive_web_graphql_timeline_navigation_enabled": true, + "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "premium_content_api_read_enabled": false, "communities_web_enable_tweet_community_results_fetch": true, - "responsive_web_jetfuel_frame": true, + "c9s_tweet_anatomy_moderator_badge_enabled": true, + "c9s_list_members_action_api_enabled": false, + "c9s_superc9s_indication_enabled": false, "responsive_web_grok_analyze_button_fetch_trends_enabled": false, + "responsive_web_grok_analyze_post_followups_enabled": true, + "rweb_cashtags_composer_attachment_enabled": true, + "responsive_web_jetfuel_frame": true, + "responsive_web_grok_share_attachment_enabled": true, + "responsive_web_grok_annotations_enabled": true, + "articles_preview_enabled": true, + "responsive_web_edit_tweet_api_enabled": true, + "rweb_conversational_replies_downvote_enabled": false, + "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, + "view_counts_everywhere_api_enabled": true, + "longform_notetweets_consumption_enabled": true, + "responsive_web_twitter_article_tweet_consumption_enabled": true, + "content_disclosure_indicator_enabled": true, + "content_disclosure_ai_generated_indicator_enabled": true, + "responsive_web_grok_show_grok_translated_post": true, + "responsive_web_grok_analysis_button_from_backend": true, + "post_ctas_fetch_enabled": true, + "freedom_of_speech_not_reach_fetch_enabled": true, + "standardized_nudges_misinfo": true, + "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, + "longform_notetweets_rich_text_read_enabled": true, + "longform_notetweets_inline_media_enabled": false, "responsive_web_grok_image_annotation_enabled": true, "responsive_web_grok_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": true, - "responsive_web_grok_analyze_post_followups_enabled": true, - "rweb_video_timestamps_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": 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 + "responsive_web_grok_community_note_auto_translation_is_enabled": true, + "responsive_web_enhance_cards_enabled": false }""".replace(" ", "").replace("\n", "") - tweetVariables* = """{ + tweetVars* = """{ "postId": "$1", $2 + "ranking_mode": "$3", "includeHasBirdwatchNotes": false, "includePromotedContent": false, - "withBirdwatchNotes": false, + "withBirdwatchNotes": true, "withVoice": false, "withV2Timeline": true }""".replace(" ", "").replace("\n", "") - tweetDetailVariables* = """{ + tweetDetailVars* = """{ "focalTweetId": "$1", $2 "referrer": "profile", @@ -123,21 +115,26 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - restIdVariables* = """{ - "rest_id": "$1", $2 - "count": 20 -}""" + tweetEditHistoryVars* = """{ + "tweetId": "$1", + "withQuickPromoteEligibilityTweetFields": true +}""".replace(" ", "").replace("\n", "") - userMediaVariables* = """{ + restIdVars* = """{ + "rest_id": "$1", $2 + "count": $3 +}""".replace(" ", "").replace("\n", "") + + userMediaVars* = """{ "userId": "$1", $2 - "count": 20, + "count": $3, "includePromotedContent": false, "withClientEventToken": false, "withBirdwatchNotes": false, "withVoice": true }""".replace(" ", "").replace("\n", "") - userTweetsVariables* = """{ + userTweetsVars* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -145,7 +142,7 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - userTweetsAndRepliesVariables* = """{ + userTweetsAndRepliesVars* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -153,5 +150,70 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - fieldToggles* = """{"withArticlePlainText":false}""" + userArticlesVars* = """{ + "userId": "$1", $2 + "count": 20, + "includePromotedContent": false, + "withVoice": true +}""".replace(" ", "").replace("\n", "") + + articleVars* = """{ + "tweetId": "$1", + "includePromotedContent": false, + "withBirdwatchNotes": true, + "withVoice": true, + "withCommunity": true +}""".replace(" ", "").replace("\n", "") + + articleBatchVars* = """{ + "tweetIds": $1, + "includePromotedContent": false, + "withBirdwatchNotes": true, + "withVoice": true, + "withCommunity": true +}""".replace(" ", "").replace("\n", "") + + articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}""" + + tweetByRestIdVars* = """{ + "tweetId": "$1", + "includePromotedContent": false, + "withBirdwatchNotes": false, + "withVoice": false, + "withCommunity": false +}""".replace(" ", "").replace("\n", "") + + userByRestIdVars* = """{ + "userId": "$1", + "withSafetyModeUserFields": true +}""".replace(" ", "").replace("\n", "") + + communityTweetsVars* = """{ + "communityId": "$1", $2 + "count": 20, + "displayLocation": "Community", + "rankingMode": "$3", + "withCommunity": true +}""".replace(" ", "").replace("\n", "") + + communityMediaVars* = """{ + "communityId": "$1", $2 + "count": 20, + "withCommunity": true +}""".replace(" ", "").replace("\n", "") + + communityMembersVars* = """{ + "communityId": "$1", + "cursor": $2 +}""".replace(" ", "").replace("\n", "") + + communityHashtagsVars* = """{ + "communityId": "$1", $2 + "count": 20, + "hashtags": ["$3"], + "withCommunity": true +}""".replace(" ", "").replace("\n", "") + + userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}""" + userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}""" tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" diff --git a/src/experimental/parser.nim b/src/experimental/parser.nim index 40986f5..e22a51f 100644 --- a/src/experimental/parser.nim +++ b/src/experimental/parser.nim @@ -1,2 +1,2 @@ -import parser/[user, graphql] -export user, graphql +import parser/[user, graphql, article] +export user, graphql, article diff --git a/src/experimental/parser/article.nim b/src/experimental/parser/article.nim new file mode 100644 index 0000000..ae5de7c --- /dev/null +++ b/src/experimental/parser/article.nim @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import std/[strutils, tables, times, options] +import jsony +import utils, graphql, ../types/article +from ../../types import Article, ArticleParagraph, ArticleEntity, ArticleMedia, + User, TweetStats + +proc parseGraphArticle*(json: string): Article = + if json.len == 0 or json[0] != '{': + return + + var raw: GraphArticle + try: + raw = json.fromJson(GraphArticle) + except CatchableError: + return + + let + tweet = raw.data.tweetResult.result + article = tweet.article.articleResults.result + + if article.title.len == 0: + return + + let publishedAt = article.metadata.firstPublishedAtSecs + var articleTime: DateTime + if publishedAt > 0: + articleTime = publishedAt.int64.fromUnix.utc + elif tweet.legacy.createdAt.len > 0: + articleTime = parseTwitterDate(tweet.legacy.createdAt) + + result = Article( + title: article.title, + coverImage: getImageUrl(article.coverMedia.mediaInfo.originalImgUrl), + time: articleTime, + user: parseUserResult(tweet.core.userResults.result), + ) + + result.stats = TweetStats( + replies: tweet.legacy.replyCount, + retweets: tweet.legacy.retweetCount, + likes: tweet.legacy.favoriteCount, + ) + if tweet.views.count.len > 0: + try: result.stats.views = parseInt(tweet.views.count) + except ValueError: discard + + for blk in article.contentState.blocks: + result.paragraphs.add ArticleParagraph( + text: blk.text, + kind: blk.blockKind, + inlineStyles: blk.inlineStyleRanges, + entityRanges: blk.entityRanges, + ) + + for entry in article.contentState.entityMap: + let key = try: parseInt(entry.key) except ValueError: continue + var entity = ArticleEntity(kind: entry.value.entityKind) + case entity.kind + of "LINK": entity.url = entry.value.data.url + of "MEDIA": + for mi in entry.value.data.mediaItems: + entity.mediaIds.add mi.mediaId + entity.caption = entry.value.data.caption + of "TWEET": entity.tweetId = entry.value.data.tweetId + of "MARKDOWN": entity.markdown = entry.value.data.markdown + else: discard + result.entities[key] = entity + + for me in article.mediaEntities: + let typeName = me.mediaInfo.typeName + var media = ArticleMedia(kind: typeName) + if me.mediaInfo.videoInfo.isSome: + let variants = me.mediaInfo.videoInfo.get.variants + case typeName + of "ApiGif": + if variants.len > 0: + media.url = variants[0].url + of "ApiVideo": + var bestBitrate = -1 + for v in variants: + if v.bitrate > bestBitrate: + bestBitrate = v.bitrate + media.url = v.url + else: discard + elif typeName == "ApiImage": + media.url = getImageUrl(me.mediaInfo.originalImgUrl) + result.media[me.mediaId] = media diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 045a5d6..85be202 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -1,6 +1,6 @@ import options, strutils import jsony -import user, ../types/[graphuser, graphlistmembers] +import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers] from ../../types import User, VerifiedType, Result, Query, QueryKind proc parseUserResult*(userResult: UserResult): User = @@ -10,27 +10,61 @@ proc parseUserResult*(userResult: UserResult): User = result.verifiedType = blue if result.username.len == 0 and userResult.core.screenName.len > 0: + # Modern UserByRestId/UserByScreenName shape: `legacy` is empty and the data + # lives in typed sub-objects (core/relationship_counts/tweet_counts/etc.). result.id = userResult.restId result.username = userResult.core.screenName result.fullname = userResult.core.name result.userPic = userResult.avatar.imageUrl.replace("_normal", "") + if userResult.banner.imageUrl.len > 0: + result.banner = userResult.banner.imageUrl & "/1500x500" + + 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.relationshipCounts.isSome: + let rc = userResult.relationshipCounts.get + result.followers = rc.followers + result.following = rc.following + + if userResult.tweetCounts.isSome: + let tc = userResult.tweetCounts.get + result.tweets = tc.tweets + result.media = tc.mediaTweets + + if userResult.actionCounts.isSome: + result.likes = userResult.actionCounts.get.favoritesCount + if userResult.profileBio.isSome: - result.bio = userResult.profileBio.get.description + let bio = userResult.profileBio.get + result.bio = bio.description + result.expandUserEntities(bio.entities) proc parseGraphUser*(json: string): User = if json.len == 0 or json[0] != '{': return - let raw = json.fromJson(GraphUser) - let userResult = raw.data.userResult.result + 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 userResult.unavailableReason.get("") == "Suspended": + if userResult.unavailableReason.get("") == "Suspended" or + userResult.reason.get("") == "Suspended": return User(suspended: true) result = parseUserResult(userResult) @@ -53,3 +87,25 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] = of TimelineTimelineCursor: if entry.content.cursorType == "Bottom": result.bottom = entry.content.value + +proc parseGraphFollowers*(json, cursor: string; kind: QueryKind): Result[User] = + result = Result[User]( + beginning: cursor.len == 0, + query: Query(kind: kind) + ) + + if json.len == 0 or json[0] != '{': + return + + let raw = json.fromJson(GraphFollowers) + for instruction in raw.data.user.result.timeline.timeline.instructions: + if instruction.kind == "TimelineAddEntries": + for entry in instruction.entries: + case entry.content.entryType + of TimelineTimelineItem: + let userResult = entry.content.itemContent.userResults.result + if userResult.restId.len > 0: + result.content.add parseUserResult(userResult) + of TimelineTimelineCursor: + if entry.content.cursorType == "Bottom": + result.bottom = entry.content.value diff --git a/src/experimental/parser/slices.nim b/src/experimental/parser/slices.nim index 45e6e1d..db2c98d 100644 --- a/src/experimental/parser/slices.nim +++ b/src/experimental/parser/slices.nim @@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice]; let name = $runes[rep.slice.a.succ .. rep.slice.b] symbol = $runes[rep.slice.a] - result.add a(symbol & name, href = "/search?q=%23" & name) + result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) of rkMention: result.add a($runes[rep.slice], href = rep.url, title = rep.display) of rkUrl: diff --git a/src/experimental/parser/tid.nim b/src/experimental/parser/tid.nim new file mode 100644 index 0000000..28fccea --- /dev/null +++ b/src/experimental/parser/tid.nim @@ -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) diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 498757a..866973c 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -9,12 +9,10 @@ let unReplace = "$1@$2" htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!|">|#)""" - htReplace = "$1$2$3" + htReplace = "$1$2$3" -proc expandUserEntities(user: var User; raw: RawUser) = - let - orig = user.bio.toRunes - ent = raw.entities +proc expandUserEntities*(user: var User; ent: Entities) = + let orig = user.bio.toRunes if ent.url.urls.len > 0: user.website = ent.url.urls[0].expandedUrl @@ -58,15 +56,17 @@ 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]) - result.expandUserEntities(raw) + result.expandUserEntities(raw.entities) proc parseHook*(s: string; i: var int; v: var User) = var u: RawUser diff --git a/src/experimental/types/article.nim b/src/experimental/types/article.nim new file mode 100644 index 0000000..946f8c3 --- /dev/null +++ b/src/experimental/types/article.nim @@ -0,0 +1,79 @@ +import std/options +import graphuser +from ../../types import ArticleStyle, ArticleEntityRange + +type + GraphArticle* = object + data*: tuple[tweetResult: tuple[result: TweetResultNode]] + + TweetResultNode* = object + article*: tuple[articleResults: tuple[result: ArticleResultNode]] + legacy*: TweetLegacy + core*: tuple[userResults: UserData] + views*: tuple[count: string] + + TweetLegacy* = object + createdAt*: string + replyCount*: int + retweetCount*: int + favoriteCount*: int + + ArticleResultNode* = object + title*: string + coverMedia*: tuple[mediaInfo: MediaInfoNode] + contentState*: ContentState + metadata*: tuple[firstPublishedAtSecs: int] + mediaEntities*: seq[RawMediaEntity] + + ContentState* = object + blocks*: seq[ContentBlock] + entityMap*: seq[EntityMapEntry] + + ContentBlock* = object + text*: string + blockKind*: string + inlineStyleRanges*: seq[ArticleStyle] + entityRanges*: seq[ArticleEntityRange] + + EntityMapEntry* = object + key*: string + value*: EntityMapValue + + EntityMapValue* = object + entityKind*: string + data*: EntityDataNode + + EntityDataNode* = object + url*: string + mediaItems*: seq[tuple[mediaId: string]] + tweetId*: string + markdown*: string + caption*: string + + RawMediaEntity* = object + mediaId*: string + mediaInfo*: MediaInfoNode + + MediaInfoNode* = object + typeName*: string + originalImgUrl*: string + videoInfo*: Option[VideoInfoNode] + + VideoInfoNode* = object + variants*: seq[VideoVariant] + + VideoVariant* = object + url*: string + bitrate*: int + +proc renameHook*(v: var ContentBlock; fieldName: var string) = + if fieldName == "type": + fieldName = "blockKind" + +proc renameHook*(v: var EntityMapValue; fieldName: var string) = + if fieldName == "type": + fieldName = "entityKind" + +proc renameHook*(v: var MediaInfoNode; fieldName: var string) = + if fieldName == "__typename": + fieldName = "typeName" diff --git a/src/experimental/types/graphfollowers.nim b/src/experimental/types/graphfollowers.nim new file mode 100644 index 0000000..ba9210b --- /dev/null +++ b/src/experimental/types/graphfollowers.nim @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import graphlistmembers + +type + GraphFollowers* = object + data*: tuple[user: UserWrapper] + + UserWrapper = object + result*: UserResultWrapper + + UserResultWrapper = object + timeline*: tuple[timeline: graphlistmembers.Timeline] + +# Hook to normalize snake_case field from API to camelCase used by shared types +proc renameHook*(v: var Content; fieldName: var string) = + if fieldName == "user_results": + fieldName = "userResults" diff --git a/src/experimental/types/graphlistmembers.nim b/src/experimental/types/graphlistmembers.nim index 4cb3757..9e520d8 100644 --- a/src/experimental/types/graphlistmembers.nim +++ b/src/experimental/types/graphlistmembers.nim @@ -7,10 +7,10 @@ type List = object membersTimeline*: tuple[timeline: Timeline] - Timeline = object + Timeline* = object instructions*: seq[Instruction] - Instruction = object + Instruction* = object kind*: string entries*: seq[tuple[content: Content]] @@ -18,7 +18,7 @@ type TimelineTimelineItem TimelineTimelineCursor - Content = object + Content* = object case entryType*: ContentEntryType of TimelineTimelineItem: itemContent*: tuple[userResults: UserData] diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim index d732b4e..ec41f89 100644 --- a/src/experimental/types/graphuser.nim +++ b/src/experimental/types/graphuser.nim @@ -1,9 +1,10 @@ import options, strutils from ../../types import User, VerifiedType +import user as userType # Entities, for modern profile_bio parsing type GraphUser* = object - data*: tuple[userResult: UserData] + data*: tuple[userResult: Option[UserData], user: Option[UserData]] UserData* = object result*: UserResult @@ -15,6 +16,7 @@ type UserBio* = object description*: string + entities*: Entities UserAvatar* = object imageUrl*: string @@ -22,15 +24,41 @@ type Verification* = object verifiedType*: VerifiedType + Location* = object + location*: string + + Privacy* = object + protected*: bool + + # Modern UserByRestId/UserByScreenName shape moves the counts out of `legacy` + # into these typed sub-objects. + RelationshipCounts* = object + followers*: int + following*: int + + TweetCounts* = object + tweets*: int + mediaTweets*: int + + ActionCounts* = object + favoritesCount*: int + UserResult* = object legacy*: User restId*: string isBlueVerified*: bool - unavailableReason*: Option[string] core*: UserCore avatar*: UserAvatar + banner*: UserAvatar + unavailableReason*: Option[string] + reason*: Option[string] + privacy*: Option[Privacy] profileBio*: Option[UserBio] verification*: Option[Verification] + location*: Option[Location] + relationshipCounts*: Option[RelationshipCounts] + tweetCounts*: Option[TweetCounts] + actionCounts*: Option[ActionCounts] proc enumHook*(s: string; v: var VerifiedType) = v = try: diff --git a/src/experimental/types/tid.nim b/src/experimental/types/tid.nim new file mode 100644 index 0000000..ad036d9 --- /dev/null +++ b/src/experimental/types/tid.nim @@ -0,0 +1,4 @@ +type + TidPair* = object + animationKey*: string + verification*: string diff --git a/src/formatters.nim b/src/formatters.nim index cafaa4f..958e518 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -1,12 +1,12 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen +import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math import std/[enumerate, re] 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"(?<=(? 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")) + twitterHost & "$2", href = https & twitterHost & "$1")) 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: @@ -88,7 +91,17 @@ proc getM3u8Url*(content: string): string = if re.find(content, m3u8Regex, matches) != -1: result = matches[0] -proc proxifyVideo*(manifest: string; proxy: bool): string = +proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string = + let (baseUrl, basePath) = + if manifestUrl.len > 0: + let + u = parseUri(manifestUrl) + origin = u.scheme & "://" & u.hostname + idx = manifestUrl.rfind('/') + dirPath = if idx > 8: manifestUrl[0 .. idx] else: "" + (origin, dirPath) + else: + ("https://video.twimg.com", "") var replacements: seq[(string, string)] for line in manifest.splitLines: let url = @@ -96,9 +109,13 @@ proc proxifyVideo*(manifest: string; proxy: bool): string = elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line: line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))] else: line - if url.startsWith('/'): - let path = "https://video.twimg.com" & url - replacements.add (url, if proxy: path.getVidUrl else: path) + let resolved = + if url.startsWith('/'): baseUrl & url + elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and + not url.startsWith("http") and ('.' in url): basePath & url + else: "" + if resolved.len > 0: + replacements.add (url, if proxy: resolved.getVidUrl else: resolved) return manifest.multiReplace(replacements) proc getUserPic*(userPic: string; style=""): string = @@ -123,25 +140,30 @@ proc pageDesc*(user: User): string = "The latest tweets from " & user.fullname proc getJoinDate*(user: User): string = + if user.joinDate.year == 0: return "" user.joinDate.format("'Joined' MMMM YYYY") proc getJoinDateFull*(user: User): string = + if user.joinDate.year == 0: return "" user.joinDate.format("h:mm tt - d MMM YYYY") proc getTime*(tweet: Tweet): string = + if tweet.time.year == 0: return "" tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'") proc getRfc822Time*(tweet: Tweet): string = + if tweet.time.year == 0: return "" tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'") -proc getShortTime*(tweet: Tweet): string = +proc getShortTime*(time: DateTime): string = + if time.year == 0: return "" let now = now() - let since = now - tweet.time + let since = now - time - if now.year != tweet.time.year: - result = tweet.time.format("d MMM yyyy") + if now.year != time.year: + result = time.format("d MMM yyyy") elif since.inDays >= 1: - result = tweet.time.format("MMM d") + result = time.format("MMM d") elif since.inHours >= 1: result = $since.inHours & "h" elif since.inMinutes >= 1: @@ -151,13 +173,33 @@ proc getShortTime*(tweet: Tweet): string = else: result = "now" +proc getShortTime*(tweet: Tweet): string = + getShortTime(tweet.time) + +proc getDuration*(ms: int): string = + let + sec = int(round(ms / 1000)) + min = floorDiv(sec, 60) + hour = floorDiv(min, 60) + if hour > 0: + &"{hour}:{min mod 60:02}:{sec mod 60:02}" + else: + &"{min mod 60}:{sec mod 60:02}" + +proc getDuration*(video: Video): string = + getDuration(video.durationMs) + +proc getLink*(id: int64; username="i"; focus=true): string = + var username = username + if username.len == 0: + username = "i" + result = &"/{username}/status/{id}" + if focus: result &= "#m" + proc getLink*(tweet: Tweet; focus=true): string = if tweet.id == 0: return var username = tweet.user.username - if username.len == 0: - username = "i" - result = &"/{username}/status/{tweet.id}" - if focus: result &= "#m" + return getLink(tweet.id, username, focus) proc getTwitterLink*(path: string; params: Table[string, string]): string = var @@ -185,7 +227,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string = proc getLocation*(u: User | Tweet): (string, string) = if "://" in u.location: return (u.location, "") let loc = u.location.split(":") - let url = if loc.len > 1: "/search?q=place:" & loc[1] else: "" + let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: "" (loc[0], url) proc getSuspended*(username: string): string = diff --git a/src/http_pool.nim b/src/http_pool.nim index 664e9a6..2553dd9 100644 --- a/src/http_pool.nim +++ b/src/http_pool.nim @@ -27,7 +27,7 @@ proc release*(pool: HttpPool; client: AsyncHttpClient; badClient=false) = proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient = if pool.conns.len == 0: - result = newAsyncHttpClient(headers=heads, proxy=proxy) + result = newAsyncHttpClient(userAgent="", headers=heads, proxy=proxy) else: result = pool.conns.pop() result.headers = heads diff --git a/src/nitter.nim b/src/nitter.nim index f81dc1c..a76dda6 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -2,15 +2,15 @@ import asyncdispatch, strformat, logging from net import Port from htmlgen import a -from os import getEnv +from os import getEnv, normalizedPath import jester -import types, config, prefs, formatters, redis_cache, http_pool, auth +import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils import views/[general, about] import routes/[ - preferences, timeline, status, media, search, rss, list, debug, - unsupported, embed, resolver, router_utils] + preferences, timeline, status, media, search, rss, list, community, debug, + unsupported, embed, resolver, broadcast, space, article, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -34,39 +34,62 @@ stdout.flushFile updateDefaultPrefs(fullCfg) setCacheTimes(cfg) setHmacKey(cfg.hmacKey) +if cfg.hmacKey.len == 0 or cfg.hmacKey == "secretkey": + stderr.write "WARNING: insecure default 'hmacKey' in nitter.conf; " & + "set a unique random value to stop media URL signatures being forgeable.\n" + stderr.flushFile setProxyEncoding(cfg.base64Media) setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) +setApiProxy(cfg.apiProxy) +setDisableTid(cfg.disableTid) +setMaxConcurrentReqs(cfg.maxConcurrentReqs) +setMaxRetries(cfg.maxRetries) +setRetryDelayMs(cfg.retryDelayMs) initAboutPage(cfg.staticDir) waitFor initRedisPool(cfg) stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" stdout.flushFile +createArticleRouter(cfg) createUnsupportedRouter(cfg) createResolverRouter(cfg) createPrefRouter(cfg) createTimelineRouter(cfg) createListRouter(cfg) +createCommunityRouter(cfg) createStatusRouter(cfg) createSearchRouter(cfg) createMediaRouter(cfg) createEmbedRouter(cfg) createRssRouter(cfg) +createBroadcastRouter(cfg) +createSpaceRouter(cfg) createDebugRouter(cfg) settings: port = Port(cfg.port) - staticDir = cfg.staticDir + staticDir = normalizedPath(cfg.staticDir) bindAddr = cfg.address reusePort = true + maxBody = 64 * 1024 routes: + before: + # Reject malformed paths + if request.path.len == 0 or request.path[0] != '/': + halt Http400 + + # skip all file URLs (except Twitter widget compatibility) + cond "." notin request.path or request.path == "/embed/Tweet.html" + applyUrlPrefs() + get "/": - resp renderMain(renderSearch(), request, cfg, themePrefs()) + resp renderMain(renderSearch(), request, cfg, requestPrefs()) get "/about": - resp renderMain(renderAbout(), request, cfg, themePrefs()) + resp renderMain(renderAbout(), request, cfg, requestPrefs()) get "/explore": redirect("/about") @@ -77,7 +100,7 @@ routes: get "/i/redirect": let url = decodeUrl(@"url") if url.len == 0: resp Http404 - redirect(replaceUrls(url, cookiePrefs())) + redirect(replaceUrls(url, requestPrefs())) error Http404: resp Http404, showError("Page not found", cfg) @@ -102,14 +125,18 @@ routes: resp Http429, showError( &"Instance has no auth tokens, or is fully rate limited.
Use {link} or try again later.", cfg) + extend articleRoute, "" extend rss, "" extend status, "" extend search, "" extend timeline, "" extend media, "" extend list, "" + extend community, "" extend preferences, "" extend resolver, "" extend embed, "" + extend broadcastRoute, "" + extend spaceRoute, "" extend debug, "" extend unsupported, "" diff --git a/src/parser.nim b/src/parser.nim index 5bf2b0b..eebca2d 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -1,10 +1,20 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, options, times, math, tables +import strutils, options, times, math, tables, uri import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard -proc parseGraphTweet(js: JsonNode): Tweet +proc parseGraphTweet*(js: JsonNode): Tweet + +proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType = + try: parseEnum[VerifiedType](s) + except ValueError: current + +proc parseCommunityNote(js: JsonNode): string = + let subtitle = js{"subtitle"} + result = subtitle{"text"}.getStr + with entities, subtitle{"entities"}: + result = expandBirdwatchEntities(result, entities) proc parseUser(js: JsonNode; id=""): User = if js.isNull: return @@ -21,7 +31,7 @@ proc parseUser(js: JsonNode; id=""): User = tweets: js{"statuses_count"}.getInt, likes: js{"favourites_count"}.getInt, media: js{"media_count"}.getInt, - protected: js{"protected"}.getBool, + protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool), joinDate: js{"created_at"}.getTime ) @@ -29,17 +39,17 @@ proc parseUser(js: JsonNode; id=""): User = result.verifiedType = blue with verifiedType, js{"verified_type"}: - result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) + result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) result.expandUserEntities(js) proc parseGraphUser(js: JsonNode): User = var user = js{"user_result", "result"} if user.isNull: - user = ? js{"user_results", "result"} + user = js{"user_results", "result"} if user.isNull: - if js{"core"}.notNull and js{"legacy"}.notNull: + if js{"core"}.notNull: user = js else: return @@ -51,15 +61,164 @@ proc parseGraphUser(js: JsonNode): User = # fallback to support UserMedia/recent GraphQL updates if result.username.len == 0: + result.id = user{"rest_id"}.getStr 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): + if user{"is_blue_verified"}.getBool( + user{"verification", "is_blue_verified"}.getBool(false)): result.verifiedType = blue with verifiedType, user{"verification", "verified_type"}: - result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) + result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) + +proc parseAboutAccount*(js: JsonNode): AccountInfo = + if js.isNull: return + + let user = ? js{"data", "user_result_by_screen_name", "result"} + + if user{"unavailable_reason"}.getStr == "Suspended": + result.suspended = true + return + + result = AccountInfo( + username: user{"core", "screen_name"}.getStr, + fullname: user{"core", "name"}.getStr, + joinDate: user{"core", "created_at"}.getTime, + userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""), + affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr, + ) + + if user{"is_blue_verified"}.getBool(false): + result.verifiedType = blue + with verifiedType, user{"verification", "verified_type"}: + result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) + + with about, user{"about_profile"}: + result.basedIn = about{"account_based_in"}.getStr + result.source = about{"source"}.getStr + result.affiliateUsername = about{"affiliate_username"}.getStr + + try: + result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt + except ValueError: + discard + + with lastChange, about{"username_changes", "last_changed_at_msec"}: + result.lastUsernameChange = lastChange.getTimeFromMsStr + + with info, user{"verification_info"}: + result.isIdentityVerified = info{"is_identity_verified"}.getBool + with reason, info{"reason"}: + result.overrideVerifiedYear = reason{"override_verified_year"}.getInt + with since, reason{"verified_since_msec"}: + result.verifiedSince = since.getTimeFromMsStr + +proc parseBroadcastInfo*(js: JsonNode): Broadcast = + let bc = ? js{"data", "broadcast"} + result = Broadcast( + id: bc{"broadcast_id"}.getStr, + title: bc{"status"}.getStr, + state: bc{"state"}.getStr.toUpperAscii, + thumb: bc{"image_url"}.getStr, + mediaKey: bc{"media_key"}.getStr, + totalWatched: bc{"total_watched"}.getInt, + startTime: bc{"start_time"}.getTimeFromMs, + endTime: bc{"end_time"}.getTimeFromMs, + replayStart: bc{"edited_replay", "start_time"}.getInt, + availableForReplay: bc{"available_for_replay"}.getBool, + user: parseGraphUser(bc) + ) + +proc parseSpaceParticipant(js: JsonNode): SpaceParticipant = + result = SpaceParticipant( + userId: js{"user_results", "rest_id"}.getStr, + username: js{"twitter_screen_name"}.getStr, + displayName: js{"display_name"}.getStr, + avatarUrl: js{"avatar_url"}.getStr, + isVerified: js{"is_verified"}.getBool or + js{"user_results", "result", "is_blue_verified"}.getBool + ) + +proc parseAudioSpace*(js: JsonNode): AudioSpace = + let space = ? js{"data", "audioSpace"} + let meta = space{"metadata"} + + result = AudioSpace( + id: meta{"rest_id"}.getStr, + title: meta{"title"}.getStr, + state: meta{"state"}.getStr.toUpperAscii, + mediaKey: meta{"media_key"}.getStr, + totalLiveListeners: meta{"total_live_listeners"}.getInt, + totalReplayWatched: meta{"total_replay_watched"}.getInt, + availableForReplay: meta{"is_space_available_for_replay"}.getBool + ) + + let startedAt = meta{"started_at"}.getInt(0) + if startedAt > 0: + result.startTime = fromUnix(startedAt div 1000).utc() + + let endedAtStr = meta{"ended_at"}.getStr + if endedAtStr.len > 0: + try: + let endedAt = parseBiggestInt(endedAtStr) + if endedAt > 0: + result.endTime = fromUnix(endedAt div 1000).utc() + except ValueError: + discard + + result.creator = parseGraphUser(meta{"creator_results", "result"}) + + for admin in space{"participants", "admins"}: + result.admins.add parseSpaceParticipant(admin) + + for speaker in space{"participants", "speakers"}: + result.speakers.add parseSpaceParticipant(speaker) + +proc parseGraphCommunity*(js: JsonNode): Community = + if js.isNull: return + let c = ? js{"data", "communityResults", "result"} + + result = Community( + id: c{"rest_id"}.getStr(c{"id_str"}.getStr), + name: c{"name"}.getStr, + description: c{"description"}.getStr, + memberCount: c{"member_count"}.getInt, + joinPolicy: c{"join_policy"}.getStr, + category: c{"primary_community_topic", "topic_name"}.getStr, + banner: c{"custom_banner_media", "media_info", "original_img_url"}.getImageStr, + creator: parseGraphUser(c{"creator_results", "result"}), + ) + + let createdMs = c{"created_at"}.getInt(0) + if createdMs > 0: + result.createdAt = fromUnix(createdMs div 1000).utc() + + for rule in c{"rules"}: + result.rules.add CommunityRule( + name: rule{"name"}.getStr, + description: rule{"description"}.getStr + ) + + for item in c{"trending_hashtags_slice", "items"}: + let tag = item{"hashtag"}.getStr + if tag.len > 0: + result.hashtags.add tag + +proc parseListObject(js: JsonNode; owner: User): List = + List( + id: js{"id_str"}.getStr, + name: js{"name"}.getStr, + username: owner.username, + userId: owner.id, + description: js{"description"}.getStr, + members: js{"member_count"}.getInt, + banner: select( + js{"custom_banner_media", "media_info", "original_img_url"}, + js{"default_banner_media", "media_info", "original_img_url"} + ).getImageStr + ) proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -70,15 +229,17 @@ proc parseGraphList*(js: JsonNode): List = if list.isNull: return - result = List( - id: list{"id_str"}.getStr, - name: list{"name"}.getStr, - username: list{"user_results", "result", "legacy", "screen_name"}.getStr, - userId: list{"user_results", "result", "rest_id"}.getStr, - description: list{"description"}.getStr, - members: list{"member_count"}.getInt, - banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr + result = parseListObject(list, parseGraphUser(list)) + +proc parseGraphSearchList(js: JsonNode): ListSearchResult = + let owner = parseGraphUser(js) + result = ListSearchResult( + list: parseListObject(js, owner), + owner: owner, + followersContext: js{"followers_context"}.getStr ) + for url in js{"facepile_urls"}: + result.facepiles.add url.getStr proc parsePoll(js: JsonNode): Poll = let vals = js{"binding_values"} @@ -134,60 +295,85 @@ proc parseVideo(js: JsonNode): Video = result.variants = parseVideoVariants(js{"video_info", "variants"}) +proc addMedia(media: var MediaEntities; photo: Photo) = + media.add Media(kind: photoMedia, photo: photo) + +proc addMedia(media: var MediaEntities; video: Video) = + media.add Media(kind: videoMedia, video: video) + +proc addMedia(media: var MediaEntities; gif: Gif) = + media.add Media(kind: gifMedia, gif: gif) + 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 + result.media.addMedia(Photo( + url: m{"media_url_https"}.getImageStr, + altText: m{"ext_alt_text"}.getStr + )) of "video": - result.video = some(parseVideo(m)) + result.media.addMedia(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)) + # Set attribution link from expanded_url (strip /video/N suffix) + let expanded = m{"expanded_url"}.getStr + if expanded.len > 0: + result.attributionLink = expanded.parseUri.path.replace("/video/1", "") of "animated_gif": - result.gif = some Gif( + result.media.addMedia(Gif( url: m{"video_info", "variants"}[0]{"url"}.getImageStr, - thumb: m{"media_url_https"}.getImageStr - ) + thumb: m{"media_url_https"}.getImageStr, + altText: m{"ext_alt_text"}.getStr + )) 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"}: + var parsedMedia: MediaEntities 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 + parsedMedia.addMedia(Photo( + url: mediaInfo{"original_img_url"}.getImageStr, + altText: mediaInfo{"alt_text"}.getStr + )) of "ApiVideo": let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} - result.video = some Video( + parsedMedia.addMedia(Video( available: status.getStr == "Available", thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, + title: mediaInfo{"alt_text"}.getStr, durationMs: mediaInfo{"duration_millis"}.getInt, variants: parseVideoVariants(mediaInfo{"variants"}) - ) + )) + + # Parse source user for video attribution + with sourceUser, mediaEntity{"source_user_results", "result"}: + if result.attribution.isNone: + let expanded = mediaEntity{"expanded_url"}.getStr + if expanded.len > 0: + result.attributionLink = expanded.parseUri.path.replace("/video/1", "") + result.attribution = some(User( + id: sourceUser{"rest_id"}.getStr, + fullname: sourceUser{"core", "name"}.getStr, + userPic: sourceUser{"avatar", "image_url"}.getImageStr.replace("_normal", "") + )) of "ApiGif": - result.gif = some Gif( + parsedMedia.addMedia(Gif( url: mediaInfo{"variants"}[0]{"url"}.getImageStr, - thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr - ) + thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, + altText: mediaInfo{"alt_text"}.getStr + )) else: discard - # Remove media URLs from text - with mediaList, js{"legacy", "entities", "media"}: - for url in mediaList: - let expandedUrl = url{"expanded_url"}.getStr - if result.text.endsWith(expandedUrl): - result.text.removeSuffix(expandedUrl) - result.text = result.text.strip() + if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len: + result.media = parsedMedia proc parsePromoVideo(js: JsonNode): Video = result = Video( @@ -210,14 +396,23 @@ proc parsePromoVideo(js: JsonNode): Video = result.variants.add variant proc parseBroadcast(js: JsonNode): Card = - let image = js{"broadcast_thumbnail_large"}.getImageVal + let + image = js{"broadcast_thumbnail_large"}.getImageVal + broadcastUrl = js{"broadcast_url"}.getStrVal + broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1] + streamUrl = "/i/broadcasts/" & broadcastId & "/stream" result = Card( kind: broadcast, - url: js{"broadcast_url"}.getStrVal, + url: "/i/broadcasts/" & broadcastId, title: js{"broadcaster_display_name"}.getStrVal, text: js{"broadcast_title"}.getStrVal, image: image, - video: some Video(thumb: image) + video: some Video( + thumb: image, + available: true, + playbackType: m3u8, + variants: @[VideoVariant(contentType: m3u8, url: streamUrl)] + ) ) proc parseCard(js: JsonNode; urls: JsonNode): Card = @@ -256,7 +451,13 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = result.url = vals{"player_url"}.getStrVal if "youtube.com" in result.url: result.url = result.url.replace("/embed/", "/watch?v=") - of audiospace, unknown: + of audiospace: + let spaceId = vals{"id"}.getStrVal + if spaceId.len > 0: + result.url = "/i/spaces/" & spaceId + result.title = "Twitter Space" + result.text = "Click to view Space" + of unknown: result.title = "This card type is not supported." else: discard @@ -267,7 +468,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}: @@ -277,8 +478,9 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = result.url.len == 0 or result.url.startsWith("card://"): result.url = getPicUrl(result.image) -proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = - if js.isNull: return +proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); + replyId: int64 = 0; hasArticle = false): Tweet = + if js.isNull: return Tweet() let time = if js{"created_at"}.notNull: js{"created_at"}.getTime @@ -301,6 +503,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = ) ) + if result.replyId == 0: + result.replyId = replyId + # fix for pinned threads if result.hasThread and result.threadId == 0: result.threadId = js{"self_thread", "id_str"}.getId @@ -318,13 +523,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = # graphql with rt, js{"retweeted_status_result", "result"}: # needed due to weird edgecase where the actual tweet data isn't included - if "legacy" in rt: + if "legacy" in rt or "rest_id" in rt: result.retweet = some parseGraphTweet(rt) return with reposts, js{"repostedStatusResults"}: with rt, reposts{"result"}: - if "legacy" in rt: + if "legacy" in rt or "rest_id" in rt: result.retweet = some parseGraphTweet(rt) return @@ -332,15 +537,17 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = let name = jsCard{"name"}.getStr if "poll" in name: if "image" in name: - result.photos.add jsCard{"binding_values", "image_large"}.getImageVal + result.media.addMedia(Photo( + url: jsCard{"binding_values", "image_large"}.getImageVal + )) result.poll = some parsePoll(jsCard) elif name == "amplify": - result.video = some(parsePromoVideo(jsCard{"binding_values"})) - else: + result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) + elif name.len > 0 and jsCard{"binding_values"}.notNull: result.card = some parseCard(jsCard, js{"entities", "urls"}) - result.expandTweetEntities(js) + result.expandTweetEntities(js, hasArticle) parseLegacyMediaEntities(js, result) with jsWithheld, js{"withheld_in_countries"}: @@ -357,7 +564,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = result.text.removeSuffix(" Learn more.") result.available = false -proc parseGraphTweet(js: JsonNode): Tweet = +proc parseGraphTweet*(js: JsonNode): Tweet = if js.kind == JNull: return Tweet() @@ -375,7 +582,7 @@ proc parseGraphTweet(js: JsonNode): Tweet = else: discard - if not js.hasKey("legacy"): + if "legacy" notin js and "rest_id" notin js: return Tweet() var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"}) @@ -394,12 +601,80 @@ proc parseGraphTweet(js: JsonNode): Tweet = "binding_values": %bindingObj } - result = parseTweet(js{"legacy"}, jsCard) - result.id = js{"rest_id"}.getId + var replyId: int64 = 0 + with restId, js{"reply_to_results", "rest_id"}: + replyId = restId.getId + + let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0 + + if "details" in js: + result = Tweet( + id: js{"rest_id"}.getId, + available: true, + text: js{"details", "full_text"}.getStr, + time: js{"details", "created_at_ms"}.getTimeFromMs, + replyId: js{"reply_to_results", "rest_id"}.getId, + isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool, + isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool, + stats: TweetStats( + replies: js{"counts", "reply_count"}.getInt, + retweets: js{"counts", "retweet_count"}.getInt, + likes: js{"counts", "favorite_count"}.getInt, + ) + ) + + if jsCard.kind != JNull: + let name = jsCard{"name"}.getStr + if "poll" in name: + if "image" in name: + result.media.addMedia(Photo( + url: jsCard{"binding_values", "image_large"}.getImageVal + )) + + result.poll = some parsePoll(jsCard) + elif name == "amplify": + result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) + elif name.len > 0 and jsCard{"binding_values"}.notNull: + result.card = some parseCard(jsCard, js{"url_entities"}) + + parseMediaEntities(js, result) + if result.attribution.isNone: + parseLegacyMediaEntities(js{"legacy"}, result) + + result.expandTweetEntitiesV2(js, hasArticle) + + # Strip video source URL from text (for videos from other tweets) + with mediaEntities, js{"media_entities"}: + for m in mediaEntities: + if "source_status_id_str" in m: + let mediaUrl = m{"url"}.getStr + if mediaUrl.len > 0: + let idx = result.text.rfind(mediaUrl) + if idx >= 0: + result.text = result.text[0 ..< idx].strip() + break + else: + result = parseTweet(js{"legacy"}, jsCard, replyId, hasArticle) + result.id = js{"rest_id"}.getId + + with artNode, js{"article", "article_results", "result"}: + let artTitle = artNode{"title"}.getStr + if artTitle.len > 0: + result.articlePreview = some ArticlePreview( + title: artTitle, + previewText: artNode{"preview_text"}.getStr, + coverImage: select( + artNode{"cover_media_results", "result", "media_info", "original_img_url"}, + artNode{"cover_media", "media_info", "original_img_url"} + ).getImageStr, + tweetId: result.id + ) + result.user = parseGraphUser(js{"core"}) - if result.replyId == 0: - result.replyId = js{"reply_to_results", "rest_id"}.getId + if result.reply.len == 0: + with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}: + result.reply = @[replyTo.getStr] with count, js{"views", "count"}: result.stats.views = count.getStr("0").parseInt @@ -409,21 +684,58 @@ proc parseGraphTweet(js: JsonNode): Tweet = parseMediaEntities(js, result) - if result.quote.isSome: - result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"})) + # Hide card if it's redundant with attribution (same video shown via embed) + if result.attribution.isSome and result.card.isSome: + let cardUri = get(result.card).url.parseUri + if cardUri.isTwitterUrl: + let cardPath = cardUri.path.replace("/video/1", "") + if cardPath.len > 0 and cardPath == result.attributionLink: + get(result.card).kind = hidden - with quoted, js{"quotedPostResults", "result"}: + # Handle retweets - check both legacy and top-level paths + with reposts, js{"legacy", "repostedStatusResults"}: + with rt, reposts{"result"}: + if "legacy" in rt or "rest_id" in rt: + result.retweet = some parseGraphTweet(rt) + + with quoted, js{"quoted_status_result", "result"}: result.quote = some(parseGraphTweet(quoted)) + with quoted, js{"quotedPostResults"}: + if "result" in quoted: + result.quote = some(parseGraphTweet(quoted{"result"})) + else: + result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId) + + with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}: + for id in ids: + result.history.add parseBiggestInt(id.getStr) + + with birdwatch, js{"birdwatch_pivot"}: + result.note = parseCommunityNote(birdwatch) + +proc getConvSection(js: JsonNode): string = + let details = select( + js{"item", "client_event_info", "details"}, + js{"item", "clientEventInfo", "details"} + ) + select( + details{"conversation_details", "conversation_section"}, + details{"conversationDetails", "conversationSection"} + ).getStr + proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = + var checkedSection = false 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: - with tweet, t.getTweetResult("item"): + if "tweet-" in entryId and "promoted" notin entryId: + if not checkedSection: + checkedSection = true + if getConvSection(t) == "RelatedTweet": + result.thread.related = true + + let tweet = t.getTweetResult("item") + if tweet.notNull: result.thread.content.add parseGraphTweet(tweet) let tweetDisplayType = select( @@ -432,11 +744,31 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = ) if tweetDisplayType.getStr == "SelfThread": result.self = true + else: + result.thread.content.add Tweet(id: entryId.getId) + elif "cursor-showmore" in entryId: + let cursor = t{"item", "content", "value"} + result.thread.cursor = cursor.getStr + result.thread.hasMore = true proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: result = parseGraphTweet(tweet) +proc parseTweetByRestId*(js: JsonNode): Tweet = + with tweet, js{"data", "tweetResult", "result"}: + result = parseGraphTweet(tweet) + +proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] = + let results = js{"data", "tweetResult"} + if results.kind != JArray: return + for item in results: + let tweet = item{"result"} + if tweet.isNull: continue + let t = parseGraphTweet(tweet) + if t != nil: + result.add t + proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) @@ -452,7 +784,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if i.getTypeName == "TimelineAddEntries": for e in i{"entries"}: let entryId = e.getEntryId - if entryId.startsWith("tweet"): + if entryId.startsWith("tweet-"): let tweetResult = getTweetResult(e) if tweetResult.notNull: let tweet = parseGraphTweet(tweetResult) @@ -460,11 +792,14 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if not tweet.available: tweet.id = entryId.getId - if $tweet.id == tweetId: + if entryId.endsWith(tweetId): result.tweet = tweet else: result.before.content.add tweet - elif entryId.startsWith("conversationthread"): + elif not entryId.endsWith(tweetId): + result.before.content.add Tweet(id: entryId.getId) + elif entryId.startsWith("conversationthread") or + entryId.startsWith("tweetdetailrelatedtweets"): let (thread, self) = parseGraphThread(e) if self: result.after = thread @@ -491,20 +826,54 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = ) result.replies.bottom = cursorValue.getStr +proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory = + let instructions = ? js{ + "data", "tweet_result_by_rest_id", "result", + "edit_history_timeline", "timeline", "instructions" + } + if instructions.len == 0: + return + + for i in instructions: + if i.getTypeName == "TimelineAddEntries": + for e in i{"entries"}: + let entryId = e.getEntryId + if entryId == "latestTweet": + with item, e{"content", "items"}[0]: + let tweetResult = item.getTweetResult("item") + if tweetResult.notNull: + result.latest = parseGraphTweet(tweetResult) + elif entryId == "staleTweets": + for item in e{"content", "items"}: + let tweetResult = item.getTweetResult("item") + if tweetResult.notNull: + result.history.add parseGraphTweet(tweetResult) + +iterator extractTweetsFromModuleItems(items: JsonNode): Tweet = + for item in items: + with tweetResult, item.getTweetResult("item"): + let tweet = parseGraphTweet(tweetResult) + if not tweet.available: + tweet.id = item.getEntryId.getId + yield tweet + +iterator extractListsFromItems(items: JsonNode): ListSearchResult = + for item in items: + with listJs, item{"item", "itemContent", "list"}: + let r = parseGraphSearchList(listJs) + if r.list.id.len > 0: + yield r + proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = with tweetResult, getTweetResult(e): - var tweet = parseGraphTweet(tweetResult) + let 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 + for tweet in extractTweetsFromModuleItems(e{"content", "items"}): + result.add tweet proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) @@ -519,12 +888,8 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = for i in instructions: 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 + for tweet in extractTweetsFromModuleItems(i{"moduleItems"}): + result.tweets.content.add tweet continue if i{"entries"}.notNull: @@ -559,18 +924,13 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = for i in instructions: 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 + for t in extractTweetsFromModuleItems(i{"moduleItems"}): + let photo = extractGalleryPhoto(t) + if photo.url.len > 0: + result.add photo - let photo = extractGalleryPhoto(t) - if photo.url.len > 0: - result.add photo - - if result.len == 16: - return + if result.len == 16: + return continue if i.getTypeName != "TimelineAddEntries": @@ -587,7 +947,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = if result.len == 16: return -proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = +proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=""): Result[T] = result = Result[T](beginning: after.len == 0) let instructions = select( @@ -603,19 +963,73 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = for e in instruction{"entries"}: let entryId = e.getEntryId when T is Tweets: - if entryId.startsWith("tweet"): - with tweetRes, getTweetResult(e): - let tweet = parseGraphTweet(tweetRes) - if not tweet.available: - tweet.id = entryId.getId + if entryId.startsWith("tweet") or entryId.startsWith("search-grid"): + for tweet in extractTweetsFromEntry(e): result.content.add tweet elif T is User: if entryId.startsWith("user"): with userRes, e{"content", "itemContent"}: result.content.add parseGraphUser(userRes) + elif T is ListSearchResult: + if entryId.startsWith("list-search"): + for list in extractListsFromItems(e{"content", "items"}): + result.content.add list if entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr + elif typ == "TimelineAddToModule": + when T is Tweets: + for tweet in extractTweetsFromModuleItems(instruction{"moduleItems"}): + result.content.add tweet + elif T is ListSearchResult: + for list in extractListsFromItems(instruction{"moduleItems"}): + result.content.add list elif typ == "TimelineReplaceEntry": if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): result.bottom = instruction{"entry", "content", "value"}.getStr + +proc parseGraphCommunityTimeline*(js: JsonNode; after=""): Timeline = + result = Timeline(beginning: after.len == 0) + + let communityResult = js{"data", "communityResults", "result"} + let instructions = ? select( + communityResult{"ranked_community_timeline", "timeline", "instructions"}, + communityResult{"community_media_timeline", "timeline", "instructions"}, + communityResult{"community_filtered_timeline", "timeline", "instructions"} + ) + if instructions.len == 0: + return + + for i in instructions: + if i{"entries"}.notNull: + for e in i{"entries"}: + let entryId = e.getEntryId + if entryId.startsWith("tweet") or entryId.startsWith("profile-grid") or + entryId.startsWith("communities-grid"): + for tweet in extractTweetsFromEntry(e): + result.content.add tweet + elif entryId.startsWith("cursor-bottom"): + result.bottom = e{"content", "value"}.getStr + + if after.len == 0 and i.getTypeName == "TimelinePinEntry": + var tweets = extractTweetsFromEntry(i{"entry"}) + for tweet in tweets.mitems: + tweet.pinned = true + if tweets.len > 0: + result.content.insert(tweets, 0) + +proc parseGraphCommunityMembers*(js: JsonNode; after=""): Result[User] = + result = Result[User](beginning: after.len == 0) + + let r = js{"data", "communityResults", "result"} + let slice = if not r{"members_slice"}.isNull: r{"members_slice"} + else: r{"moderators_slice"} + for item in slice{"items_results"}: + let user = parseGraphUser(item{"result"}) + if user.username.len > 0: + result.content.add user + + let cursor = slice{"slice_info", "next_cursor"}.getStr + if cursor.len > 0: + result.bottom = cursor + diff --git a/src/parserutils.nim b/src/parserutils.nim index 72c50e1..4b8e0b4 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -17,7 +17,7 @@ let unReplace = "$1@$2" htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)" - htReplace = "$1$2$3" + htReplace = "$1$2$3" type ReplaceSliceKind = enum @@ -72,7 +72,6 @@ template getTypeName*(js: JsonNode): string = 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()) @@ -89,11 +88,19 @@ proc getTimeFromMs*(js: JsonNode): DateTime = let seconds = ms div 1000 return fromUnix(seconds).utc() +proc getTimeFromMsStr*(js: JsonNode): DateTime = + var ms: int64 + try: ms = parseBiggestInt(js.getStr("0")) + except ValueError: return + 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 parseBiggestInt(id) - return parseBiggestInt(id[start + 1 ..< id.len]) + try: + parseBiggestInt(if start < 0: id else: id[start + 1 ..< id.len]) + except ValueError: 0'i64 proc getId*(js: JsonNode): int64 {.inline.} = case js.kind @@ -112,6 +119,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: @@ -175,12 +185,16 @@ 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) = + textLen: int; hideTwitter = false; + hideArticle = false) = let - url = js["expanded_url"].getStr + url = js.getExpandedUrl slice = js.extractSlice - if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: + if hideArticle and url.isTwitterUrl and "/article/" in url: + if slice.a < textLen: + result.add ReplaceSlice(kind: rkRemove, slice: slice) + elif hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: if slice.a < textLen: result.add ReplaceSlice(kind: rkRemove, slice: slice) else: @@ -192,28 +206,41 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) = proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; textSlice: Slice[int]): string = + let + runeLen = runes.len + safeStart = max(0, textSlice.a) + safeEnd = min(runeLen, textSlice.b) + + var validRepls: seq[ReplaceSlice] + for rep in repls: + if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b: + validRepls.add rep + template extractLowerBound(i: int; idx): int = - if i > 0: repls[idx].slice.b.succ else: textSlice.a + if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart result = newStringOfCap(runes.len) - for i, rep in repls: - result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a] + for i, rep in validRepls: + let lower = extractLowerBound(i, i - 1) + if lower < rep.slice.a: + result.add $runes[lower ..< rep.slice.a] case rep.kind of rkHashtag: - let - name = $runes[rep.slice.a.succ .. rep.slice.b] - symbol = $runes[rep.slice.a] - result.add a(symbol & name, href = "/search?q=%23" & name) + if rep.slice.a.succ <= rep.slice.b: + let + name = $runes[rep.slice.a.succ .. rep.slice.b] + symbol = $runes[rep.slice.a] + result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) of rkMention: - result.add a($runes[rep.slice], href = rep.url, title = rep.display) + result.add a($runes[rep.slice], href = rep.url, title = escape(rep.display)) of rkUrl: - result.add a(rep.display, href = rep.url) + result.add a(escape(rep.display), href = rep.url) of rkRemove: discard - let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b - if rest.a <= rest.b: + let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd + if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen: result.add $runes[rest] proc deduplicate(s: var seq[ReplaceSlice]) = @@ -238,7 +265,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]() @@ -254,7 +281,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) = .replacef(htRegex, htReplace) proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int]; - replyTo=""; hasRedundantLink=false) = + replyTo=""; hasRedundantLink=false; hasArticle=false) = let hasCard = tweet.card.isSome var replacements = newSeq[ReplaceSlice]() @@ -265,10 +292,11 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic if urlStr.len == 0 or urlStr notin text: continue - replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) + replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink, + hideArticle = hasArticle) 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: @@ -302,7 +330,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false) -proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = +proc expandTweetEntities*(tweet: Tweet; js: JsonNode; hasArticle=false) = let entities = ? js{"entities"} textRange = js{"display_text_range"} @@ -316,23 +344,99 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = replyTo = reply.getStr tweet.reply.add replyTo - tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) + tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, + hasQuote or hasJobCard, hasArticle) + +proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; + hasRedundantLink=false; hasArticle=false) = + let hasCard = tweet.card.isSome + + var replacements = newSeq[ReplaceSlice]() + + with urls, js{"url_entities"}: + for u in urls: + let urlStr = u["url"].getStr + if urlStr.len == 0 or urlStr notin text: + continue + + replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink, + hideArticle = hasArticle) + + if hasCard and u{"url"}.getStr == get(tweet.card).url: + get(tweet.card).url = u.getExpandedUrl + + with hashtags, js{"details", "hashtag_entities"}: + for hashtag in hashtags: + replacements.extractHashtags(hashtag) + + with cashtags, js{"details", "cashtag_entities"}: + for cashtag in cashtags: + replacements.extractHashtags(cashtag) + + with mentions, js{"mention_entities"}: + for mention in mentions: + let + name = mention{"screen_name"}.getStr + slice = mention.extractSlice + idx = tweet.reply.find(name) + + if slice.a >= textSlice.a: + replacements.add ReplaceSlice(kind: rkMention, slice: slice, + url: "/" & name, display: mention["name"].getStr) + elif idx == -1 and tweet.replyId != 0: + tweet.reply.add name + + replacements.deduplicate + replacements.sort(cmp) + + tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false) + +proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode; hasArticle=false) = + let + textRange = js{"details", "display_text_range"} + textSlice = textRange{0}.getInt .. textRange{1}.getInt + hasQuote = "quoted_tweet_results" in js + hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails + hasAttribution = tweet.attribution.isSome + + tweet.expandTextEntitiesV2(js, tweet.text, textSlice, + hasQuote or hasJobCard or hasAttribution, + hasArticle) proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = let entities = ? js{"entity_set"} text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose)) textSlice = 0..text.runeLen + hasAttribution = tweet.attribution.isSome - tweet.expandTextEntities(entities, text, textSlice) + tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution) tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose)) +proc expandBirdwatchEntities*(text: string; entities: JsonNode): string = + let runes = text.toRunes + var replacements: seq[ReplaceSlice] + + for entity in entities: + let + fromIdx = entity{"from_index"}.getInt + toIdx = entity{"to_index"}.getInt + url = entity{"ref", "url"}.getStr + if url.len > 0: + replacements.add ReplaceSlice( + kind: rkUrl, + slice: fromIdx ..< toIdx, + url: url, + display: $runes[fromIdx ..< min(toIdx, runes.len)] + ) + + replacements.sort(cmp) + result = runes.replacedWith(replacements, 0 ..< runes.len) + 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 + if t.media.len > 0: t.media[0].getThumb elif t.card.isSome: get(t.card).image else: "" diff --git a/src/prefs.nim b/src/prefs.nim index fa40a6d..1a75f75 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,22 +1,22 @@ # SPDX-License-Identifier: AGPL-3.0-only -import tables +import tables, strutils import types, prefs_impl from config import get from parsecfg import nil -export genUpdatePrefs, genResetPrefs +export genUpdatePrefs, genResetPrefs, genApplyPrefs var defaultPrefs*: Prefs proc updateDefaultPrefs*(cfg: parsecfg.Config) = genDefaultPrefs() -proc getPrefs*(cookies: Table[string, string]): Prefs = +proc getPrefs*(cookies, params: Table[string, string]): Prefs = result = defaultPrefs - genCookiePrefs(cookies) + genParsePrefs(cookies) + genParsePrefs(params) -template getPref*(cookies: Table[string, string], pref): untyped = - bind genCookiePref - var res = defaultPrefs.`pref` - genCookiePref(cookies, pref, res) - res +proc encodePrefs*(prefs: Prefs): string = + var encPairs: seq[string] + genEncodePrefs(prefs) + encPairs.join(",") diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 8e2ac8f..8519bd5 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -60,6 +60,9 @@ genPrefs: stickyProfile(checkbox, true): "Make profile sidebar stick to top" + stickyNav(checkbox, true): + "Keep navbar fixed to top" + bidiSupport(checkbox, false): "Support bidirectional text (makes clicking on tweets harder)" @@ -75,6 +78,12 @@ genPrefs: hideReplies(checkbox, false): "Hide tweet replies" + hideRelated(checkbox, true): + "Hide related tweets under replies" + + hideCommunityNotes(checkbox, false): + "Hide community notes" + squareAvatars(checkbox, false): "Square profile pictures" @@ -94,6 +103,17 @@ genPrefs: autoplayGifs(checkbox, true): "Autoplay gifs" + compactGallery(checkbox, false): + "Compact media gallery (no profile info or text)" + + gallerySize(select, "Medium"): + "Gallery column size" + options: @["Small", "Medium", "Large"] + + mediaView(select, "Timeline"): + "Default media view" + options: @["Timeline", "Grid", "Gallery"] + "Link replacements (blank to disable)": replaceTwitter(input, ""): "Twitter -> Nitter" @@ -127,7 +147,7 @@ macro genDefaultPrefs*(): untyped = result.add quote do: defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`) -macro genCookiePrefs*(cookies): untyped = +macro genParsePrefs*(prefs): untyped = result = nnkStmtList.newTree() for pref in allPrefs(): let @@ -137,37 +157,17 @@ macro genCookiePrefs*(cookies): untyped = options = pref.options result.add quote do: - if `name` in `cookies`: + if `name` in `prefs`: when `kind` == input or `name` == "theme": - result.`ident` = `cookies`[`name`] + result.`ident` = `prefs`[`name`] elif `kind` == checkbox: - result.`ident` = `cookies`[`name`] == "on" + result.`ident` = `prefs`[`name`] == "on" or + `prefs`[`name`] == "true" or + `prefs`[`name`] == "1" else: - let value = `cookies`[`name`] + let value = `prefs`[`name`] if value in `options`: result.`ident` = value -macro genCookiePref*(cookies, prefName, res): untyped = - result = nnkStmtList.newTree() - for pref in allPrefs(): - let ident = ident(pref.name) - if ident != prefName: - continue - - let - name = pref.name - kind = newLit(pref.kind) - options = pref.options - - result.add quote do: - if `name` in `cookies`: - when `kind` == input or `name` == "theme": - `res` = `cookies`[`name`] - elif `kind` == checkbox: - `res` = `cookies`[`name`] == "on" - else: - let value = `cookies`[`name`] - if value in `options`: `res` = value - macro genUpdatePrefs*(): untyped = result = nnkStmtList.newTree() let req = ident("request") @@ -202,6 +202,36 @@ macro genResetPrefs*(): untyped = result.add quote do: savePref(`name`, "", `req`, expire=true) +macro genEncodePrefs*(prefs): untyped = + result = nnkStmtList.newTree() + for pref in allPrefs(): + let + name = newLit(pref.name) + ident = ident(pref.name) + kind = newLit(pref.kind) + defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name)) + + result.add quote do: + when `kind` == checkbox: + if `prefs`.`ident` != `defaultIdent`: + if `prefs`.`ident`: + encPairs.add `name` & "=on" + else: + encPairs.add `name` & "=" + else: + if `prefs`.`ident` != `defaultIdent`: + encPairs.add `name` & "=" & `prefs`.`ident` + +macro genApplyPrefs*(params, req): untyped = + result = nnkStmtList.newTree() + for pref in allPrefs(): + let name = newLit(pref.name) + result.add quote do: + if `name` in `params`: + savePref(`name`, `params`[`name`], `req`) + else: + savePref(`name`, "", `req`, expire=true) + macro genPrefsType*(): untyped = let name = nnkPostfix.newTree(ident("*"), ident("Prefs")) result = quote do: diff --git a/src/query.nim b/src/query.nim index 06e1da2..ecb428d 100644 --- a/src/query.nim +++ b/src/query.nim @@ -1,15 +1,14 @@ # SPDX-License-Identifier: AGPL-3.0-only import strutils, strformat, sequtils, tables, uri -import types +import types, utils 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" @@ -21,32 +20,43 @@ template `@`(param: string): untyped = proc initQuery*(pms: Table[string, string]; name=""): Query = result = Query( kind: parseEnum[QueryKind](@"f", tweets), + view: @"view", text: @"q", filters: validFilters.filterIt("f-" & it in pms), excludes: validFilters.filterIt("e-" & it in pms), since: @"since", until: @"until", - near: @"near" + minLikes: validateNumber(@"min_faves") ) + # articles is an internal tab kind, not a valid search filter + if result.kind == QueryKind.articles: + result.kind = tweets + if name.len > 0: result.fromUser = name.split(",") proc getMediaQuery*(name: string): Query = Query( - kind: media, + kind: QueryKind.media, filters: @["twimg", "native_video"], fromUser: @[name], sep: "OR" ) +proc getArticlesQuery*(name: string): Query = + Query( + kind: QueryKind.articles, + fromUser: @[name] + ) + proc getReplyQuery*(name: string): Query = Query( kind: replies, fromUser: @[name] ) -proc genQueryParam*(query: Query): string = +proc genQueryParam*(query: Query; maxId=""): string = var filters: seq[string] param: string @@ -55,15 +65,20 @@ proc genQueryParam*(query: Query): string = return query.text for i, user in query.fromUser: - param &= &"from:{user} " - if i < query.fromUser.high: - param &= "OR " + if i == 0: + param = "(" - if query.fromUser.len > 0 and query.kind in {posts, media}: - param &= "filter:self_threads OR -filter:replies " + param &= &"from:{user}" + if i < query.fromUser.high: + param &= " OR " + else: + param &= ")" + + if query.fromUser.len > 0 and query.kind in {posts, QueryKind.media}: + param &= " (filter:self_threads OR -filter:replies)" if "nativeretweets" notin query.excludes: - param &= "include:nativeretweets " + param &= " include:nativeretweets" for f in query.filters: filters.add "filter:" & f @@ -73,38 +88,51 @@ proc genQueryParam*(query: Query): string = for i in query.includes: filters.add "include:" & i - result = strip(param & filters.join(&" {query.sep} ")) + if filters.len > 0: + result = strip(param & " (" & filters.join(&" {query.sep} ") & ")") + else: + result = strip(param) + if query.since.len > 0: result &= " since:" & query.since - if query.until.len > 0: + if query.until.len > 0 and maxId.len == 0: result &= " until:" & query.until - if query.near.len > 0: - result &= &" near:\"{query.near}\" within:15mi" + if query.minLikes.len > 0: + result &= " min_faves:" & query.minLikes if query.text.len > 0: if result.len > 0: result &= " " & query.text else: result = query.text + if result.len > 0 and maxId.len > 0: + result &= " max_id:" & maxId + proc genQueryUrl*(query: Query): string = - if query.kind notin {tweets, users}: return + var params: seq[string] - var params = @[&"f={query.kind}"] - if query.text.len > 0: - params.add "q=" & encodeUrl(query.text) - for f in query.filters: - params.add &"f-{f}=on" - for e in query.excludes: - params.add &"e-{e}=on" - for i in query.includes.filterIt(it != "nativeretweets"): - params.add &"i-{i}=on" + if query.view.len > 0: + params.add "view=" & encodeUrl(query.view) - if query.since.len > 0: - params.add "since=" & query.since - if query.until.len > 0: - params.add "until=" & query.until - if query.near.len > 0: - params.add "near=" & query.near + # media doubles as the profile media tab, where f isn't part of the URL scheme + if query.kind in {tweets, users, lists, top} or + (query.kind == QueryKind.media and query.fromUser.len == 0): + params.add &"f={query.kind}" + if query.text.len > 0: + params.add "q=" & encodeUrl(query.text) + for f in query.filters: + params.add &"f-{f}=on" + for e in query.excludes: + params.add &"e-{e}=on" + for i in query.includes.filterIt(it != "nativeretweets"): + params.add &"i-{i}=on" + + if query.since.len > 0: + params.add "since=" & query.since + if query.until.len > 0: + params.add "until=" & query.until + if query.minLikes.len > 0: + params.add "min_faves=" & query.minLikes if params.len > 0: result &= params.join("&") diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 559d299..b9ddbcc 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -144,9 +144,10 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = else: let user = await getGraphUserById(userId) result = user.username - await setEx(key, baseCacheTime, result) - if result.len > 0 and user.id.len > 0: - await all(cacheUserId(result, user.id), cache(user)) + if result.len > 0: + await setEx(key, baseCacheTime, result) + if user.id.len > 0: + await all(cacheUserId(result, user.id), cache(user)) # proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = # if id == 0: return @@ -158,6 +159,48 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = # if not result.isNil: # await cache(result) +proc cache*(data: Broadcast) {.async.} = + if data.id.len == 0: return + await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data))) + +proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} = + if id.len == 0: return + let cached = await get("bc:" & id) + if cached != redisNil: + cached.deserialize(Broadcast) + else: + result = await getBroadcastInfo(id) + await cache(result) + result.m3u8Url = await fetchBroadcastStream(result.mediaKey) + +proc cache*(data: AudioSpace) {.async.} = + if data.id.len == 0: return + let ttl = if data.state == "RUNNING": baseCacheTime div 6 else: baseCacheTime + await setEx("sp:" & data.id, ttl, compress(toFlatty(data))) + +proc getCachedAudioSpace*(id: string): Future[AudioSpace] {.async.} = + if id.len == 0: return + let cached = await get("sp:" & id) + if cached != redisNil: + cached.deserialize(AudioSpace) + else: + result = await getAudioSpace(id) + await cache(result) + result.m3u8Url = await fetchBroadcastStream(result.mediaKey) + +proc cache*(data: AccountInfo; name: string) {.async.} = + await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data))) + +proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} = + if username.len == 0: return + let name = toLower(username) + let cached = await get("ai:" & name) + if cached != redisNil: + cached.deserialize(AccountInfo) + elif fetch: + result = await getAboutAccount(username) + await cache(result, name) + proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return let rail = await get("pr2:" & toLower(id)) @@ -167,6 +210,29 @@ proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = result = await getPhotoRail(id) await cache(result, id) +proc cache*(data: Community) {.async.} = + if data.id.len == 0: return + await setEx("cm:" & data.id, listCacheTime, compress(toFlatty(data))) + +proc getCachedCommunity*(id: string): Future[Community] {.async.} = + if id.len == 0: return + let cached = await get("cm:" & id) + if cached != redisNil: + cached.deserialize(Community) + else: + result = await getGraphCommunity(id) + await cache(result) + +proc getCachedCommunityModerators*(id: string): Future[seq[User]] {.async.} = + if id.len == 0: return + let cached = await get("cmm:" & id) + if cached != redisNil: + cached.deserialize(seq[User]) + else: + let mods = await getGraphCommunityModerators(id) + result = mods.content + await setEx("cmm:" & id, listCacheTime, compress(toFlatty(result))) + proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} = let list = if id.len == 0: redisNil else: await get("l:" & id) diff --git a/src/routes/article.nim b/src/routes/article.nim new file mode 100644 index 0000000..0a7d1dc --- /dev/null +++ b/src/routes/article.nim @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import asyncdispatch, tables, strutils +import jester, karax/vdom +import ".."/[types, api] +import ../views/[article, general] +import router_utils + +export api, article, vdom, general, router_utils + +proc createArticleRouter*(cfg: Config) = + router articleRoute: + get "/i/article/@id": + cond @"id".allCharsInSet(Digits) + + let article = await getGraphArticle(@"id") + if article == nil: + resp Http404, showError("Article not found", cfg) + + var tweetIds: seq[string] + for e in article.entities.values: + if e.kind == "TWEET": + tweetIds.add e.tweetId + + var tweets = initTable[int64, Tweet]() + if tweetIds.len > 0: + try: + for t in await getGraphTweetResults(tweetIds): + tweets[t.id] = t + except CatchableError: + discard + + let + prefs = requestPrefs() + path = getPath() + html = renderArticle(article, tweets, path, prefs, @"id") + twitterUrl = "https://x.com/" & article.user.username & "/article/" & @"id" + resp renderMain(html, request, cfg, prefs, titleText=article.title, + twitterLink=twitterUrl) + + get "/@name/article/@id/?": + cond '.' notin @"name" + cond @"id".allCharsInSet(Digits) + redirect("/i/article/" & @"id") + + get "/@name/status/@id/article": + cond '.' notin @"name" + cond @"id".allCharsInSet(Digits) + redirect("/i/article/" & @"id") diff --git a/src/routes/broadcast.nim b/src/routes/broadcast.nim new file mode 100644 index 0000000..d3bb95a --- /dev/null +++ b/src/routes/broadcast.nim @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import asyncdispatch, strutils +import jester + +import router_utils +import ".."/[types, formatters, redis_cache] +import ../views/[general, broadcast] +import media + +export broadcast + +proc createBroadcastRouter*(cfg: Config) = + router broadcastRoute: + get "/i/broadcasts/@id": + cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) + var bc: Broadcast + try: + bc = await getCachedBroadcast(@"id") + except: + discard + + if bc.id.len == 0: + resp Http404, showError("Broadcast not found", cfg) + + let prefs = requestPrefs() + resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs, + bc.title, ogTitle=bc.title) + + get "/i/broadcasts/@id/stream": + cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) + var bc: Broadcast + try: + bc = await getCachedBroadcast(@"id") + except: + discard + + if bc.m3u8Url.len == 0: + resp Http404 + + let manifest = await safeFetch(bc.m3u8Url) + if manifest.len == 0: + resp Http502 + + resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime diff --git a/src/routes/community.nim b/src/routes/community.nim new file mode 100644 index 0000000..b850b6b --- /dev/null +++ b/src/routes/community.nim @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strformat + +import jester + +import router_utils +import ".."/[types, redis_cache, api] +import ../views/[general, timeline, community] + +export community + +template respCommunity*(cmty: Community; title: string; nav, vnode: typed) = + if cmty.id.len == 0 or cmty.name.len == 0: + resp Http404, showError(&"""Community "{@"id"}" not found""", cfg) + + let html = renderCommunity(vnode, nav, cmty) + resp renderMain(html, request, cfg, prefs, titleText=title, banner=cmty.banner) + +proc createCommunityRouter*(cfg: Config) = + router community: + get "/i/communities/@id/?": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + tl = await getGraphCommunityTweets(cmty.id, "Relevance", getCursor()) + respCommunity(cmty, cmty.name, + renderCommunityTabs(QueryKind.posts, cmty), + renderTimelineTweets(tl, prefs, request.path)) + + get "/i/communities/@id/latest": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + tl = await getGraphCommunityTweets(cmty.id, "Recency", getCursor()) + respCommunity(cmty, cmty.name & " - Latest", + renderCommunityTabs(QueryKind.replies, cmty), + renderTimelineTweets(tl, prefs, request.path)) + + get "/i/communities/@id/media": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + tl = await getGraphCommunityMedia(cmty.id, getCursor()) + respCommunity(cmty, cmty.name & " - Media", + renderCommunityTabs(QueryKind.media, cmty), + renderTimelineTweets(tl, prefs, request.path)) + + get "/i/communities/@id/about": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + mods = await getCachedCommunityModerators(cmty.id) + respCommunity(cmty, cmty.name & " - About", + renderCommunityTabs(QueryKind.userList, cmty), + renderCommunityAbout(cmty, mods)) + + get "/i/communities/@id/members": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + members = await getGraphCommunityMembers(cmty.id, getCursor()) + respCommunity(cmty, cmty.name & " - Members", + renderMemberTabs(cmty, false), + renderTimelineUsers(members, prefs, request.path)) + + get "/i/communities/@id/moderators": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + mods = await getCachedCommunityModerators(cmty.id) + respCommunity(cmty, cmty.name & " - Moderators", + renderMemberTabs(cmty, true), + renderTimelineUsers(Result[User](content: mods), prefs, request.path)) + + get "/i/communities/@id/hashtag/@tag": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + tl = await getGraphCommunityHashtags(cmty.id, @"tag", getCursor()) + respCommunity(cmty, cmty.name & " - #" & @"tag", + renderHashtagHeader(cmty, @"tag"), + renderTimelineTweets(tl, prefs, request.path)) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 994364b..24bba2d 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -1,29 +1,79 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strutils, strformat, options +import asyncdispatch, strutils, strformat, json import jester, karax/vdom -import ".."/[types, api] +import ".."/[types, api, formatters] import ../views/[embed, tweet, general] +include "../views/oembed.nimf" import router_utils export api, embed, vdom, tweet, general, router_utils +proc parseTweetPath(path: string): tuple[username, id: string] = + let parts = path.split('/') + if parts.len >= 3 and parts[1] in ["status", "statuses"]: + let tweetId = parts[2].split('?')[0].split('#')[0] + if tweetId.len > 0 and tweetId.allCharsInSet(Digits): + return (parts[0], tweetId) + return ("", "") + +proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] = + var path = url + if path.startsWith("https://"): + path = path[8..^1] + elif path.startsWith("http://"): + path = path[7..^1] + + const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/", + "www.twitter.com/", "www.x.com/"] + + for prefix in twitterPrefixes: + if path.startsWith(prefix): + return parseTweetPath(path[prefix.len..^1]) + + let nitterPrefix = cfg.hostname & "/" + if path.startsWith(nitterPrefix): + return parseTweetPath(path[nitterPrefix.len..^1]) + + # Fall back: strip any hostname and try to parse as a tweet path. + # Handles requests where the URL's host differs from cfg.hostname + # (e.g. localhost in dev/CI, or a reverse proxy with a different domain). + let slashPos = path.find('/') + if slashPos > 0: + let afterHost = path[slashPos + 1..^1] + let parsed = parseTweetPath(afterHost) + if parsed.username.len > 0: + return parsed + + return ("", "") + proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": - let tweet = await getGraphTweetResult(@"id") - if tweet == nil or tweet.video.isNone: - resp Http404 + let + id = @"id" + tweet = await getTweetByRestId(id) + prefs = requestPrefs() + + if tweet == nil: + resp renderErrorEmbed("Tweet not found", prefs, cfg, request, tweetId=id) + + if not tweet.hasVideos: + resp renderErrorEmbed("No video in tweet", prefs, cfg, request, + tweetId=id, username=tweet.user.username) resp renderVideoEmbed(tweet, cfg, request) get "/@user/status/@id/embed": let - tweet = await getGraphTweetResult(@"id") - prefs = cookiePrefs() + id = @"id" + user = @"user" + tweet = await getTweetByRestId(id) + prefs = requestPrefs() path = getPath() if tweet == nil: - resp Http404 + resp renderErrorEmbed("Tweet not found", prefs, cfg, request, + tweetId=id, username=user) resp renderTweetEmbed(tweet, path, prefs, cfg, request) @@ -34,3 +84,57 @@ proc createEmbedRouter*(cfg: Config) = redirect(&"/i/status/{id}/embed") else: resp Http404 + + get "/api/oembed": + responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) + + let + url = @"url" + format = @"format" + + if format.len > 0 and format != "json": + resp Http501, "Only JSON format is supported" + + if url.len == 0: + resp Http400, "Missing url parameter" + + let (username, tweetId) = parseTweetUrl(url, cfg) + if username.len == 0 or tweetId.len == 0: + resp Http400, "Invalid tweet URL" + + let tweet = await getTweetByRestId(tweetId) + if tweet == nil: + resp Http404 + + let + maxwidthParam = @"maxwidth" + maxwidth = if maxwidthParam.len > 0: + try: clamp(parseInt(maxwidthParam), 220, 550) + except ValueError: 550 + else: 550 + embedUrl = getUrlPrefix(cfg) & "/" & tweet.user.username & "/status/" & tweetId & "/embed" + authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username + title = stripHtml(tweet.text) + + var response = %*{ + "version": "1.0", + "type": "rich", + "provider_name": cfg.title, + "provider_url": getUrlPrefix(cfg), + "title": title, + "author_name": tweet.user.fullname, + "author_url": authorUrl, + "url": embedUrl, + "width": maxwidth, + "height": newJNull(), + "cache_age": "3153600000", + "html": renderOembedIframe(embedUrl, maxwidth) + } + + if tweet.media.len > 0: + let thumbUrl = getUrlPrefix(cfg) & getPicUrl(tweet.media[0].getThumb) + response["thumbnail_url"] = %thumbUrl + response["thumbnail_width"] = %maxwidth + response["thumbnail_height"] = %maxwidth + + respJson response diff --git a/src/routes/list.nim b/src/routes/list.nim index ac3e97e..b4ab091 100644 --- a/src/routes/list.nim +++ b/src/routes/list.nim @@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) = let html = renderList(vnode, timeline.query, list) - rss = &"""/i/lists/{@"id"}/rss""" + rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: "" resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner) @@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) = get "/i/lists/@id/?": cond '.' notin @"id" let - prefs = cookiePrefs() + prefs = requestPrefs() list = await getCachedList(id=(@"id")) timeline = await getGraphListTweets(list.id, getCursor()) vnode = renderTimelineTweets(timeline, prefs, request.path) @@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) = get "/i/lists/@id/members": cond '.' notin @"id" let - prefs = cookiePrefs() + prefs = requestPrefs() list = await getCachedList(id=(@"id")) members = await getGraphListMembers(list, getCursor()) respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) diff --git a/src/routes/media.nim b/src/routes/media.nim index de51061..3442916 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import uri, strutils, httpclient, os, hashes, base64, re import asynchttpserver, asyncstreams, asyncfile, asyncnet +import asyncdispatch import jester @@ -15,7 +16,9 @@ const maxAge* = "max-age=604800" proc safeFetch*(url: string): Future[string] {.async.} = - let client = newAsyncHttpClient() + # maxRedirects=0: the caller already validated the host, so never follow a + # redirect off the allowlisted host (would re-open the #1411 SSRF). + let client = newAsyncHttpClient(maxRedirects = 0) try: result = await client.getContent(url) except: discard finally: client.close() @@ -30,47 +33,79 @@ template respond*(req: asynchttpserver.Request; headers) = proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = result = Http200 - let - request = req.getNativeReq() - client = newAsyncHttpClient() + let request = req.getNativeReq() + var fetchUrl = url - try: - 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] - return Http404 - - let hashed = $hash(url) - if request.headers.getOrDefault("If-None-Match") == hashed: - return Http304 - - let contentLength = - if res.headers.hasKey("content-length"): - res.headers["content-length", 0] + for attempt in 0 .. 2: + let client = newAsyncHttpClient(maxRedirects = 0) + var shouldRetry = false + try: + let resFut = client.get(fetchUrl) + let completed = await withTimeout(resFut, 5000) + if not completed: + if attempt < 2: + echo "[media] Retry $1/2, timeout after 5s, url: $2" % [$(attempt + 1), fetchUrl] + shouldRetry = true + else: + echo "[media] Proxying timeout after 5s, url: $1" % [fetchUrl] + return Http504 else: - "" + let res = resFut.read() + if res.status != "200 OK": + if res.status == "404 Not Found": + return Http404 + if res.status.startsWith("30") and res.headers.hasKey("location"): + let location = res.headers["location", 0] + if isTwitterUrl(location): + fetchUrl = location + shouldRetry = true + continue + else: + return Http403 + if attempt < 2: + echo "[media] Retry $1/2, status: $2, url: $3" % [$(attempt + 1), res.status, fetchUrl] + shouldRetry = true + else: + echo "[media] Proxying failed, status: $1, url: $2" % [res.status, fetchUrl] + return Http404 + else: + let hashed = $hash(url) + if request.headers.getOrDefault("If-None-Match") == hashed: + return Http304 - let headers = newHttpHeaders({ - "Content-Type": res.headers["content-type", 0], - "Content-Length": contentLength, - "Cache-Control": maxAge, - "ETag": hashed - }) + let contentLength = + if res.headers.hasKey("content-length"): + res.headers["content-length", 0] + else: + "" - respond(request, headers) + let headers = newHttpHeaders({ + "content-type": res.headers["content-type", 0], + "content-length": contentLength, + "cache-control": maxAge, + "etag": hashed + }) - var (hasValue, data) = (true, "") - while hasValue: - (hasValue, data) = await res.bodyStream.read() - if hasValue: - await request.client.send(data) - data.setLen 0 - except HttpRequestError, ProtocolError, OSError: - echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] - result = Http404 - finally: - client.close() + respond(request, headers) + + var (hasValue, data) = (true, "") + while hasValue: + (hasValue, data) = await res.bodyStream.read() + if hasValue: + await request.client.send(data) + data.setLen 0 + return Http200 + except CatchableError: + if attempt < 2: + echo "[media] Retry $1/2, error: $2, url: $3" % [$(attempt + 1), getCurrentExceptionMsg(), fetchUrl] + shouldRetry = true + else: + echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), fetchUrl] + result = Http404 + finally: + client.close() + if not shouldRetry: + break template check*(code): untyped = if code != Http200: @@ -86,6 +121,12 @@ proc decoded*(req: jester.Request; index: int): string = if based: decode(encoded) else: decodeUrl(encoded) +proc normalizeImgUrl*(url: var string) = + if not url.startsWith("http"): + if "twimg.com" notin url: + url.insert(twimg) + url.insert(https) + proc createMediaRouter*(cfg: Config) = router media: get "/pic/?": @@ -93,10 +134,8 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/orig\/(enc)?\/?(.+)": var url = decoded(request, 1) - if "twimg.com" notin url: - url.insert(twimg) - if not url.startsWith(https): - url.insert(https) + cond "/amplify_video/" notin url + normalizeImgUrl(url) url.add("?name=orig") let uri = parseUri(url) @@ -107,10 +146,8 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/(enc)?\/?(.+)": var url = decoded(request, 1) - if "twimg.com" notin url: - url.insert(twimg) - if not url.startsWith(https): - url.insert(https) + cond "/amplify_video/" notin url + normalizeImgUrl(url) let uri = parseUri(url) cond isTwitterUrl(uri) == true @@ -120,12 +157,12 @@ proc createMediaRouter*(cfg: Config) = get re"^\/video\/(enc)?\/?(.+)\/(.+)$": let url = decoded(request, 2) - cond "http" in url + cond isTwitterUrl(url) if getHmac(url) != request.matches[1]: resp Http403, showError("Failed to verify signature", cfg) - if ".mp4" in url or ".ts" in url or ".m4s" in url: + if ".mp4" in url or ".ts" in url or ".m4s" in url or ".aac" in url: let code = await proxyMedia(request, url) check code @@ -139,6 +176,6 @@ proc createMediaRouter*(cfg: Config) = if ".m3u8" in url: let vid = await safeFetch(url) - content = proxifyVideo(vid, cookiePref(proxyVideos)) + content = proxifyVideo(vid, requestPrefs().proxyVideos, url) resp content, m3u8Mime diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim index b8af03d..7f04de2 100644 --- a/src/routes/preferences.nim +++ b/src/routes/preferences.nim @@ -19,8 +19,10 @@ proc createPrefRouter*(cfg: Config) = router preferences: get "/settings": let - prefs = cookiePrefs() - html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir)) + prefs = requestPrefs() + prefsCode = encodePrefs(prefs) + prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode + html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl) resp renderMain(html, request, cfg, prefs, "Preferences") get "/settings/@i?": @@ -38,3 +40,6 @@ proc createPrefRouter*(cfg: Config) = savePref("hlsPlayback", "on", request) redirect(refPath()) + post "/enablemp4": + savePref("mp4Playback", "on", request) + redirect(refPath()) diff --git a/src/routes/resolver.nim b/src/routes/resolver.nim index 1baf873..5f074a5 100644 --- a/src/routes/resolver.nim +++ b/src/routes/resolver.nim @@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) = router resolver: get "/cards/@card/@id": let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"] - respResolved(await resolve(url, cookiePrefs()), "card") + respResolved(await resolve(url, requestPrefs()), "card") get "/t.co/@url": let url = "https://t.co/" & @"url" - respResolved(await resolve(url, cookiePrefs()), "t.co") + respResolved(await resolve(url, requestPrefs()), "t.co") diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index a071a0d..612a96b 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -4,26 +4,19 @@ from jester import Request, cookies import ../views/general import ".."/[utils, prefs, types] -export utils, prefs, types, uri +export utils, prefs, types, uri, json template savePref*(pref, value: string; req: Request; expire=false) = if not expire or pref in cookies(req): + let sameSite = if cfg.useHttps: None else: Lax setCookie(pref, value, daysForward(when expire: -10 else: 360), - httpOnly=true, secure=cfg.useHttps, sameSite=None) + httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/") -template cookiePrefs*(): untyped {.dirty.} = - getPrefs(cookies(request)) - -template cookiePref*(pref): untyped {.dirty.} = - getPref(cookies(request), pref) - -template themePrefs*(): Prefs = - var res = defaultPrefs - res.theme = cookiePref(theme) - res +template requestPrefs*(): untyped {.dirty.} = + getPrefs(cookies(request), params(request)) template showError*(error: string; cfg: Config): string = - renderMain(renderError(error), request, cfg, themePrefs(), "Error") + renderMain(renderError(error), request, cfg, requestPrefs(), "Error") template getPath*(): untyped {.dirty.} = $(parseUri(request.path) ? filterParams(request.params)) @@ -43,5 +36,28 @@ template getCursor*(req: Request): string = proc getNames*(name: string): seq[string] = name.strip(chars={'/'}).split(",").filterIt(it.len > 0) +template applyUrlPrefs*() {.dirty.} = + if @"prefs".len > 0: + var prefParams = initTable[string, string]() + for pair in @"prefs".split(','): + let kv = pair.split('=', maxsplit=1) + if kv.len == 2: + prefParams[kv[0]] = kv[1] + elif kv.len == 1 and kv[0].len > 0: + prefParams[kv[0]] = "" + genApplyPrefs(prefParams, request) + + # Rebuild URL without prefs param + var params: seq[(string, string)] + for k, v in request.params: + if k != "prefs": + params.add (k, v) + + if params.len > 0: + let cleanUrl = request.getNativeReq.url ? params + redirect($cleanUrl) + else: + redirect(request.path) + template respJson*(node: JsonNode) = resp $node, "application/json" diff --git a/src/routes/rss.nim b/src/routes/rss.nim index b0e781d..038096c 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string = if cursor.len > 0: result &= ":" & cursor -proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} = +proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} = var profile: Profile let name = req.params.getOrDefault("name") @@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. return Rss(feed: profile.user.username, cursor: "suspended") if profile.user.fullname.len > 0: - let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1)) + let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1)) return Rss(feed: rss, cursor: profile.tweets.bottom) template respRss*(rss, page) = @@ -60,12 +60,15 @@ template respRss*(rss, page) = proc createRssRouter*(cfg: Config) = router rss: get "/search/rss": - cond cfg.enableRss + if not cfg.enableRSSSearch: + resp Http403, showError("RSS feed is disabled", cfg) if @"q".len > 200: resp Http400, showError("Search input too long.", cfg) - let query = initQuery(params(request)) - if query.kind != tweets: + let + prefs = requestPrefs() + query = initQuery(params(request)) + if query.kind notin {QueryKind.tweets, QueryKind.top, QueryKind.media}: resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) let @@ -78,15 +81,17 @@ proc createRssRouter*(cfg: Config) = let tweets = await getGraphTweetSearch(query, cursor) rss.cursor = tweets.bottom - rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) + rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs) await cacheRss(key, rss) respRss(rss, "Search") get "/@name/rss": - cond cfg.enableRss cond '.' notin @"name" + if not cfg.enableRSSUserTweets: + resp Http403, showError("RSS feed is disabled", cfg) let + prefs = requestPrefs() name = @"name" key = redisKey("twitter", name, getCursor()) @@ -94,24 +99,23 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "User") - rss = await timelineRss(request, cfg, Query(fromUser: @[name])) + rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs) await cacheRss(key, rss) respRss(rss, "User") get "/@name/@tab/rss": - cond cfg.enableRss cond '.' notin @"name" - cond @"tab" in ["with_replies", "media", "search"] + cond @"tab" in ["with_replies", "media", "search", "articles"] + # articles can't be approximated by search, so multi-user is unsupported + cond not (@"tab" == "articles" and ',' in @"name") + if not cfg.tabRssEnabled(@"tab"): + resp Http403, showError("RSS feed is disabled", cfg) let + prefs = requestPrefs() name = @"name" tab = @"tab" - query = - case tab - of "with_replies": getReplyQuery(name) - of "media": getMediaQuery(name) - of "search": initQuery(params(request), name=name) - else: Query(fromUser: @[name]) + query = request.getQuery(tab, name, prefs) let searchKey = if tab != "search": "" else: ":" & $hash(genQueryUrl(query)) @@ -122,14 +126,15 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "User") - rss = await timelineRss(request, cfg, query) + rss = await timelineRss(request, cfg, query, prefs) await cacheRss(key, rss) respRss(rss, "User") get "/@name/lists/@slug/rss": - cond cfg.enableRss cond @"name" != "i" + if not cfg.enableRSSList: + resp Http403, showError("RSS feed is disabled", cfg) let slug = decodeUrl(@"slug") list = await getCachedList(@"name", slug) @@ -145,8 +150,10 @@ proc createRssRouter*(cfg: Config) = redirect(url) get "/i/lists/@id/rss": - cond cfg.enableRss + if not cfg.enableRSSList: + resp Http403, showError("RSS feed is disabled", cfg) let + prefs = requestPrefs() id = @"id" cursor = getCursor() key = redisKey("lists", id, cursor) @@ -159,7 +166,7 @@ proc createRssRouter*(cfg: Config) = list = await getCachedList(id=id) timeline = await getGraphListTweets(list.id, cursor) rss.cursor = timeline.bottom - rss.feed = renderListRss(timeline.content, list, cfg) + rss.feed = renderListRss(timeline.content, list, cfg, prefs) await cacheRss(key, rss) respRss(rss, "List") diff --git a/src/routes/search.nim b/src/routes/search.nim index e9f991d..7c7fd14 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -19,10 +19,22 @@ proc createSearchRouter*(cfg: Config) = resp Http400, showError("Search input too long.", cfg) let - prefs = cookiePrefs() - query = initQuery(params(request)) + prefs = requestPrefs() title = "Search" & (if q.len > 0: " (" & q & ")" else: "") + var query = initQuery(params(request)) + # x.com URL compat: f=user and f=list map to our kind names + # (f=live already falls back to tweets/Latest; f=media matches natively) + if @"f" == "user": + query.kind = users + elif @"f" == "list": + query.kind = lists + + # media searches support view modes, defaulting like /user/media + if query.kind == QueryKind.media and + query.view notin ["timeline", "grid", "gallery"]: + query.view = prefs.mediaView.toLowerAscii + case query.kind of users: if "," in q: @@ -33,19 +45,24 @@ proc createSearchRouter*(cfg: Config) = except InternalError: users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) - of tweets: + of tweets, top, QueryKind.media: let tweets = await getGraphTweetSearch(query, getCursor()) - rss = "/search/rss?" & genQueryUrl(query) + rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: "" resp renderMain(renderTweetSearch(tweets, prefs, getPath()), request, cfg, prefs, title, rss=rss) + of lists: + let listResults = await getGraphListSearch(query, getCursor()) + resp renderMain(renderListSearch(listResults, prefs, getPath()), + request, cfg, prefs, title) else: resp Http404, showError("Invalid search", cfg) get "/hashtag/@hash": - redirect("/search?q=" & encodeUrl("#" & @"hash")) + redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash")) get "/opensearch": - let url = getUrlPrefix(cfg) & "/search?q=" - resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, - generateOpenSearchXML(cfg.title, cfg.hostname, url) + let + url = getUrlPrefix(cfg) & "/search?f=tweets&q=" + headers = {"Content-Type": "application/opensearchdescription+xml"} + resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url) diff --git a/src/routes/space.nim b/src/routes/space.nim new file mode 100644 index 0000000..bd956ea --- /dev/null +++ b/src/routes/space.nim @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import asyncdispatch, strutils +import jester + +import router_utils +import ".."/[types, formatters, redis_cache] +import ../views/[general, space] +import media + +export space + +proc createSpaceRouter*(cfg: Config) = + router spaceRoute: + get "/i/spaces/@id": + cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) + let sp = await getCachedAudioSpace(@"id") + + if sp.id.len == 0: + resp Http404, showError("Space not found", cfg) + + let prefs = requestPrefs() + resp renderMain(renderSpace(sp, prefs, request.path), request, cfg, prefs, + sp.title, ogTitle=sp.title) + + get "/i/spaces/@id/stream": + cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) + let sp = await getCachedAudioSpace(@"id") + + if sp.m3u8Url.len == 0: + resp Http404 + + let manifest = await safeFetch(sp.m3u8Url) + if manifest.len == 0: + resp Http502 + + resp proxifyVideo(manifest, requestPrefs().proxyVideos, sp.m3u8Url), m3u8Mime diff --git a/src/routes/status.nim b/src/routes/status.nim index 0168dac..32a4447 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -21,16 +21,18 @@ proc createStatusRouter*(cfg: Config) = if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) - let prefs = cookiePrefs() + let + prefs = requestPrefs() + sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance) # used for the infinite scroll feature if @"scroll".len > 0: - let replies = await getReplies(id, getCursor()) + let replies = await getReplies(id, getCursor(), sort) if replies.content.len == 0: - resp Http404, "" - resp $renderReplies(replies, prefs, getPath()) + resp Http204 + resp $renderReplies(replies, prefs, getPath(), sort=sort) - let conv = await getTweet(id, getCursor()) + let conv = await getTweet(id, getCursor(), sort) if conv == nil or conv.tweet == nil or conv.tweet.id == 0: var error = "Tweet not found" @@ -44,15 +46,19 @@ proc createStatusRouter*(cfg: Config) = desc = conv.tweet.text var - images = conv.tweet.photos + images = conv.tweet.getPhotos.mapIt(it.url) video = "" - if conv.tweet.video.isSome(): - images = @[get(conv.tweet.video).thumb] + let + firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind + else: photoMedia + + if firstMediaKind == videoMedia: + images = @[conv.tweet.media[0].getThumb] video = getVideoEmbed(cfg, conv.tweet.id) - elif conv.tweet.gif.isSome(): - images = @[get(conv.tweet.gif).thumb] - video = getPicUrl(get(conv.tweet.gif).url) + elif firstMediaKind == gifMedia: + images = @[conv.tweet.media[0].getThumb] + video = getPicUrl(conv.tweet.media[0].gif.url) elif conv.tweet.card.isSome(): let card = conv.tweet.card.get() if card.image.len > 0: @@ -60,13 +66,37 @@ proc createStatusRouter*(cfg: Config) = elif card.video.isSome(): images = @[card.video.get().thumb] - let html = renderConversation(conv, prefs, getPath() & "#m") + let + tweetUrl = getUrlPrefix(cfg) & "/" & conv.tweet.user.username & "/status/" & $conv.tweet.id + oembedUrl = getUrlPrefix(cfg) & "/api/oembed?url=" & encodeUrl(tweetUrl) + + let html = renderConversation(conv, prefs, getPath() & "#m", sort) resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, - images=images, video=video) + images=images, video=video, oembed=oembedUrl) + + get "/@name/status/@id/history/?": + cond '.' notin @"name" + let id = @"id" + + if id.len > 19 or id.any(c => not c.isDigit): + resp Http404, showError("Invalid tweet ID", cfg) + + let edits = await getGraphEditHistory(id) + if edits.latest == nil or edits.latest.id == 0: + resp Http404, showError("Tweet history not found", cfg) + + let + prefs = requestPrefs() + title = "History for " & pageTitle(edits.latest) + ogTitle = "Edit History for " & pageTitle(edits.latest.user) + desc = edits.latest.text + + let html = renderEditHistory(edits, prefs, getPath()) + resp renderMain(html, request, cfg, prefs, title, desc, ogTitle) get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] - cond @"m" in ["video", "photo", "history"] + cond @"m" in ["video", "photo"] redirect("/$1/status/$2" % [@"name", @"id"]) get "/@name/statuses/@id/?": diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 7a10e91..c7949a2 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -4,20 +4,39 @@ import jester, karax/vdom import router_utils import ".."/[types, redis_cache, formatters, query, api] -import ../views/[general, profile, timeline, status, search] +import ../views/[general, profile, timeline, status, search, about_account] export vdom export uri, sequtils export router_utils export redis_cache, formatters, query, api -export profile, timeline, status +export profile, timeline, status, about_account -proc getQuery*(request: Request; tab, name: string): Query = +proc tabRssEnabled*(cfg: Config; tab: string): bool = case tab - of "with_replies": getReplyQuery(name) - of "media": getMediaQuery(name) - of "search": initQuery(params(request), name=name) - else: Query(fromUser: @[name]) + of "": cfg.enableRSSUserTweets + of "with_replies": cfg.enableRSSUserReplies + of "media": cfg.enableRSSUserMedia + of "articles": cfg.enableRSSUserArticles + of "search": cfg.enableRSSSearch + else: false + +proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query = + let view = request.params.getOrDefault("view") + case tab + of "with_replies": + result = getReplyQuery(name) + of "articles": + result = getArticlesQuery(name) + of "media": + result = getMediaQuery(name) + result.view = + if view in ["timeline", "grid", "gallery"]: view + else: prefs.mediaView.toLowerAscii + of "search": + result = initQuery(params(request), name=name) + else: + result = Query(fromUser: @[name]) template skipIf[T](cond: bool; default; body: Future[T]): Future[T] = if cond: @@ -45,20 +64,23 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] let rail = - skipIf(skipRail or query.kind == media, @[]): + skipIf(skipRail or query.kind == QueryKind.media, @[]): getCachedPhotoRail(userId) user = getCachedUser(name) + info = getCachedAccountInfo(name, fetch=false) result = case query.kind of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after) + of QueryKind.articles: await getGraphUserTweets(userId, TimelineKind.articles, after) else: Profile(tweets: await getGraphTweetSearch(query, after)) result.user = await user result.photoRail = await rail + result.accountInfo = await info result.tweets.query = query @@ -105,16 +127,72 @@ 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/about/?": + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}) + let + prefs = requestPrefs() + name = @"name" + info = await getCachedAccountInfo(name) + if info.suspended: + resp showError(getSuspended(name), cfg) + if info.username.len == 0: + resp Http404, showError("User \"" & name & "\" not found", cfg) + let aboutHtml = renderAboutAccount(info) + resp renderMain(aboutHtml, request, cfg, prefs, + "About @" & info.username) + + get "/@name/@kind/?": + cond @"kind" in ["followers", "following"] + cond '.' notin @"name" + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}) + let + prefs = requestPrefs() + name = @"name" + userId = await getUserId(name) + + if userId.len == 0: + resp Http404, showError("User \"" & name & "\" not found", cfg) + if userId == "suspended": + resp showError(getSuspended(name), cfg) + + let + cursor = getCursor() + isFollowers = @"kind" == "followers" + user = await getCachedUser(name) + results = if isFollowers: await getGraphFollowers(userId, cursor) + else: await getGraphFollowing(userId, cursor) + + if user.protected: + resp renderMain(renderProtected(user.username), request, cfg, prefs, + "Protected account @" & name) + + let + tab = if isFollowers: "Followers" else: "Following" + title = (if isFollowers: "People following @" else: "People followed by @") & name + html = renderUserList(user, results, prefs, request.path, tab) + + resp renderMain(html, request, cfg, prefs, title, + images = @[user.getUserPic("_400x400")]) + get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] - cond @"tab" in ["with_replies", "media", "search", ""] + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','}) + cond @"tab" in ["with_replies", "media", "search", "articles", ""] + # articles can't be approximated by search, so multi-user is unsupported + cond not (@"tab" == "articles" and ',' in @"name") let - prefs = cookiePrefs() + prefs = requestPrefs() after = getCursor() names = getNames(@"name") - var query = request.getQuery(@"tab", @"name") + var query = request.getQuery(@"tab", @"name", prefs) if names.len != 1: query.fromUser = names @@ -122,7 +200,8 @@ proc createTimelineRouter*(cfg: Config) = if @"scroll".len > 0: if query.fromUser.len != 1: var timeline = await getGraphTweetSearch(query, after) - if timeline.content.len == 0: resp Http404 + if timeline.content.len == 0: + resp Http204 timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) else: @@ -132,7 +211,9 @@ proc createTimelineRouter*(cfg: Config) = resp $renderTimelineTweets(profile.tweets, prefs, getPath()) let rss = - if @"tab".len == 0: + if not cfg.tabRssEnabled(@"tab"): + "" + elif @"tab".len == 0: "/$1/rss" % @"name" elif @"tab" == "search": "/$1/search/rss?$2" % [@"name", genQueryUrl(query)] diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index 0c085d4..345dee7 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -10,14 +10,14 @@ export feature proc createUnsupportedRouter*(cfg: Config) = router unsupported: template feature {.dirty.} = - resp renderMain(renderFeature(), request, cfg, themePrefs()) + resp renderMain(renderFeature(), request, cfg, requestPrefs()) get "/about/feature": feature() get "/login/?@i?": feature() get "/@name/lists/?": feature() get "/intent/?@i?": - cond @"i" notin ["user"] + cond @"i" notin ["user", "follow"] feature() get "/i/@i?/?@j?": diff --git a/src/sass/_article.scss b/src/sass/_article.scss new file mode 100644 index 0000000..9488658 --- /dev/null +++ b/src/sass/_article.scss @@ -0,0 +1,278 @@ +.article-page { + max-width: 700px; + margin: 0 auto 20px; + background-color: var(--bg_panel); + + > .top-ref { + padding-top: 20px; + } + + .article-cover { + width: 100%; + display: block; + } + + .article-body { + padding: 20px; + + > :last-child { + margin-bottom: 0; + } + + .article-title { + display: block; + font-size: 2rem; + line-height: 1.3; + margin: 0 0 10px; + color: var(--fg_color); + } + + .article-author { + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border_grey); + font-size: 14px; + + .article-author-row { + display: flex; + align-items: center; + gap: 8px; + } + + .article-avatar { + display: flex; + } + + .avatar { + width: 40px; + height: 40px; + } + + .article-author-name { + display: flex; + align-items: center; + margin-bottom: 2px; + + .fullname { + font-size: 15px; + } + + .verified-icon { + margin-left: 2px; + } + } + + .article-author-meta { + display: flex; + align-items: center; + } + + .fullname { + font-weight: 700; + color: var(--fg_color); + max-width: unset; + text-overflow: unset; + overflow: visible; + white-space: normal; + } + + .username, + .article-date-sep, + .article-date { + color: var(--fg_dark); + } + + .username { + margin-left: 0; + } + + .article-date-sep { + margin: 0 4px; + } + + .article-date { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .tweet-stats { + margin-top: 6px; + + .tweet-stat { + padding-top: 0; + } + } + } + + > h1 { + display: block; + font-size: 1.8rem; + margin: 25px 0 15px; + } + + > h2 { + font-size: 1.4rem; + font-weight: bold; + margin: 20px 0 12px; + } + + > h3 { + font-size: 1.2rem; + font-weight: bold; + margin: 18px 0 10px; + } + + > p { + font-size: 16px; + line-height: 1.7; + margin: 16px 0; + word-wrap: break-word; + } + + .blockquote-attribution { + display: block; + margin-top: 0.5em; + } + + > blockquote { + border-left: 3px solid var(--accent); + padding-left: 16px; + margin: 16px 0; + color: var(--fg_faded); + font-size: 16px; + line-height: 1.7; + } + + > pre { + background-color: var(--bg_elements); + padding: 12px 16px; + border-radius: 6px; + overflow-x: auto; + margin: 16px 0; + + code { + font-family: monospace; + font-size: 14px; + color: var(--fg_color); + } + } + + code { + background-color: var(--bg_elements); + padding: 2px 5px; + border-radius: 3px; + font-family: monospace; + font-size: 0.9em; + } + + > ul, + > ol { + margin: 16px 0; + padding-left: 2em; + + li { + font-size: 16px; + line-height: 1.7; + margin: 6px 0; + } + } + + .article-media { + text-align: center; + margin: 20px 0; + + img, + video { + max-width: 100%; + border-radius: 12px; + } + + .article-media-caption { + color: var(--fg_faded); + font-size: 0.875rem; + margin-top: 6px; + } + } + + > a, + > p a, + > h1 a, + > h2 a, + > h3 a, + > blockquote a, + > ul a, + > ol a { + color: var(--accent); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .article-divider { + border: none; + border-top: 1px solid var(--border_grey); + margin: 30px 0; + } + + .timeline-item { + margin: 20px 0; + border: 1px solid var(--border_grey); + border-radius: 12px; + overflow: hidden; + } + } +} + +.conversation .article-page { + max-width: 100%; + margin-bottom: 0; +} + +.article-card { + .card-image-container { + position: relative; + } + + .card-image img { + height: auto; + } + + .article-card-badge { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + font-size: 13px; + font-weight: 700; + padding: 2px 8px; + border-radius: 4px; + } +} + +.quote .article-card { + margin: 0; + + .card-container { + border: none; + border-radius: 0; + border-top: solid 1px var(--dark_grey); + } +} + +@media (max-width: 700px) { + .article-page { + .article-body { + padding: 12px 15px 25px; + + .article-title { + font-size: 1.6rem; + } + } + } +} diff --git a/src/sass/_broadcast.scss b/src/sass/_broadcast.scss new file mode 100644 index 0000000..dd93606 --- /dev/null +++ b/src/sass/_broadcast.scss @@ -0,0 +1,75 @@ +.broadcast-page { + max-width: 800px; + width: 100%; + margin: 20px auto 0; +} + +.broadcast-panel { + background-color: var(--bg_panel); + border: 1px solid var(--border_grey); + border-radius: 8px; + overflow: hidden; +} + +.broadcast-player { + position: relative; + background: black; + + video, + img { + display: block; + width: 100%; + } +} + +.broadcast-info { + padding: 14px 16px; +} + +.broadcast-title { + font-size: 18px; + font-weight: bold; + margin: 0 0 12px; +} + +.broadcast-user-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.broadcast-user { + display: flex; + align-items: center; + gap: 10px; + color: var(--fg_color); + + img { + width: 40px; + height: 40px; + border-radius: 50%; + } +} + +.broadcast-username { + color: var(--fg_dark); +} + +.broadcast-meta { + color: var(--fg_faded); + font-size: 14px; + display: flex; + flex-direction: column; + align-items: flex-end; + flex-shrink: 0; + line-height: 1.5em; +} + +.broadcast-live { + background: #e0245e; + color: white; + padding: 1px 6px; + border-radius: 3px; + font-weight: bold; + font-size: 12px; +} diff --git a/src/sass/_space.scss b/src/sass/_space.scss new file mode 100644 index 0000000..5fe2e7e --- /dev/null +++ b/src/sass/_space.scss @@ -0,0 +1,149 @@ +.space-page { + max-width: 800px; + width: 100%; + margin: 20px auto 0; +} + +.space-panel { + background-color: var(--bg_panel); + border: 1px solid var(--border_grey); + border-radius: 8px; + overflow: hidden; +} + +.space-player { + position: relative; + background: linear-gradient(135deg, #7b2a8c 0%, #9b3ab1 100%); + min-height: 140px; + display: flex; + align-items: center; + justify-content: center; + + audio { + width: 100%; + padding: 15px; + box-sizing: border-box; + + &:not([controls]) { + display: none; + } + } + + .video-overlay { + background-color: transparent; + } +} + +.space-live { + background: #e0245e; + color: white; + padding: 3px 8px; + border-radius: 4px; + font-weight: bold; + font-size: 12px; + text-transform: uppercase; + position: absolute; + top: 8px; + right: 8px; +} + +.space-info { + padding: 16px; +} + +.space-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +} + +.space-title { + font-size: 18px; + font-weight: bold; + margin: 0; + line-height: 1.3; + flex: 1; +} + +.space-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; + font-size: 14px; + color: var(--fg_faded); +} + +.listener-count { + color: var(--fg_color); +} + +.space-state { + color: var(--fg_dark); +} + +.space-participants { + border-top: 1px solid var(--border_grey); + padding-top: 12px; +} + +.space-participant { + margin-bottom: 10px; + + a { + display: flex; + align-items: center; + gap: 10px; + color: var(--fg_color); + padding: 6px 0; + } + + img { + width: 40px; + height: 40px; + border-radius: 50%; + flex-shrink: 0; + } +} + +.participant-info { + min-width: 0; +} + +.participant-name { + display: flex; + align-items: center; + gap: 4px; + + strong { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .verified-icon { + margin-bottom: 0; + position: relative; + top: -2px; + } +} + +.host-badge { + background: var(--accent); + color: white; + padding: 2px 7px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + line-height: 1; + position: relative; + top: 1px; +} + +.participant-username { + color: var(--fg_dark); + font-size: 13px; +} diff --git a/src/sass/general.scss b/src/sass/general.scss index 9feb3d3..e6247d6 100644 --- a/src/sass/general.scss +++ b/src/sass/general.scss @@ -1,39 +1,40 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .panel-container { - margin: auto; - font-size: 130%; + margin: auto; + font-size: 130%; } .error-panel { - @include center-panel(var(--error_red)); - text-align: center; + @include center-panel(var(--error_red)); + text-align: center; } .search-bar > form { - @include center-panel(var(--darkest_grey)); + @include center-panel(var(--darkest_grey)); - button { - background: var(--bg_elements); - color: var(--fg_color); - border: 0; - border-radius: 3px; - cursor: pointer; - font-weight: bold; - width: 30px; - height: 30px; - } + button { + background: var(--bg_elements); + color: var(--fg_color); + border: 0; + border-radius: 3px; + cursor: pointer; + font-weight: bold; + width: 30px; + height: 30px; + padding: 0px 5px 1px 8px; + } - input { - font-size: 16px; - width: 100%; - background: var(--bg_elements); - color: var(--fg_color); - border: 0; - border-radius: 4px; - padding: 4px; - margin-right: 8px; - height: unset; - } + input { + font-size: 16px; + width: 100%; + background: var(--bg_elements); + color: var(--fg_color); + border: 0; + border-radius: 4px; + padding: 4px; + margin-right: 8px; + height: unset; + } } diff --git a/src/sass/include/_mixins.css b/src/sass/include/_mixins.css index 94e11ee..5fde51a 100644 --- a/src/sass/include/_mixins.css +++ b/src/sass/include/_mixins.css @@ -66,18 +66,7 @@ } #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: 380px !important; } } } diff --git a/src/sass/include/_variables.scss b/src/sass/include/_variables.scss index 0c95ff6..127cccb 100644 --- a/src/sass/include/_variables.scss +++ b/src/sass/include/_variables.scss @@ -1,46 +1,43 @@ // colors -$bg_color: #0F0F0F; -$fg_color: #F8F8F2; -$fg_faded: #F8F8F2CF; -$fg_dark: #FF6C60; -$fg_nav: #FF6C60; +$bg_color: #0f0f0f; +$fg_color: #f8f8f2; +$fg_faded: #f8f8f2cf; +$fg_dark: #ff6c60; +$fg_nav: #ff6c60; $bg_panel: #161616; $bg_elements: #121212; -$bg_overlays: #1F1F1F; -$bg_hover: #1A1A1A; +$bg_overlays: #1f1f1f; +$bg_hover: #1a1a1a; $grey: #888889; $dark_grey: #404040; $darker_grey: #282828; $darkest_grey: #222222; -$border_grey: #3E3E35; +$border_grey: #3e3e35; -$accent: #FF6C60; -$accent_light: #FFACA0; -$accent_dark: #8A3731; -$accent_border: #FF6C6091; +$accent: #ff6c60; +$accent_light: #ffaca0; +$accent_dark: #8a3731; +$accent_border: #ff6c6091; -$play_button: #D8574D; -$play_button_hover: #FF6C60; +$play_button: #d8574d; +$play_button_hover: #ff6c60; -$more_replies_dots: #AD433B; -$error_red: #420A05; +$more_replies_dots: #ad433b; +$error_red: #420a05; -$verified_blue: #1DA1F2; -$verified_business: #FAC82B; -$verified_government: #C1B6A4; +$verified_blue: #1da1f2; +$verified_business: #fac82b; +$verified_government: #c1b6a4; $icon_text: $fg_color; $tab: $fg_color; $tab_selected: $accent; -$shadow: rgba(0,0,0,.6); -$shadow_dark: rgba(0,0,0,.2); +$shadow: rgba(0, 0, 0, 0.6); +$shadow_dark: rgba(0, 0, 0, 0.2); //fonts -$font_0: Helvetica Neue; -$font_1: Helvetica; -$font_2: Arial; -$font_3: sans-serif; -$font_4: fontello; +$font_0: sans-serif; +$font_1: fontello; diff --git a/src/sass/index.scss b/src/sass/index.scss index 6cab48e..404f7d5 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -1,180 +1,220 @@ -@import '_variables'; +@import "_variables"; -@import 'tweet/_base'; -@import 'profile/_base'; -@import 'general'; -@import 'navbar'; -@import 'inputs'; -@import 'timeline'; -@import 'search'; +@import "tweet/_base"; +@import "profile/_base"; +@import "general"; +@import "navbar"; +@import "inputs"; +@import "timeline"; +@import "search"; +@import "broadcast"; +@import "space"; +@import "_article"; body { - // colors - --bg_color: #{$bg_color}; - --fg_color: #{$fg_color}; - --fg_faded: #{$fg_faded}; - --fg_dark: #{$fg_dark}; - --fg_nav: #{$fg_nav}; + // colors + --bg_color: #{$bg_color}; + --fg_color: #{$fg_color}; + --fg_faded: #{$fg_faded}; + --fg_dark: #{$fg_dark}; + --fg_nav: #{$fg_nav}; - --bg_panel: #{$bg_panel}; - --bg_elements: #{$bg_elements}; - --bg_overlays: #{$bg_overlays}; - --bg_hover: #{$bg_hover}; + --bg_panel: #{$bg_panel}; + --bg_elements: #{$bg_elements}; + --bg_overlays: #{$bg_overlays}; + --bg_hover: #{$bg_hover}; - --grey: #{$grey}; - --dark_grey: #{$dark_grey}; - --darker_grey: #{$darker_grey}; - --darkest_grey: #{$darkest_grey}; - --border_grey: #{$border_grey}; + --grey: #{$grey}; + --dark_grey: #{$dark_grey}; + --darker_grey: #{$darker_grey}; + --darkest_grey: #{$darkest_grey}; + --border_grey: #{$border_grey}; - --accent: #{$accent}; - --accent_light: #{$accent_light}; - --accent_dark: #{$accent_dark}; - --accent_border: #{$accent_border}; + --accent: #{$accent}; + --accent_light: #{$accent_light}; + --accent_dark: #{$accent_dark}; + --accent_border: #{$accent_border}; - --play_button: #{$play_button}; - --play_button_hover: #{$play_button_hover}; + --play_button: #{$play_button}; + --play_button_hover: #{$play_button_hover}; - --more_replies_dots: #{$more_replies_dots}; - --error_red: #{$error_red}; + --more_replies_dots: #{$more_replies_dots}; + --error_red: #{$error_red}; - --verified_blue: #{$verified_blue}; - --verified_business: #{$verified_business}; - --verified_government: #{$verified_government}; - --icon_text: #{$icon_text}; + --verified_blue: #{$verified_blue}; + --verified_business: #{$verified_business}; + --verified_government: #{$verified_government}; + --icon_text: #{$icon_text}; - --tab: #{$fg_color}; - --tab_selected: #{$accent}; + --tab: #{$fg_color}; + --tab_selected: #{$accent}; - --profile_stat: #{$fg_color}; + --profile_stat: #{$fg_color}; - background-color: var(--bg_color); - color: var(--fg_color); - font-family: $font_0, $font_1, $font_2, $font_3; - font-size: 14px; - line-height: 1.3; - margin: 0; + background-color: var(--bg_color); + color: var(--fg_color); + font-family: $font_0, $font_1; + font-size: 15px; + line-height: 1.3; + margin: 0; } * { - outline: unset; - margin: 0; - text-decoration: none; + outline: unset; + margin: 0; + text-decoration: none; +} + +img { + dynamic-range-limit: standard; } h1 { - display: inline; + display: inline; } -h2, h3 { - font-weight: normal; +h2, +h3 { + font-weight: normal; } p { - margin: 14px 0; + margin: 14px 0; } a { - color: var(--accent); + color: var(--accent); - &:hover { - text-decoration: underline; - } + &:hover { + text-decoration: underline; + } } fieldset { - border: 0; - padding: 0; - margin-top: -0.6em; + border: 0; + padding: 0; + margin-top: -0.6em; } legend { - width: 100%; - padding: .6em 0 .3em 0; - border: 0; - font-size: 16px; - font-weight: 600; - border-bottom: 1px solid var(--border_grey); - margin-bottom: 8px; + width: 100%; + padding: 0.6em 0 0.3em 0; + border: 0; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid var(--border_grey); + margin-bottom: 8px; } -.preferences .note { +.preferences { + .note { border-top: 1px solid var(--border_grey); border-bottom: 1px solid var(--border_grey); padding: 6px 0 8px 0; margin-bottom: 8px; margin-top: 16px; + } + + .bookmark-note { + margin: 0; + margin-bottom: 10px; + } } ul { - padding-left: 1.3em; + padding-left: 1.3em; } .container { - display: flex; - flex-wrap: wrap; - box-sizing: border-box; - padding-top: 50px; - margin: auto; - min-height: 100vh; + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + margin: auto; + min-height: 100vh; +} + +body.fixed-nav .container { + padding-top: 50px; } .icon-container { - display: inline; + display: inline; } .overlay-panel { - max-width: 600px; - width: 100%; - margin: 0 auto; - margin-top: 10px; - background-color: var(--bg_overlays); - padding: 10px 15px; - align-self: start; + max-width: 600px; + width: 100%; + margin: 0 auto; + margin-top: 10px; + background-color: var(--bg_overlays); + padding: 10px 15px; + align-self: start; - ul { - margin-bottom: 14px; - } + ul { + margin-bottom: 14px; + } - p { - word-break: break-word; - } + p { + word-break: break-word; + } } .verified-icon { - color: var(--icon_text); - border-radius: 50%; - flex-shrink: 0; - margin: 2px 0 3px 3px; - padding-top: 3px; - height: 11px; - width: 14px; - font-size: 8px; - display: inline-block; - text-align: center; - vertical-align: middle; + display: inline-block; + position: relative; + width: 14px; + height: 14px; + margin-bottom: 2px; - &.blue { - background-color: var(--verified_blue); + .verified-icon-circle { + position: absolute; + font-size: 15px; + } + + .verified-icon-check { + position: absolute; + font-size: 9px; + margin: 5px 3px; + } + + &.blue { + .verified-icon-circle { + color: var(--verified_blue); } - &.business { - color: var(--bg_panel); - background-color: var(--verified_business); + .verified-icon-check { + color: var(--icon_text); + } + } + + &.business { + .verified-icon-circle { + color: var(--verified_business); } - &.government { - color: var(--bg_panel); - background-color: var(--verified_government); + .verified-icon-check { + color: var(--bg_panel); } + } + + &.government { + .verified-icon-circle { + color: var(--verified_government); + } + + .verified-icon-check { + color: var(--bg_panel); + } + } } -@media(max-width: 600px) { - .preferences-container { - max-width: 95vw; - } +@media (max-width: 600px) { + .preferences-container { + max-width: 95vw; + } - .nav-item, .nav-item .icon-container { - font-size: 16px; - } + .nav-item, + .nav-item .icon-container { + font-size: 16px; + } } diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index 17c2a22..2b6016f 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -1,185 +1,216 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; button { - @include input-colors; - background-color: var(--bg_elements); - color: var(--fg_color); - border: 1px solid var(--accent_border); - padding: 3px 6px; - font-size: 14px; - cursor: pointer; - float: right; + @include input-colors; + background-color: var(--bg_elements); + color: var(--fg_color); + border: 1px solid var(--accent_border); + padding: 3px 6px; + font-size: 14px; + cursor: pointer; + float: right; } input[type="text"], input[type="date"], +input[type="number"], select { - @include input-colors; - background-color: var(--bg_elements); - padding: 1px 4px; - color: var(--fg_color); - border: 1px solid var(--accent_border); - border-radius: 0; - font-size: 14px; + @include input-colors; + background-color: var(--bg_elements); + padding: 1px 4px; + color: var(--fg_color); + border: 1px solid var(--accent_border); + border-radius: 0; + font-size: 14px; } -input[type="text"] { - height: 16px; +input[type="number"] { + -moz-appearance: textfield; +} + +input[type="text"], +input[type="number"] { + height: 16px; } select { - height: 20px; - padding: 0 2px; - line-height: 1; + height: 20px; + padding: 0 2px; + line-height: 1; } input[type="date"]::-webkit-inner-spin-button { - display: none; + 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%); - filter: hue-rotate(120deg); + margin-left: 17px; + filter: grayscale(100%); + filter: hue-rotate(120deg); } input::-webkit-calendar-picker-indicator { - opacity: 0; + opacity: 0; } input::-webkit-datetime-edit-day-field:focus, input::-webkit-datetime-edit-month-field:focus, input::-webkit-datetime-edit-year-field:focus { - background-color: var(--accent); - color: var(--fg_color); - outline: none; + background-color: var(--accent); + color: var(--fg_color); + outline: none; } .date-range { - .date-input { - display: inline-block; - position: relative; - } + .date-input { + display: inline-block; + position: relative; + } - .icon-container { - pointer-events: none; - position: absolute; - top: 2px; - right: 5px; - } + .icon-container { + pointer-events: none; + position: absolute; + top: 2px; + right: 5px; + } - .search-title { - margin: 0 2px; - } + .search-title { + margin: 0 2px; + } } .icon-button button { - color: var(--accent); - text-decoration: none; - background: none; - border: none; - float: none; - padding: unset; - padding-left: 4px; + color: var(--accent); + text-decoration: none; + background: none; + border: none; + float: none; + padding: unset; + padding-left: 4px; - &:hover { - color: var(--accent_light); - } + &:hover { + color: var(--accent_light); + } } .checkbox { - position: absolute; - top: 1px; - right: 0; - height: 17px; - width: 17px; - background-color: var(--bg_elements); - border: 1px solid var(--accent_border); + position: absolute; + top: 1px; + right: 0; + height: 17px; + width: 17px; + background-color: var(--bg_elements); + border: 1px solid var(--accent_border); - &:after { - content: ""; - position: absolute; - display: none; - } + &:after { + content: ""; + position: absolute; + display: none; + } } .checkbox-container { - display: block; - position: relative; - margin-bottom: 5px; + display: block; + position: relative; + margin-bottom: 5px; + cursor: pointer; + user-select: none; + padding-right: 22px; + + input { + position: absolute; + opacity: 0; cursor: pointer; - user-select: none; - padding-right: 22px; + height: 0; + width: 0; - input { - position: absolute; - opacity: 0; - cursor: pointer; - height: 0; - width: 0; - - &:checked ~ .checkbox:after { - display: block; - } + &:checked ~ .checkbox:after { + display: block; } + } - &:hover input ~ .checkbox { - border-color: var(--accent); - } + &:hover input ~ .checkbox { + border-color: var(--accent); + } - &:active input ~ .checkbox { - border-color: var(--accent_light); - } + &:active input ~ .checkbox { + border-color: var(--accent_light); + } - .checkbox:after { - left: 2px; - bottom: 0; - font-size: 13px; - font-family: $font_4; - content: '\e803'; - } + .checkbox:after { + left: 2px; + bottom: 0; + font-size: 13px; + font-family: $font_1; + content: "\e811"; + } } .pref-group { - display: inline; + display: inline; } .preferences { - button { - margin: 6px 0 3px 0; - } + button { + margin: 6px 0 3px 0; + } - label { - padding-right: 150px; - } + label { + padding-right: 150px; + } - select { - position: absolute; - top: 0; - right: 0; - display: block; - -moz-appearance: none; - -webkit-appearance: none; - appearance: none; - } + select { + position: absolute; + top: 0; + right: 0; + display: block; + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + min-width: 100px; + } - input[type="text"] { - position: absolute; - right: 0; - max-width: 140px; - } + input[type="text"], + input[type="number"] { + position: absolute; + right: 0; + max-width: 140px; + } - .pref-group { - display: block; - } + .pref-group { + display: block; + } - .pref-input { - position: relative; - margin-bottom: 6px; - } + .pref-input { + position: relative; + margin-bottom: 6px; + } - .pref-reset { - float: left; - } + .pref-reset { + float: left; + } + + .prefs-code { + background-color: var(--bg_elements); + border: 1px solid var(--accent_border); + color: var(--fg_color); + font-size: 13px; + padding: 6px 8px; + margin: 4px 0; + word-break: break-all; + white-space: pre-wrap; + user-select: all; + } } diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss index 47a8765..c999022 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -1,89 +1,90 @@ -@import '_variables'; +@import "_variables"; nav { - display: flex; - align-items: center; - position: fixed; - background-color: var(--bg_overlays); - box-shadow: 0 0 4px $shadow; - padding: 0; - width: 100%; - height: 50px; - z-index: 1000; - font-size: 16px; + display: flex; + align-items: center; + background-color: var(--bg_overlays); + box-shadow: 0 0 4px $shadow; + padding: 0; + width: 100%; + height: 50px; + z-index: 1000; + font-size: 16px; - a, .icon-button button { - color: var(--fg_nav); - } + a, + .icon-button button { + color: var(--fg_nav); + } + + body.fixed-nav & { + position: fixed; + } } .inner-nav { - margin: auto; - box-sizing: border-box; - padding: 0 10px; - display: flex; - align-items: center; - flex-basis: 920px; - height: 50px; + margin: auto; + box-sizing: border-box; + padding: 0 10px; + display: flex; + align-items: center; + flex-basis: 920px; + height: 50px; } .site-name { - font-size: 15px; - font-weight: 600; - line-height: 1; + font-size: 15px; + font-weight: 600; + line-height: 1; - &:hover { - color: var(--accent_light); - text-decoration: unset; - } + &:hover { + color: var(--accent_light); + text-decoration: unset; + } } .site-logo { - display: block; - width: 35px; - height: 35px; + display: block; + width: 35px; + height: 35px; } .nav-item { - display: flex; - flex: 1; - line-height: 50px; - height: 50px; - overflow: hidden; - flex-wrap: wrap; - align-items: center; + display: flex; + flex: 1; + line-height: 50px; + height: 50px; + overflow: hidden; + flex-wrap: wrap; + align-items: center; - &.right { - text-align: right; - justify-content: flex-end; - } + &.right { + text-align: right; + justify-content: flex-end; + } - &.right a { - padding-left: 4px; - - &:hover { - color: var(--accent_light); - text-decoration: unset; - } - } + &.right a:hover { + color: var(--accent_light); + text-decoration: unset; + } } .lp { - height: 14px; - display: inline-block; - position: relative; - top: 2px; - fill: var(--fg_nav); + height: 14px; + display: inline-block; + position: relative; + top: 2px; + fill: var(--fg_nav); - &:hover { - fill: var(--accent_light); - } + &:hover { + fill: var(--accent_light); + } } -.icon-info:before { - margin: 0 -3px; +.icon-info { + margin: 0 -3px; } .icon-cog { - font-size: 15px; + font-size: 15px; + padding-left: 0 !important; } diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index b7f33e6..81b3d78 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -1,83 +1,118 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; -@import 'card'; -@import 'photo-rail'; +@import "card"; +@import "about-account"; +@import "photo-rail"; +@import "community"; .profile-tabs { - @include panel(auto, 900px); + @include panel(auto, 900px); - .timeline-container { - float: right; - width: 68% !important; - max-width: unset; - } + .timeline-container { + float: right; + width: 68% !important; + max-width: unset; + } } .profile-banner { - margin-bottom: 4px; - background-color: var(--bg_panel); + margin-bottom: 4px; + background-color: var(--bg_panel); - a { - display: block; - position: relative; - padding: 33.34% 0 0 0; - } + a { + display: block; + position: relative; + padding: 33.34% 0 0 0; + } - img { - max-width: 100%; - position: absolute; - top: 0; - } + img { + max-width: 100%; + position: absolute; + top: 0; + } } .profile-tab { - padding: 0 4px 0 0; - box-sizing: border-box; - display: inline-block; - font-size: 14px; - text-align: left; - vertical-align: top; - max-width: 32%; + padding: 0 4px 0 0; + box-sizing: border-box; + display: inline-block; + font-size: 14px; + text-align: left; + vertical-align: top; + max-width: 32%; + top: 0; + + body.fixed-nav & { top: 50px; + } } .profile-result { - min-height: 54px; + min-height: 54px; - .username { - margin: 0 !important; - } + .username { + margin: 0 !important; + } - .tweet-header { - margin-bottom: unset; - } + .tweet-header { + margin-bottom: unset; + } } -@media(max-width: 700px) { - .profile-tabs { - width: 100vw; - max-width: 600px; +.profile-tabs.media-only { + max-width: none; + width: 100%; - .timeline-container { - width: 100% !important; + .timeline-container { + float: none; + width: 100% !important; + max-width: none; + padding: 0 10px; + box-sizing: border-box; + } - .tab-item wide { - flex-grow: 1.4; - } - } + .timeline-container > .tab { + max-width: 900px; + margin-left: auto; + margin-right: auto; + } +} + +@media (max-width: 700px) { + .profile-tabs { + width: 100vw; + max-width: 600px; + + .timeline-container { + width: 100% !important; + + .tab-item wide { + flex-grow: 1.4; + } } + } - .profile-tab { - width: 100%; - max-width: unset; - position: initial !important; - padding: 0; + .profile-tabs.media-only { + width: 100%; + max-width: none; + + .timeline-container { + width: 100vw !important; + padding: 0; } + } + + .profile-tab { + width: 100%; + max-width: unset; + position: initial !important; + padding: 0; + } } @media (min-height: 900px) { - .profile-tab.sticky { - position: sticky; - } + .profile-tab.sticky { + position: sticky; + } } diff --git a/src/sass/profile/_community.scss b/src/sass/profile/_community.scss new file mode 100644 index 0000000..92d13c9 --- /dev/null +++ b/src/sass/profile/_community.scss @@ -0,0 +1,203 @@ +.community-header { + padding: 12px 15px; + border-bottom: 1px solid var(--border_grey); + background-color: var(--bg_panel); + + .community-name { + font-size: 22px; + margin-bottom: 6px; + + a { + color: inherit; + } + } + + .community-category { + display: inline-block; + background-color: var(--bg_elements); + border: 1px solid var(--border_grey); + border-radius: 16px; + padding: 2px 12px; + font-size: 13px; + color: var(--fg_faded); + margin-bottom: 8px; + } + + .community-description { + color: var(--fg_faded); + margin-bottom: 8px; + line-height: 1.4; + } + + .community-member-count { + font-weight: bold; + color: inherit; + } + + .community-stats { + color: var(--grey); + font-size: 14px; + } +} + +.community-about { + padding: 16px 15px 15px; + background-color: var(--bg_panel); + + h2 { + font-size: 18px; + margin: 0 0 12px; + } + + .community-info { + border-bottom: 1px solid var(--border_grey); + padding-bottom: 12px; + } + + .community-info-item { + display: flex; + gap: 10px; + padding: 8px 0; + align-items: center; + + .verified-icon { + margin-left: 2px; + } + + > .icon-container { + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--grey); + flex-shrink: 0; + width: 26px; + height: 26px; + } + + strong { + color: var(--fg_color); + } + + a { + color: var(--accent); + } + } + + .community-rules { + border-bottom: 1px solid var(--border_grey); + padding: 16px 0 12px; + } + + .community-rules-intro { + color: var(--fg_faded); + font-size: 14px; + margin: 0 0 12px; + } + + .community-rule { + display: flex; + gap: 10px; + padding: 10px 0; + align-items: flex-start; + + .community-rule-number { + display: flex; + align-items: center; + justify-content: center; + min-width: 26px; + height: 26px; + border-radius: 50%; + background-color: var(--accent); + color: var(--fg_color); + font-weight: bold; + font-size: 13px; + flex-shrink: 0; + } + + .community-rule-content p { + margin: 4px 0 0; + color: var(--fg_faded); + font-size: 14px; + } + } + + .community-moderators { + padding-top: 16px; + + h2 { + display: flex; + align-items: center; + justify-content: space-between; + } + + .community-mods-link { + font-size: 14px; + font-weight: normal; + color: var(--accent); + } + } + + .community-moderator { + display: flex; + gap: 10px; + padding: 8px 0; + align-items: center; + + .community-mod-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + } + + .community-mod-info { + display: flex; + flex-direction: column; + } + + .community-mod-name { + display: flex; + align-items: center; + font-weight: bold; + color: var(--fg_color); + + .verified-icon { + margin-left: 2px; + } + } + + .community-mod-username { + color: var(--fg_faded); + font-size: 14px; + } + } +} + +.community-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 15px; + border-bottom: 1px solid var(--border_grey); + + .community-tag { + display: inline-block; + background-color: var(--bg_elements); + border: 1px solid var(--border_grey); + border-radius: 16px; + padding: 4px 12px; + font-size: 13px; + color: var(--accent); + } +} + +.community-hashtag-header { + padding: 12px 15px; + border-bottom: 1px solid var(--border_grey); + + .community-hashtag-title { + font-size: 20px; + color: var(--accent); + margin: 0; + } +} diff --git a/src/sass/profile/about-account.scss b/src/sass/profile/about-account.scss new file mode 100644 index 0000000..aa12f49 --- /dev/null +++ b/src/sass/profile/about-account.scss @@ -0,0 +1,71 @@ +@import '_variables'; + +.about-account { + max-width: 500px; + width: 100%; + margin: 20px auto 0; + align-self: flex-start; + background: var(--bg_panel); + border-radius: 4px; + padding: 12px 20px 20px; +} + +.about-account-header { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border_grey); +} + +.about-account-avatar img { + width: 72px; + height: 72px; + border-radius: 50%; + margin-bottom: 4px; +} + +.about-account-name { + @include breakable; + font-weight: bold; +} + +.about-account-body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.about-account-at { + font-size: 18px; + font-weight: bold; +} + +.about-account-row { + display: flex; + align-items: center; + gap: 10px; + + > span:first-child { + color: var(--fg_faded); + flex-shrink: 0; + } + + > div { + display: flex; + flex-direction: column; + } +} + +.about-account-label { + color: var(--fg_faded); + font-size: 13px; +} + +@media(max-width: 700px) { + .about-account { + max-width: none; + margin: 10px; + } +} diff --git a/src/sass/search.scss b/src/sass/search.scss index f70f7ea..fa2a2d8 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -1,122 +1,194 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .search-title { - font-weight: bold; - display: inline-block; - margin-top: 4px; + font-weight: bold; + display: inline-block; + margin-top: 4px; } .search-field { + display: flex; + flex-wrap: wrap; + + button { + margin: 0 2px 0 0; + padding: 0px 1px 1px 4px; + height: 23px; display: flex; - flex-wrap: wrap; + align-items: center; + } - button { - margin: 0 2px 0 0; - height: 23px; - display: flex; - align-items: center; - } + .pref-input { + margin: 0 4px 0 0; + flex-grow: 1; + height: 23px; + } - .pref-input { - margin: 0 4px 0 0; - flex-grow: 1; - height: 23px; - } + input[type="text"], + input[type="number"] { + height: calc(100% - 4px); + width: calc(100% - 8px); + } - input[type="text"] { - height: calc(100% - 4px); - width: calc(100% - 8px); - } + > label { + display: inline; + background-color: var(--bg_elements); + color: var(--fg_color); + border: 1px solid var(--accent_border); + padding: 1px 1px 2px 4px; + font-size: 14px; + cursor: pointer; + margin-bottom: 2px; - > label { - display: inline; - background-color: var(--bg_elements); - color: var(--fg_color); - border: 1px solid var(--accent_border); - padding: 1px 6px 2px 6px; - font-size: 14px; - cursor: pointer; - margin-bottom: 2px; + @include input-colors; + } - @include input-colors; - } - - @include create-toggle(search-panel, 200px); + @include create-toggle(search-panel, 380px); } .search-panel { - width: 100%; - max-height: 0; - overflow: hidden; - transition: max-height 0.4s; + width: 100%; + max-height: 0; + overflow: hidden; + transition: max-height 0.4s; - flex-grow: 1; - font-weight: initial; - text-align: left; + flex-grow: 1; + font-weight: initial; + text-align: left; - > div { - line-height: 1.7em; - } + .checkbox-container { + display: inline; + padding-right: unset; + margin-bottom: 5px; + margin-left: 23px; + } - .checkbox-container { - display: inline; - padding-right: unset; - margin-bottom: unset; - margin-left: 23px; - } + .checkbox { + right: unset; + left: -22px; + line-height: 1.6em; + } - .checkbox { - right: unset; - left: -22px; - } - - .checkbox-container .checkbox:after { - top: -4px; - } + .checkbox-container .checkbox:after { + top: -4px; + } } .search-row { - display: flex; - flex-wrap: wrap; - line-height: unset; + display: flex; + flex-wrap: wrap; + line-height: unset; - > div { - flex-grow: 1; - flex-shrink: 1; - } + > div { + flex-grow: 1; + flex-shrink: 1; + } + + input { + height: 21px; + } + + .pref-input { + display: block; + padding-bottom: 5px; input { - height: 21px; - } - - .pref-input { - display: block; - padding-bottom: 5px; - - input { - height: 21px; - margin-top: 1px; - } + height: 21px; + margin-top: 1px; } + } } .search-toggles { - flex-grow: 1; - display: grid; - grid-template-columns: repeat(6, auto); - grid-column-gap: 10px; + flex-grow: 1; + display: grid; + grid-template-columns: repeat(5, auto); + grid-column-gap: 10px; +} + +.list-result { + display: flex; + align-items: flex-start; + + .list-result-banner { + flex-shrink: 0; + width: 56px; + height: 56px; + margin-right: 10px; + border-radius: 8px; + overflow: hidden; + background-color: var(--darker_grey); + // stay above the tweet-link overlay's hover background + z-index: 1; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .list-result-body { + min-width: 0; + pointer-events: none; + z-index: 1; + + a { + pointer-events: all; + } + } + + .list-result-title { + align-items: baseline; + } + + .list-members { + flex-shrink: 0; + margin-left: 0.3em; + color: var(--fg_faded); + } + + .list-result-context { + display: flex; + align-items: center; + flex-wrap: wrap; + margin-top: 2px; + color: var(--fg_faded); + + a { + color: var(--fg_dark); + } + + a.fullname { + color: var(--fg_color); + } + + .list-facepile { + width: 20px; + height: 20px; + border-radius: 50%; + margin-right: 4px; + } + } + + .list-result-description { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: all; + } } .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(410px, 3); + @include search-resize(820px, 5); + @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); diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index c8ce309..b7d4a9f 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -1,162 +1,505 @@ -@import '_variables'; +@import "_variables"; .timeline-container { - @include panel(100%, 600px); + @include panel(100%, 600px); } -.timeline { - background-color: var(--bg_panel); +.timeline-container.media-only { + max-width: none; + width: 100%; + padding: 0 10px; + box-sizing: border-box; - > div:not(:first-child) { - border-top: 1px solid var(--border_grey); - } + > .tab, + > .timeline-header { + max-width: 900px; + margin-left: auto; + margin-right: auto; + } +} + +@media (max-width: 700px) { + .timeline-container.media-only { + padding: 0; + } +} + +.timeline > div:not(:first-child) { + border-top: 1px solid var(--border_grey); } .timeline-header { - width: 100%; - background-color: var(--bg_panel); - text-align: center; - padding: 8px; - display: block; - font-weight: bold; - margin-bottom: 5px; - box-sizing: border-box; + width: 100%; + background-color: var(--bg_panel); + text-align: center; + padding: 8px; + display: block; + font-weight: bold; + margin-bottom: 4px; + box-sizing: border-box; - button { - float: unset; - } + button { + float: unset; + } } .timeline-banner img { - width: 100%; + width: 100%; } .timeline-description { - font-weight: normal; + font-weight: normal; } .tab { - align-items: center; - display: flex; - flex-wrap: wrap; - list-style: none; - margin: 0 0 5px 0; - background-color: var(--bg_panel); - padding: 0; + align-items: center; + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0 0 4px 0; + background-color: var(--bg_panel); + padding: 0; } .tab-item { - flex: 1 1 0; - text-align: center; - margin-top: 0; + flex: 1 1 0; + text-align: center; + margin-top: 0; - a { - border-bottom: .1rem solid transparent; - color: var(--tab); - display: block; - padding: 8px 0; - text-decoration: none; - font-weight: bold; + a { + border-bottom: 0.1rem solid transparent; + color: var(--tab); + display: block; + padding: 8px 0; + text-decoration: none; + font-weight: bold; - &:hover { - text-decoration: none; - } - - &.active { - border-bottom-color: var(--tab_selected); - color: var(--tab_selected); - } + &:hover { + text-decoration: none; } - &.active a { - border-bottom-color: var(--tab_selected); - color: var(--tab_selected); + &.active { + border-bottom-color: var(--tab_selected); + color: var(--tab_selected); } + } - &.wide { - flex-grow: 1.2; - flex-basis: 50px; - } + &.active a { + border-bottom-color: var(--tab_selected); + color: var(--tab_selected); + } + + &.wide { + flex-grow: 1.2; + flex-basis: 50px; + } } .timeline-footer { - background-color: var(--bg_panel); - padding: 6px 0; + background-color: var(--bg_panel); + padding: 6px 0; } .timeline-protected { - text-align: center; + text-align: center; - p { - margin: 8px 0; - } + p { + margin: 8px 0; + } - h2 { - color: var(--accent); - font-size: 20px; - font-weight: 600; - } -} - -.timeline-none { + h2 { color: var(--accent); font-size: 20px; font-weight: 600; - text-align: center; + } +} + +.timeline-none { + color: var(--accent); + font-size: 20px; + font-weight: 600; + text-align: center; } .timeline-end { - background-color: var(--bg_panel); - color: var(--accent); - font-size: 16px; - font-weight: 600; - text-align: center; + background-color: var(--bg_panel); + color: var(--accent); + font-size: 16px; + font-weight: 600; + text-align: center; } .show-more { - background-color: var(--bg_panel); - text-align: center; - padding: .75em 0; - display: block !important; + background-color: var(--bg_panel); + text-align: center; + padding: 0.75em 0; + display: block !important; - a { - background-color: var(--darkest_grey); - display: inline-block; - height: 2em; - padding: 0 2em; - line-height: 2em; + a { + background-color: var(--darkest_grey); + display: inline-block; + height: 2em; + padding: 0 2em; + line-height: 2em; - &:hover { - background-color: var(--darker_grey); - } + &:hover { + background-color: var(--darker_grey); } + } } .top-ref { - background-color: var(--bg_color); - border-top: none !important; + background-color: var(--bg_color); + border-top: none !important; - .icon-down { - font-size: 20px; - display: flex; - justify-content: center; - text-decoration: none; + .icon-down { + font-size: 20px; + display: flex; + justify-content: center; + text-decoration: none; - &:hover { - color: var(--accent_light); - } - - &::before { - transform: rotate(180deg) translateY(-1px); - } + &:hover { + color: var(--accent_light); } + + &::before { + transform: rotate(180deg) translateY(-1px); + } + } } .timeline-item { - overflow-wrap: break-word; - border-left-width: 0; - min-width: 0; - padding: .75em; - display: flex; - position: relative; + overflow-wrap: break-word; + border-left-width: 0; + min-width: 0; + padding: 0.75em; + display: flex; + position: relative; + background-color: var(--bg_panel); +} + +.timeline.media-grid-view, +.timeline.media-gallery-view { + > div:not(:first-child) { + border-top: none; + } + + .timeline-item::before { + display: none; + } +} + +.timeline.media-grid-view, +.timeline.media-gallery-view .gallery-masonry.compact { + .tweet-header, + .replying-to, + .retweet-header, + .pinned, + .tweet-stats, + .attribution, + .poll, + .quote, + .community-note, + .media-tag-block, + .tweet-content, + .card-content { + display: none; + } + + .card { + margin: unset; + + .card-container { + border: unset; + border-radius: unset; + + .card-image-container { + width: 100%; + min-height: 100%; + } + + .card-content-container { + display: none; + } + } + } +} + +.timeline.media-grid-view { + display: grid; + gap: 4px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + + > div:not(:first-child) { + margin-top: 0; + } + + .timeline-item { + padding: 0; + } + + .tweet-link { + z-index: 1000; + + &:hover { + background-color: unset; + } + } + + > .show-more, + > .top-ref, + > .timeline-footer, + > .timeline-header { + grid-column: 1 / -1; + } + + .tweet-body { + height: 100%; + margin-left: 0; + padding: 0; + position: relative; + aspect-ratio: 1/1; + } + + .gallery-row + .gallery-row { + margin-top: 0.25em !important; + } + + .attachments { + background-color: var(--darkest_grey); + border-radius: 0; + margin: 0; + max-height: none; + } + + .attachments, + .gallery-row, + .still-image { + height: 100%; + width: 100%; + } + + .still-image img, + .attachment > video, + .attachment > img { + object-fit: cover; + height: 100%; + width: 100%; + } + + .attachment { + display: flex; + align-items: center; + } + + .gallery-video { + height: 100%; + } + + .media-gif { + display: flex; + } + + .timeline-item:hover { + opacity: 0.85; + } + + .alt-text { + display: none; + } +} + +.timeline.media-gallery-view { + .gallery-masonry { + margin: 10px 0; + column-gap: 10px; + column-width: unquote("clamp(190px, 22vw, 350px)"); + + &[data-col-size="small"] { + column-width: unquote("max(130px, 11vw)"); + } + + &[data-col-size="large"] { + column-width: unquote("clamp(350px, 22vw, 480px)"); + } + + &.masonry-active { + column-width: unset; + column-gap: unset; + position: relative; + + .timeline-item { + animation: none; + position: absolute; + box-sizing: border-box; + margin-bottom: 0; + } + } + + &.compact { + .tweet-body { + padding: 0; + + > .attachments { + margin: 0; + } + } + + .card-image-container img { + max-height: unset; + } + } + } + + @keyframes masonry-init { + to { + opacity: 1; + pointer-events: auto; + } + } + + // Start hidden. CSS animation reveals after a delay as a no-JS fallback. + // With JS, masonry-active cancels the animation and masonry-visible reveals. + .gallery-masonry .timeline-item, + > .show-more, + > .top-ref, + > .timeline-footer { + opacity: 0; + pointer-events: none; + animation: masonry-init 0.2s 0.3s forwards; + } + + .gallery-masonry.masonry-active .timeline-item.masonry-visible, + > .show-more.masonry-visible, + > .top-ref.masonry-visible, + > .timeline-footer.masonry-visible { + opacity: 1; + pointer-events: auto; + transition: opacity 0.15s ease; + animation: none; + } + + .timeline-item { + margin-bottom: 10px; + break-inside: avoid; + flex-direction: column; + padding: 0; + } + + > .show-more, + > .top-ref, + > .timeline-footer, + > .timeline-header { + margin-left: auto; + margin-right: auto; + max-width: 900px; + } + + > .show-more { + padding: 0; + margin-top: 8px; + background-color: unset; + } + + .tweet-content { + margin: 3px 0; + } + + .tweet-body { + display: flex; + flex-direction: column; + height: 100%; + margin-left: 0; + padding: 10px; + + > .attachments { + align-self: stretch; + border-radius: 0; + margin: -10px -10px 10px; + max-height: none; + order: -1; + width: auto; + background-color: var(--bg_elements); + + .gallery-row { + max-height: none; + max-width: none; + align-items: center; + } + + .still-image img, + .attachment > video, + .attachment > img { + max-height: none; + width: 100%; + } + + .attachment:last-child { + max-height: none; + } + + .card-container { + border: unset; + border-radius: unset; + } + } + + .tweet-stat { + padding-top: unset; + } + + .quote { + margin-bottom: 5px; + margin-top: 5px; + } + + .replying-to { + margin: 0; + } + } + + .tweet-header { + align-items: flex-start; + display: flex; + gap: 0.75em; + margin-bottom: 0; + + .tweet-avatar { + img { + float: none; + height: 42px; + margin: 0; + width: 42px; + } + } + + .tweet-name-row { + flex: 1; + } + + .fullname-and-username { + flex-wrap: wrap; + } + + .fullname { + max-width: calc(100% - 18px); + } + + .verified-icon { + margin-left: 4px; + margin-top: 1px; + } + + .username { + display: block; + flex-basis: 100%; + margin-left: 0; + } + } +} + +@media (max-width: 520px) { + .timeline.media-gallery-view { + padding: 8px 0; + } } diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 69f51c0..2f6693e 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -1,240 +1,284 @@ -@import '_variables'; -@import '_mixins'; -@import 'thread'; -@import 'media'; -@import 'video'; -@import 'embed'; -@import 'card'; -@import 'poll'; -@import 'quote'; +@import "_variables"; +@import "_mixins"; +@import "thread"; +@import "media"; +@import "video"; +@import "embed"; +@import "card"; +@import "poll"; +@import "quote"; .tweet-body { - flex: 1; - min-width: 0; - margin-left: 58px; - pointer-events: none; - z-index: 1; + flex: 1; + min-width: 0; + margin-left: 58px; + pointer-events: none; + z-index: 1; } .tweet-content { - font-family: $font_3; - line-height: 1.3em; - pointer-events: all; - display: inline; + line-height: 1.3em; + pointer-events: all; + display: inline; } .tweet-bidi { - display: block !important; + display: block !important; } .tweet-header { - padding: 0; - vertical-align: bottom; - flex-basis: 100%; - margin-bottom: .2em; + padding: 0; + vertical-align: bottom; + flex-basis: 100%; + margin-bottom: 0.2em; - a { - display: inline-block; - word-break: break-all; - max-width: 100%; - pointer-events: all; - } + a { + display: inline-block; + word-break: break-all; + max-width: 100%; + pointer-events: all; + } } .tweet-name-row { - padding: 0; - display: flex; - justify-content: space-between; + padding: 0; + display: flex; + justify-content: space-between; + + .verified-icon { + margin-left: 2px; + } } .fullname-and-username { - display: flex; - min-width: 0; + display: flex; + min-width: 0; } .fullname { - @include ellipsis; - flex-shrink: 2; - max-width: 80%; - font-size: 14px; - font-weight: 700; - color: var(--fg_color); + @include ellipsis; + flex-shrink: 2; + max-width: 80%; + font-size: 14px; + font-weight: 700; + color: var(--fg_color); } .username { - @include ellipsis; - min-width: 1.6em; - margin-left: .4em; - word-wrap: normal; + @include ellipsis; + min-width: 1.6em; + margin-left: 0.4em; + word-wrap: normal; } .tweet-date { - display: flex; - flex-shrink: 0; - margin-left: 4px; + display: flex; + flex-shrink: 0; + margin-left: 4px; } -.tweet-date a, .username, .show-more a { - color: var(--fg_dark); +.tweet-date a, +.username, +.show-more a { + color: var(--fg_dark); } .tweet-published { - margin: 0; - margin-top: 5px; - color: var(--grey); - pointer-events: all; + margin-top: 6px; + margin-bottom: 0px; + color: var(--grey); } .tweet-avatar { - display: contents !important; + display: contents !important; - img { - float: left; - margin-top: 3px; - margin-left: -58px; - width: 48px; - height: 48px; - } + img { + float: left; + margin-top: 3px; + margin-left: -58px; + width: 48px; + height: 48px; + } } .avatar { - &.round { - border-radius: 50%; - -webkit-user-select: none; - } - - &.mini { - position: unset; - margin-right: 5px; - margin-top: -1px; - width: 20px; - height: 20px; - } -} + &.round { + border-radius: 50%; + user-select: none; + -webkit-user-select: none; + } -.tweet-embed { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; - background-color: var(--bg_panel); - - .tweet-content { - font-size: 18px; - } - - .tweet-body { - display: flex; - flex-direction: column; - max-height: calc(100vh - 0.75em * 2); - } - - .card-image img { - height: auto; - } - - .avatar { - position: absolute; - } + &.mini { + position: unset; + margin-right: 5px; + margin-top: -1px; + width: 20px; + height: 20px; + } } .attribution { - display: flex; - pointer-events: all; - margin: 5px 0; + display: flex; + pointer-events: all; + margin: 5px 0; - strong { - color: var(--fg_color); - } + strong { + color: var(--fg_color); + } } .media-tag-block { - padding-top: 5px; - pointer-events: all; + padding-top: 5px; + pointer-events: all; + color: var(--fg_faded); + + .icon-container { + padding-right: 2px; + } + + .media-tag, + .icon-container { color: var(--fg_faded); - - .icon-container { - padding-right: 2px; - } - - .media-tag, .icon-container { - color: var(--fg_faded); - } + } } .timeline-container .media-tag-block { - font-size: 13px; + font-size: 13px; } .tweet-geo { - color: var(--fg_faded); + color: var(--fg_faded); } .replying-to { - color: var(--fg_faded); - margin: -2px 0 4px; + color: var(--fg_faded); + margin: -2px 0 4px; - a { - pointer-events: all; - } + a { + pointer-events: all; + } } -.retweet-header, .pinned, .tweet-stats { - align-content: center; - color: var(--grey); - display: flex; - flex-shrink: 0; - flex-wrap: wrap; - font-size: 14px; - font-weight: 600; - line-height: 22px; +.retweet-header, +.pinned, +.tweet-stats { + align-content: center; + color: var(--grey); + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + font-size: 14px; + font-weight: 600; + line-height: 22px; - span { - @include ellipsis; - } + span { + @include ellipsis; + } } .retweet-header { - margin-top: -5px !important; + margin-top: -5px !important; } .tweet-stats { - margin-bottom: -3px; - -webkit-user-select: none; + margin-bottom: -3px; + user-select: none; + -webkit-user-select: none; } .tweet-stat { - padding-top: 5px; - min-width: 1em; - margin-right: 0.8em; + padding-top: 5px; + min-width: 1em; + margin-right: 0.8em; } .show-thread { - display: block; - pointer-events: all; - padding-top: 2px; + display: block; + pointer-events: all; + padding-top: 2px; } .unavailable-box { - width: 100%; - height: 100%; - padding: 12px; - border: solid 1px var(--dark_grey); - box-sizing: border-box; - border-radius: 10px; - background-color: var(--bg_color); - z-index: 2; + width: 100%; + height: 100%; + padding: 12px; + border: solid 1px var(--dark_grey); + box-sizing: border-box; + border-radius: 10px; + background-color: var(--bg_color); + z-index: 2; } .tweet-link { - height: 100%; - width: 100%; - left: 0; - top: 0; - position: absolute; - -webkit-user-select: none; + height: 100%; + width: 100%; + left: 0; + top: 0; + position: absolute; + user-select: none; + -webkit-user-select: none; - &:hover { - background-color: var(--bg_hover); - } + &:hover { + background-color: var(--bg_hover); + } +} + +.latest-post-version { + border-bottom: 1px solid var(--dark_grey); + border-top: 1px solid var(--dark_grey); + padding: 01ch 0px; + margin: 1ch 0px; + color: var(--grey); + + a { + pointer-events: all; + } +} + +.community-note { + background-color: var(--bg_elements); + margin-top: 10px; + border: solid 1px var(--dark_grey); + border-radius: 10px; + overflow: hidden; + pointer-events: all; + + &:hover { + background-color: var(--bg_panel); + border-color: var(--grey); + } +} + +.community-note-header { + background-color: var(--bg_hover); + font-weight: 700; + padding: 8px 10px; + padding-top: 6px; + display: flex; + align-items: center; + gap: 2px; + + .icon-container { + flex-shrink: 0; + color: var(--accent); + } +} + +.community-note-text { + white-space: pre-line; + padding: 10px 10px; + padding-top: 6px; +} + +.disclosures { + display: flex; + flex-direction: column; + color: var(--grey); + font-size: 14px; + margin-top: 4px; + margin-bottom: -2px; + + .icon-attention-circled { + margin-right: -3px; + } } diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss index 5575191..7441d11 100644 --- a/src/sass/tweet/card.scss +++ b/src/sass/tweet/card.scss @@ -1,119 +1,119 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .card { - margin: 5px 0; - pointer-events: all; - max-height: unset; + margin: 5px 0; + pointer-events: all; + max-height: unset; } .card-container { - border-radius: 10px; - border-width: 1px; - border-style: solid; - border-color: var(--dark_grey); - background-color: var(--bg_elements); - overflow: hidden; - color: inherit; - display: flex; - flex-direction: row; - text-decoration: none !important; + border: solid 1px var(--dark_grey); + border-radius: 10px; + background-color: var(--bg_elements); + overflow: hidden; + color: inherit; + display: flex; + flex-direction: row; + text-decoration: none !important; - &:hover { - border-color: var(--grey); - } + &:hover { + border-color: var(--grey); + } - .attachments { - margin: 0; - border-radius: 0; - } + .attachments { + margin: 0; + border-radius: 0; + } } .card-content { - padding: 0.5em; + padding: 0.5em; } .card-title { - @include ellipsis; - white-space: unset; - font-weight: bold; - font-size: 1.1em; + @include ellipsis; + white-space: unset; + font-weight: bold; + font-size: 1.1em; } .card-description { - margin: 0.3em 0; - white-space: pre-wrap; + margin: 0.3em 0; + white-space: pre-wrap; } .card-destination { - @include ellipsis; - color: var(--grey); - display: block; + @include ellipsis; + color: var(--grey); + display: block; } .card-content-container { - color: unset; - overflow: auto; - &:hover { - text-decoration: none; - } + color: unset; + overflow: auto; + + &:hover { + text-decoration: none; + } } .card-image-container { - width: 98px; - flex-shrink: 0; - position: relative; - overflow: hidden; - &:before { - content: ""; - display: block; - padding-top: 100%; - } + width: 98px; + flex-shrink: 0; + position: relative; + overflow: hidden; + + &:before { + content: ""; + display: block; + padding-top: 100%; + } } .card-image { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - background-color: var(--bg_overlays); + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: var(--bg_overlays); - img { - width: 100%; - height: 100%; - max-height: 400px; - display: block; - object-fit: cover; - } + img { + width: 100%; + height: 100%; + max-height: 400px; + display: block; + object-fit: cover; + } } .card-overlay { - @include play-button; - opacity: 0.8; - display: flex; - justify-content: center; - align-items: center; + @include play-button; + opacity: 0.8; + display: flex; + justify-content: center; + align-items: center; } .large { - .card-container { - display: block; - } + .card-container { + display: block; + } - .card-image-container { - width: unset; + .card-image-container { + width: unset; - &:before { - display: none; - } + &:before { + display: none; } + } - .card-image { - position: unset; - border-style: solid; - border-color: var(--dark_grey); - border-width: 0; - border-bottom-width: 1px; - } + .card-image { + position: unset; + border-style: solid; + border-color: var(--dark_grey); + border-width: 0; + border-bottom-width: 1px; + } } diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index 227fc5e..9ff8403 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -1,17 +1,159 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; -.embed-video { - .gallery-video { - width: 100%; - height: 100%; - position: absolute; - background-color: black; - top: 0%; - left: 0%; - } +// Embed page: transparent background, no scrollbars +html:has(body > .embed-wrapper), +html:has(body > .embed-video) { + background: transparent; + overflow: hidden; - .video-container { - max-height: unset; - } + body { + background: transparent; + overflow: hidden; + } +} + +// Tweet embed wrapper +.embed-wrapper { + box-sizing: border-box; + border: 1px solid var(--border_grey); + border-radius: 12px; + overflow: hidden; + + .embed-footer { + display: block; + padding: 12px 16px; + border-top: 1px solid var(--border_grey); + background: var(--bg_panel); + color: var(--accent); + font-size: 14px; + font-weight: 500; + text-align: center; + text-decoration: none; + transition: background-color 0.15s; + + &:hover { + background: var(--bg_hover); + } + } +} + +// Tweet embed content +.tweet-embed { + position: relative; + background-color: var(--bg_panel); + transition: background-color 0.15s; + + &:hover { + background-color: var(--bg_hover); + } + + .timeline-item { + pointer-events: none; + background-color: transparent; + } + + .tweet-link:hover { + background-color: transparent; + } + + .tweet-content { + font-size: 18px; + } + + .avatar:not(.mini) { + position: absolute; + } + + // Cap media height in embeds + .still-image img, + .quote-media-container img, + .quote-media-container video { + max-height: 600px; + } + + &.error-embed { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + padding: 20px; + + .error-panel { + margin: 0; + } + } +} + +// Video-only embed +.embed-video { + position: relative; + min-height: 300px; + background-color: black; + border: 1px solid var(--border_grey); + + .attachments { + margin: 0; + border-radius: 0; + max-height: 560px; + background-color: unset; + } + + .card { + margin: 0; + } + + .gallery-video { + width: 100%; + } + + .gallery-video>.attachment { + max-height: 560px; + width: 100%; + } + + video { + width: 100%; + height: auto; + max-height: 560px; + object-fit: contain; + } + + .video-download { + display: none; + } + + .video-overlay-link { + position: absolute; + top: 12px; + right: 12px; + padding: 6px 12px; + background: rgba(30, 30, 30, 0.75); + backdrop-filter: blur(4px); + color: #fff; + font-size: 13px; + font-weight: 700; + text-decoration: none; + border-radius: 9999px; + border: 1px solid transparent; + transition: + background 0.15s, + opacity 0.15s; + z-index: 10; + + &:hover { + background: rgba(60, 60, 60, 0.9); + } + } + + // Hide button while playing, show on hover or when paused + &.video-playing .video-overlay-link { + opacity: 0; + pointer-events: none; + } + + &.video-playing:hover .video-overlay-link { + opacity: 1; + pointer-events: auto; + } } diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss index 91c9dab..3001a86 100644 --- a/src/sass/tweet/media.scss +++ b/src/sass/tweet/media.scss @@ -1,119 +1,168 @@ -@import '_variables'; +@import "_variables"; .gallery-row { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: center; - overflow: hidden; - flex-grow: 1; - max-height: 379.5px; - max-width: 533px; - pointer-events: all; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow: hidden; + flex-grow: 1; + max-height: 379.5px; + max-width: 533px; + pointer-events: all; + + &.mixed-row { + .attachment { + min-width: 0; + min-height: 0; + flex: 1 1 0; + max-height: 379.5px; + display: flex; + align-items: center; + justify-content: center; + background-color: #101010; + } + + .still-image, + .still-image img, + .attachment > video, + .attachment > img { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + } .still-image { - width: 100%; - display: flex; + display: flex; + align-self: stretch; } + + .still-image img { + flex-basis: auto; + flex-grow: 0; + object-fit: cover; + } + + .attachment > video, + .attachment > img { + object-fit: cover; + } + + .attachment > video { + object-fit: contain; + } + } } .attachments { - margin-top: .35em; - display: flex; - flex-direction: row; - width: 100%; - max-height: 600px; - border-radius: 7px; - overflow: hidden; - flex-flow: column; - background-color: var(--bg_color); - align-items: center; - pointer-events: all; - - .image-attachment { - width: 100%; - } + margin-top: 0.35em; + display: flex; + flex-direction: row; + width: 100%; + max-height: 600px; + border-radius: 7px; + overflow: hidden; + flex-flow: column; + background-color: var(--bg_color); + align-items: center; + pointer-events: all; } .attachment { - position: relative; - line-height: 0; - overflow: hidden; - margin: 0 .25em 0 0; - flex-grow: 1; - box-sizing: border-box; - min-width: 2em; + position: relative; + line-height: 0; + overflow: hidden; + margin: 0 0.25em 0 0; + flex-grow: 1; + box-sizing: border-box; + min-width: 2em; - &:last-child { - margin: 0; - max-height: 530px; - } -} - -.gallery-gif video { + &:last-child { + margin: 0; max-height: 530px; - background-color: #101010; -} - -.still-image { - max-height: 379.5px; - max-width: 533px; - justify-content: center; - - img { - object-fit: cover; - max-width: 100%; - max-height: 379.5px; - flex-basis: 300px; - flex-grow: 1; - } -} - -.image { - display: inline-block; -} - -// .single-image { -// display: inline-block; -// width: 100%; -// max-height: 600px; - -// .attachments { -// width: unset; -// max-height: unset; -// display: inherit; -// } -// } - -.overlay-circle { - border-radius: 50%; - background-color: var(--dark_grey); - width: 40px; - height: 40px; - align-items: center; - display: flex; - border-width: 5px; - border-color: var(--play_button); - border-style: solid; -} - -.overlay-triangle { - width: 0; - height: 0; - border-style: solid; - border-width: 12px 0 12px 17px; - border-color: transparent transparent transparent var(--play_button); - margin-left: 14px; + } } .media-gif { - display: table; - background-color: unset; - width: unset; + display: table; + background-color: unset; + width: unset; + max-height: unset; +} + +.media-gif video, +.media-gif img { + width: 100%; + height: 100%; + max-height: 530px; + background-color: #101010; +} + +.still-image { + max-height: 379.5px; + max-width: 533px; + + img { + object-fit: cover; + max-width: 100%; + max-height: 379.5px; + flex-basis: 300px; + flex-grow: 1; + } +} + +.alt-text { + margin: 0px; + padding: 11px 7px; + box-sizing: border-box; + position: absolute; + bottom: 10px; + left: 10px; + width: 2.98em; + max-height: 25px; + white-space: pre; + overflow: hidden; + border-radius: 10px; + color: var(--fg_color); + font-size: 12px; + font-weight: bold; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(12px); +} + +.alt-text:hover { + padding: 7px; + width: Min(230px, calc(100% - 10px * 2)); + max-height: calc(100% - 10px); + line-height: 1.2em; + white-space: pre-wrap; + transition-duration: 0.4s; + transition-property: max-height; +} + +.overlay-circle { + border-radius: 50%; + background-color: var(--dark_grey); + width: 40px; + height: 40px; + align-items: center; + display: flex; + border-width: 5px; + border-color: var(--play_button); + border-style: solid; +} + +.overlay-triangle { + width: 0; + height: 0; + border-style: solid; + border-width: 12px 0 12px 17px; + border-color: transparent transparent transparent var(--play_button); + margin-left: 14px; } .media-body { - flex: 1; - padding: 0; - white-space: pre-wrap; + flex: 1; + padding: 0; + white-space: pre-wrap; } diff --git a/src/sass/tweet/poll.scss b/src/sass/tweet/poll.scss index 57590c8..6d54e00 100644 --- a/src/sass/tweet/poll.scss +++ b/src/sass/tweet/poll.scss @@ -1,42 +1,42 @@ -@import '_variables'; +@import "_variables"; .poll-meter { - overflow: hidden; - position: relative; - margin: 6px 0; - height: 26px; - background: var(--bg_color); - border-radius: 5px; - display: flex; - align-items: center; + overflow: hidden; + position: relative; + margin: 6px 0; + height: 26px; + background: var(--bg_color); + border-radius: 5px; + display: flex; + align-items: center; } .poll-choice-bar { - height: 100%; - position: absolute; - background: var(--dark_grey); + height: 100%; + position: absolute; + background: var(--dark_grey); } .poll-choice-value { - position: relative; - font-weight: bold; - margin-left: 5px; - margin-right: 6px; - min-width: 30px; - text-align: right; - pointer-events: all; + position: relative; + font-weight: bold; + margin-left: 5px; + margin-right: 6px; + min-width: 30px; + text-align: right; + pointer-events: all; } .poll-choice-option { - position: relative; - pointer-events: all; + position: relative; + pointer-events: all; } .poll-info { - color: var(--grey); - pointer-events: all; + color: var(--grey); + pointer-events: all; } .leader .poll-choice-bar { - background: var(--accent_dark); + background: var(--accent_dark); } diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index b4bc60e..f722455 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -1,94 +1,121 @@ -@import '_variables'; +@import "_variables"; .quote { - margin-top: 10px; - border: solid 1px var(--dark_grey); - border-radius: 10px; - background-color: var(--bg_elements); + margin-top: 10px; + border: solid 1px var(--dark_grey); + border-radius: 10px; + background-color: var(--bg_elements); + overflow: hidden; + pointer-events: all; + position: relative; + width: 100%; + + &:hover { + border-color: var(--grey); + } + + &.unavailable:hover { + border-color: var(--dark_grey); + } + + .tweet-name-row { + padding: 8px 10px 6px 10px; + } + + .quote-text { overflow: hidden; - pointer-events: all; - position: relative; - width: 100%; + white-space: pre-wrap; + word-wrap: break-word; + padding: 10px; + padding-top: 0; + } + + .show-thread { + padding: 0px 10px 6px 10px; + margin-top: -6px; + } + + .quote-latest { + padding: 0px 10px 6px 10px; + color: var(--grey); + } + + .replying-to { + padding: 0px 10px; + padding-bottom: 4px; + margin: unset; + } + + .community-note { + background-color: var(--bg_panel); + border: unset; + border-top: solid 1px var(--dark_grey); + border-radius: unset; + margin-top: 0; &:hover { - border-color: var(--grey); + border-top-color: var(--grey); } - &.unavailable:hover { - border-color: var(--dark_grey); - } - - .tweet-name-row { - padding: 6px 8px; - margin-top: 1px; - } - - .quote-text { - overflow: hidden; - white-space: pre-wrap; - word-wrap: break-word; - padding: 0px 8px 8px 8px; - } - - .show-thread { - padding: 0px 8px 6px 8px; - margin-top: -6px; - } - - .replying-to { - padding: 0px 8px; - margin: unset; + .community-note-header { + background-color: var(--bg_panel); + padding-bottom: 0; } + } } .unavailable-quote { - padding: 12px; + padding: 12px; + display: block; } .quote-link { - width: 100%; - height: 100%; - left: 0; - top: 0; - position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + position: absolute; } .quote-media-container { - max-height: 300px; + max-height: 300px; + display: flex; + + .card { + margin: unset; + } + + .attachments { + border-radius: 0; + } + + .media-gif { + width: 100%; display: flex; + justify-content: center; + } - .card { - margin: unset; + .media-gif > .attachment { + display: flex; + justify-content: center; + background-color: var(--bg_color); + + video, + img { + height: unset; + width: unset; + max-height: 100%; + max-width: 100%; } + } - .attachments { - border-radius: 0; - } + .gallery-row .attachment, + .gallery-row .attachment > video, + .gallery-row .attachment > img { + max-height: 300px; + } - .media-gif { - width: 100%; - display: flex; - justify-content: center; - } - - .gallery-gif .attachment { - display: flex; - justify-content: center; - background-color: var(--bg_color); - - video { - height: unset; - width: unset; - max-height: 100%; - max-width: 100%; - } - } - - .gallery-video, .gallery-gif { - max-height: 300px; - } - - .still-image img { - max-height: 250px - } + .still-image img { + max-height: 250px; + } } diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index 19fb3e0..134e375 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -1,138 +1,196 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; -.conversation { - @include panel(100%, 600px); +.conversation, +.edit-history { + @include panel(100%, 600px); - .show-more { - margin-bottom: 10px; - } + .show-more { + margin-bottom: 10px; + } } -.main-thread { - margin-bottom: 20px; - background-color: var(--bg_panel); -} - -.main-tweet, .replies { - padding-top: 50px; - margin-top: -50px; -} - -.main-tweet .tweet-content { - font-size: 18px; -} - -@media(max-width: 600px) { - .main-tweet .tweet-content { - font-size: 16px; - } +.main-thread, +.latest-edit { + margin-bottom: 20px; } .reply { - background-color: var(--bg_panel); - margin-bottom: 10px; + margin-bottom: 10px; +} + +.reply-sort { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px 14px; + margin-bottom: 10px; + padding: 8px 12px; + background-color: var(--bg_panel); + font-size: 14px; +} + +.reply-sort-label { + color: var(--fg_faded); + margin-right: 2px; +} + +.reply-sort-option { + color: var(--tab); + font-weight: bold; + text-decoration: none; + border-bottom: 0.1rem solid transparent; + + &:hover { + color: var(--fg_color); + text-decoration: none; + } + + &.active { + color: var(--tab_selected); + border-bottom-color: var(--tab_selected); + } +} + +.main-tweet, +.replies, +.edit-history > div { + body.fixed-nav & { + padding-top: 50px; + margin-top: -50px; + } +} + +.edit-history-header { + padding: 10px; + margin-bottom: 5px; + font-size: 16px; + font-weight: bold; + background-color: var(--bg_panel); +} + +.tweet-edit { + margin-bottom: 5px; +} + +.main-tweet .tweet-content { + font-size: 18px; +} + +@media (max-width: 600px) { + .main-tweet .tweet-content { + font-size: 16px; + } } .thread-line { - .timeline-item::before, - &.timeline-item::before { - background: var(--accent_dark); - content: ''; - position: relative; - min-width: 3px; - width: 3px; - left: 26px; - border-radius: 2px; - margin-left: -3px; - margin-bottom: 37px; - top: 56px; - z-index: 1; - pointer-events: none; - } + .timeline-item::before, + &.timeline-item::before { + background: var(--accent_dark); + content: ""; + position: relative; + min-width: 3px; + width: 3px; + left: 26px; + border-radius: 2px; + margin-left: -3px; + margin-bottom: 37px; + top: 56px; + z-index: 1; + pointer-events: none; + } - .with-header:not(:first-child)::after { - background: var(--accent_dark); - content: ''; - position: relative; - float: left; - min-width: 3px; - width: 3px; - right: calc(100% - 26px); - border-radius: 2px; - margin-left: -3px; - margin-bottom: 37px; - bottom: 10px; - height: 30px; - z-index: 1; - pointer-events: none; - } + .with-header:not(:first-child)::after { + background: var(--accent_dark); + content: ""; + position: relative; + float: left; + min-width: 3px; + width: 3px; + right: calc(100% - 26px); + border-radius: 2px; + margin-left: -3px; + margin-bottom: 37px; + bottom: 10px; + height: 30px; + z-index: 1; + pointer-events: none; + } - .unavailable::before { - top: 48px; - margin-bottom: 28px; - } + .unavailable::before { + top: 48px; + margin-bottom: 28px; + } - .more-replies::before { - content: '...'; - background: unset; - color: var(--more_replies_dots); - font-weight: bold; - font-size: 20px; - line-height: 0.25em; - left: 1.2em; - width: 5px; - top: 2px; - margin-bottom: 0; - margin-left: -2.5px; - } + .more-replies::before { + content: "..."; + background: unset; + color: var(--more_replies_dots); + font-weight: bold; + font-size: 20px; + line-height: 0.25em; + left: 1.2em; + width: 5px; + top: 2px; + margin-bottom: 0; + margin-left: -2.5px; + } - .earlier-replies { - padding-bottom: 0; - margin-bottom: -5px; - } + .earlier-replies { + padding-bottom: 0; + margin-bottom: -5px; + } } .timeline-item.thread-last::before { - background: unset; - min-width: unset; - width: 0; - margin: 0; + background: unset; + min-width: unset; + width: 0; + margin: 0; } .more-replies { - padding-top: 0.3em !important; + padding-top: 0.3em !important; } .more-replies-text { - @include ellipsis; - display: block; - margin-left: 58px; - padding: 7px 0; + @include ellipsis; + display: block; + margin-left: 58px; + padding: 7px 0; } .timeline-item.thread.more-replies-thread { - padding: 0 0.75em; + padding: 0 0.75em; + + &::before { + top: 40px; + margin-bottom: 31px; + } + + .more-replies { + display: flex; + padding-top: unset !important; + margin-top: 8px; &::before { - top: 40px; - margin-bottom: 31px; + display: inline-block; + position: relative; + top: -1px; + line-height: 0.4em; } - .more-replies { - display: flex; - padding-top: unset !important; - margin-top: 8px; - - &::before { - display: inline-block; - position: relative; - top: -1px; - line-height: 0.4em; - } - - .more-replies-text { - display: inline; - } + .more-replies-text { + display: inline; } + } +} + +.related-header { + padding: 8px 12px; + margin-top: 10px; + background-color: var(--bg_panel); + color: var(--fg_faded); + font-size: 14px; + border-bottom: 1px solid var(--border_grey); } diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index 98a1c29..28fc125 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -1,68 +1,111 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; video { - max-height: 100%; - width: 100%; + height: 100%; + width: 100%; } .gallery-video { - display: flex; - overflow: hidden; -} - -.gallery-video.card-container { + display: flex; + overflow: hidden; + + &.card-container { flex-direction: column; -} + width: 100%; + } -.video-container { + > .attachment { min-height: 80px; min-width: 200px; max-height: 530px; margin: 0; - display: flex; - align-items: center; - justify-content: center; img { - max-height: 100%; - max-width: 100%; + max-height: 100%; + max-width: 100%; } + } +} + +.video-download { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + color: var(--accent); + text-decoration: none; + opacity: 0; + transition: opacity 0.2s; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7)); + + .icon-container { + margin: 0; + } + + .icon-download-alt { + font-size: 18px; + } +} + +.attachment:hover .video-download { + opacity: 1; + + &:hover { + color: var(--fg_color); + } +} + +@media (hover: none) { + .video-download { + opacity: 1; + } } .video-overlay { - @include play-button; - background-color: $shadow; + @include play-button; + background-color: $shadow; - p { - position: relative; - z-index: 0; - text-align: center; - top: calc(50% - 20px); - font-size: 20px; - line-height: 1.3; - margin: 0 20px; - } + p { + position: relative; + z-index: 0; + text-align: center; + top: calc(50% - 20px); + font-size: 20px; + line-height: 1.3; + margin: 0 20px; + } - div { - position: relative; - z-index: 0; - top: calc(50% - 20px); - margin: 0 auto; - width: 40px; - height: 40px; - } + .overlay-circle { + position: relative; + z-index: 0; + top: calc(50% - 20px); + margin: 0 auto; + width: 40px; + height: 40px; + } - form { - width: 100%; - height: 100%; - align-items: center; - justify-content: center; - display: flex; - } + .overlay-duration { + position: absolute; + bottom: 8px; + left: 8px; + background-color: #0000007a; + line-height: 1em; + padding: 4px 6px 4px 6px; + border-radius: 5px; + font-weight: bold; + } - button { - padding: 5px 8px; - font-size: 16px; - } + form { + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + display: flex; + } + + button { + padding: 5px 8px; + font-size: 16px; + } } diff --git a/src/tid.nim b/src/tid.nim new file mode 100644 index 0000000..ba1f8ec --- /dev/null +++ b/src/tid.nim @@ -0,0 +1,64 @@ +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: + let client = newAsyncHttpClient() + defer: client.close() + + let resp = await client.get(pairsUrl) + if resp.status == $Http200: + cachedPairs = parseTidPairs(await resp.body) + lastCached = int(epochTime()) + + if cachedPairs.len == 0: + raise newException(ValueError, "Failed to fetch x-client-transaction-id pairs") + + 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) diff --git a/src/types.nim b/src/types.nim index f16fe6f..0a748ba 100644 --- a/src/types.nim +++ b/src/types.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import times, sequtils, options, tables, uri +import times, sequtils, options, tables import prefs_impl genPrefsType() @@ -11,21 +11,16 @@ type BadClientError* = object of CatchableError TimelineKind* {.pure.} = enum - tweets, replies, media + tweets, replies, media, articles - Api* {.pure.} = enum - tweetDetail - tweetResult - search - list - listBySlug - listMembers - listTweets - userRestId - userScreenName - userTweets - userTweetsAndReplies - userMedia + ApiUrl* = object + endpoint*: string + params*: seq[(string, string)] + skipTid*: bool + + ApiReq* = object + oauth*: ApiUrl + cookie*: ApiUrl RateLimit* = object limit*: int @@ -42,7 +37,7 @@ type pending*: int limited*: bool limitedAt*: int - apis*: Table[Api, RateLimit] + apis*: Table[string, RateLimit] case kind*: SessionKind of oauth: oauthToken*: string @@ -51,10 +46,6 @@ type authToken*: string ct0*: string - SessionAwareUrl* = object - oauthUrl*: Uri - cookieUrl*: Uri - Error* = enum null = 0 noUserMatches = 17 @@ -70,6 +61,7 @@ type rateLimited = 88 expiredToken = 89 listIdOrSlug = 112 + timelineUnavailable = 131 tweetNotFound = 144 tweetNotAuthorized = 179 forbidden = 200 @@ -106,6 +98,59 @@ type suspended*: bool joinDate*: DateTime + AccountInfo* = object + username*: string + fullname*: string + userPic*: string + joinDate*: DateTime + verifiedType*: VerifiedType + suspended*: bool + basedIn*: string + source*: string + usernameChanges*: int + lastUsernameChange*: DateTime + affiliateUsername*: string + affiliateLabel*: string + isIdentityVerified*: bool + verifiedSince*: DateTime + overrideVerifiedYear*: int + + Broadcast* = object + id*: string + title*: string + state*: string + thumb*: string + mediaKey*: string + m3u8Url*: string + totalWatched*: int + startTime*: DateTime + endTime*: DateTime + replayStart*: int + availableForReplay*: bool + user*: User + + SpaceParticipant* = object + userId*: string + username*: string + displayName*: string + avatarUrl*: string + isVerified*: bool + + AudioSpace* = object + id*: string + title*: string + state*: string + mediaKey*: string + m3u8Url*: string + totalLiveListeners*: int + totalReplayWatched*: int + startTime*: DateTime + endTime*: DateTime + availableForReplay*: bool + creator*: User + admins*: seq[SpaceParticipant] + speakers*: seq[SpaceParticipant] + VideoType* = enum m3u8 = "application/x-mpegURL" mp4 = "video/mp4" @@ -129,10 +174,15 @@ type variants*: seq[VideoVariant] QueryKind* = enum - posts, replies, media, users, tweets, userList + posts, replies, media, users, tweets, userList, followers, following, lists, top, + articles + + RankingMode* = enum + Relevance, Recency, Likes Query* = object kind*: QueryKind + view*: string text*: string filters*: seq[string] includes*: seq[string] @@ -140,12 +190,33 @@ type fromUser*: seq[string] since*: string until*: string - near*: string + minLikes*: string sep*: string Gif* = object url*: string thumb*: string + altText*: string + + Photo* = object + url*: string + altText*: string + + MediaKind* = enum + photoMedia + videoMedia + gifMedia + + Media* = object + case kind*: MediaKind + of photoMedia: + photo*: Photo + of videoMedia: + video*: Video + of gifMedia: + gif*: Gif + + MediaEntities* = seq[Media] GalleryPhoto* = object url*: string @@ -154,6 +225,44 @@ type PhotoRail* = seq[GalleryPhoto] + Article* = ref object + title*: string + coverImage*: string + user*: User + time*: DateTime + stats*: TweetStats + paragraphs*: seq[ArticleParagraph] + entities*: Table[int, ArticleEntity] + media*: Table[string, ArticleMedia] + + ArticleParagraph* = object + text*: string + kind*: string + inlineStyles*: seq[ArticleStyle] + entityRanges*: seq[ArticleEntityRange] + + ArticleStyle* = object + offset*: int + length*: int + style*: string + + ArticleEntityRange* = object + offset*: int + length*: int + key*: int + + ArticleEntity* = object + kind*: string + url*: string + mediaIds*: seq[string] + tweetId*: string + markdown*: string + caption*: string + + ArticleMedia* = object + kind*: string + url*: string + Poll* = object options*: seq[string] values*: seq[int] @@ -203,6 +312,12 @@ type likes*: int views*: int + ArticlePreview* = object + title*: string + previewText*: string + coverImage*: string + tweetId*: int64 + Tweet* = ref object id*: int64 threadId*: int64 @@ -221,13 +336,17 @@ type stats*: TweetStats retweet*: Option[Tweet] attribution*: Option[User] + attributionLink*: string mediaTags*: seq[User] quote*: Option[Tweet] card*: Option[Card] poll*: Option[Poll] - gif*: Option[Gif] - video*: Option[Video] - photos*: seq[string] + media*: MediaEntities + history*: seq[int64] + note*: string + isAd*: bool + isAI*: bool + articlePreview*: Option[ArticlePreview] Tweets* = seq[Tweet] @@ -241,6 +360,7 @@ type content*: Tweets hasMore*: bool cursor*: string + related*: bool Conversation* = ref object tweet*: Tweet @@ -248,6 +368,10 @@ type after*: Chain replies*: Result[Chain] + EditHistory* = object + latest*: Tweet + history*: Tweets + Timeline* = Result[Tweets] Profile* = object @@ -255,6 +379,7 @@ type photoRail*: PhotoRail pinned*: Option[Tweet] tweets*: Timeline + accountInfo*: AccountInfo List* = object id*: string @@ -265,6 +390,29 @@ type members*: int banner*: string + ListSearchResult* = object + list*: List + owner*: User + followersContext*: string + facepiles*: seq[string] + + CommunityRule* = object + name*: string + description*: string + + Community* = object + id*: string + name*: string + description*: string + memberCount*: int + banner*: string + creator*: User + category*: string + joinPolicy*: string + createdAt*: DateTime + rules*: seq[CommunityRule] + hashtags*: seq[string] + GlobalObjects* = ref object tweets*: Table[string, Tweet] users*: Table[string, User] @@ -281,10 +429,20 @@ type hmacKey*: string base64Media*: bool minTokens*: int - enableRss*: bool + enableRSSUserTweets*: bool + enableRSSUserReplies*: bool + enableRSSUserMedia*: bool + enableRSSUserArticles*: bool + enableRSSSearch*: bool + enableRSSList*: bool enableDebug*: bool proxy*: string proxyAuth*: string + apiProxy*: string + disableTid*: bool + maxConcurrentReqs*: int + maxRetries*: int + retryDelayMs*: int rssCacheTime*: int listCacheTime*: int @@ -303,3 +461,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool = proc add*(timeline: var seq[Tweets]; tweet: Tweet) = timeline.add @[tweet] + +proc getPhotos*(tweet: Tweet): seq[Photo] = + tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo) + +proc getVideos*(tweet: Tweet): seq[Video] = + tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video) + +proc hasPhotos*(tweet: Tweet): bool = + tweet.media.anyIt(it.kind == photoMedia) + +proc hasVideos*(tweet: Tweet): bool = + tweet.media.anyIt(it.kind == videoMedia) + +proc hasGifs*(tweet: Tweet): bool = + tweet.media.anyIt(it.kind == gifMedia) + +proc getThumb*(media: Media): string = + case media.kind + of photoMedia: media.photo.url + of videoMedia: media.video.thumb + of gifMedia: media.gif.thumb diff --git a/src/utils.nim b/src/utils.nim index c96a6dd..95b46de 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, uri, tables, base64 +import sequtils, strutils, strformat, uri, tables, base64 import nimcrypto var @@ -9,7 +9,7 @@ var const https* = "https://" twimg* = "pbs.twimg.com/" - nitterParams = ["name", "tab", "id", "list", "referer", "scroll"] + nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"] twitterDomains = @[ "twitter.com", "pic.twitter.com", @@ -17,7 +17,9 @@ const "abs.twimg.com", "pbs.twimg.com", "video.twimg.com", - "x.com" + "x.com", + "pscp.tv", + "video.pscp.tv" ] proc setHmacKey*(key: string) = @@ -38,12 +40,14 @@ proc getVidUrl*(link: string): string = &"/video/{sig}/{encodeUrl(link)}" proc getPicUrl*(link: string): string = + if link.len == 0: return if base64Media: &"/pic/enc/{encode(link, safe=true)}" else: &"/pic/{encodeUrl(link)}" proc getOrigPicUrl*(link: string): string = + if link.len == 0: return if base64Media: &"/pic/orig/enc/{encode(link, safe=true)}" else: @@ -55,7 +59,13 @@ proc filterParams*(params: Table): seq[(string, string)] = result.add p proc isTwitterUrl*(uri: Uri): bool = - uri.hostname in twitterDomains + uri.scheme in ["http", "https"] and + (uri.hostname in twitterDomains or uri.hostname.endsWith(".video.pscp.tv")) proc isTwitterUrl*(url: string): bool = isTwitterUrl(parseUri(url)) + +proc validateNumber*(value: string): string = + if value.anyIt(not it.isDigit): + return "" + return value diff --git a/src/views/about_account.nim b/src/views/about_account.nim new file mode 100644 index 0000000..aedd444 --- /dev/null +++ b/src/views/about_account.nim @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, strformat, times +import karax/[karaxdsl, vdom] + +import renderutils +import ".."/[types, formatters] + +proc renderAboutAccount*(info: AccountInfo): VNode = + let user = User( + username: info.username, + fullname: info.fullname, + userPic: info.userPic, + verifiedType: info.verifiedType + ) + + buildHtml(tdiv(class="about-account")): + tdiv(class="about-account-header"): + a(class="about-account-avatar", href=(&"/{info.username}")): + genImg(getUserPic(info.userPic, "_200x200")) + tdiv(class="about-account-name"): + linkUser(user, class="profile-card-fullname") + verifiedIcon(user) + linkUser(user, class="profile-card-username") + + tdiv(class="about-account-body"): + tdiv(class="about-account-row"): + span: icon "calendar" + tdiv: + span(class="about-account-label"): text "Date joined" + span(class="about-account-value"): + text info.joinDate.format("MMMM YYYY") + + if info.basedIn.len > 0: + tdiv(class="about-account-row"): + span: icon "location" + tdiv: + span(class="about-account-label"): text "Account based in" + span(class="about-account-value"): text info.basedIn + + if info.verifiedType != VerifiedType.none: + if info.overrideVerifiedYear != 0: + tdiv(class="about-account-row"): + span: icon "ok" + tdiv: + span(class="about-account-label"): text "Verified" + span(class="about-account-value"): + let year = abs(info.overrideVerifiedYear) + let era = if info.overrideVerifiedYear < 0: " BCE" else: "" + text "Since " & $year & era + elif info.verifiedSince.year > 0: + tdiv(class="about-account-row"): + span: icon "ok" + tdiv: + span(class="about-account-label"): text "Verified" + span(class="about-account-value"): + text "Since " & info.verifiedSince.format("MMMM YYYY") + + if info.isIdentityVerified: + tdiv(class="about-account-row"): + span: icon "ok" + tdiv: + span(class="about-account-label"): text "ID Verified" + span(class="about-account-value"): text "Yes" + + if info.affiliateUsername.len > 0: + tdiv(class="about-account-row"): + span: icon "group" + tdiv: + span(class="about-account-label"): text "An affiliate of" + span(class="about-account-value"): + a(href=(&"/{info.affiliateUsername}")): + if info.affiliateLabel.len > 0: + text info.affiliateLabel & " (@" & info.affiliateUsername & ")" + else: + text "@" & info.affiliateUsername + + if info.usernameChanges > 0: + tdiv(class="about-account-row"): + span(class="about-account-at"): text "@" + tdiv: + span(class="about-account-label"): + text $info.usernameChanges & " username change" + if info.usernameChanges > 1: text "s" + if info.lastUsernameChange.year > 0: + span(class="about-account-value"): + text "Last on " & info.lastUsernameChange.format("MMMM YYYY") + + if info.source.len > 0: + tdiv(class="about-account-row"): + span: icon "link" + tdiv: + span(class="about-account-label"): text "Connected via" + span(class="about-account-value"): text info.source diff --git a/src/views/article.nim b/src/views/article.nim new file mode 100644 index 0000000..eedd4cf --- /dev/null +++ b/src/views/article.nim @@ -0,0 +1,248 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, strformat, tables, unicode, bitops, uri +import karax/[karaxdsl, vdom] + +import renderutils, tweet, timeline +import ".."/[types, utils, formatters] + +proc renderAtomicParagraph(paragraph: ArticleParagraph; article: Article; + tweets: Table[int64, Tweet]; path: string; + prefs: Prefs): VNode = + if paragraph.entityRanges.len == 0: + return text "" + + let er = paragraph.entityRanges[0] + if er.key notin article.entities: + return text "" + + let entity = article.entities[er.key] + + case entity.kind + of "MEDIA": + buildHtml(tdiv(class="article-media")): + for id in entity.mediaIds: + let media = article.media.getOrDefault(id) + if media.url.len == 0: + continue + case media.kind + of "ApiGif": + video(src=getVidUrl(media.url), controls="", autoplay="", loop="", + muted="") + of "ApiVideo": + video(src=getVidUrl(media.url), controls="") + else: + a(href=getOrigPicUrl(media.url), target="_blank"): + img(src=getSmallPic(media.url), alt=entity.caption, loading="lazy") + if entity.caption.len > 0: + p(class="article-media-caption"): text entity.caption + of "TWEET": + let tweet = tweets.getOrDefault( + try: parseBiggestInt(entity.tweetId) + except ValueError: 0, nil) + if tweet != nil: + renderTweet(tweet, prefs, path) + else: + text "" + of "MARKDOWN": + var content = entity.markdown + if content.startsWith("```"): + let firstNl = content.find('\n') + if firstNl >= 0: content = content[firstNl + 1 .. ^1] + if content.endsWith("```"): content = content[0 .. ^4] + content = content.strip + buildHtml(pre()): + code(): text content + of "DIVIDER": + buildHtml(hr(class="article-divider")) + else: + text "" + +proc wrapStyle(node: VNode; style: int): VNode = + result = node + if style.testBit(4): result = buildHtml(code()): result + if style.testBit(0): result = buildHtml(strong()): result + if style.testBit(1): result = buildHtml(em()): result + if style.testBit(2): result = buildHtml(del()): result + if style.testBit(3): result = buildHtml(underlined()): result + +proc addContent(target: VNode; content: string; style = 0) = + var first = true + for line in content.split('\n'): + if not first: + target.add VNode(kind: VNodeKind.br) + first = false + var pos = 0 + while pos < line.len: + let atPos = line.find('@', pos) + if atPos == -1: + target.add wrapStyle(text line[pos .. ^1], style) + break + if atPos > 0 and line[atPos - 1] in Letters + Digits + {'_'}: + target.add wrapStyle(text line[pos .. atPos], style) + pos = atPos + 1 + continue + var j = atPos + 1 + while j < line.len and j - atPos - 1 < 15 and + line[j] in Letters + Digits + {'_'}: + inc j + if j == atPos + 1: + target.add wrapStyle(text line[pos .. atPos], style) + pos = atPos + 1 + continue + if atPos > pos: + target.add wrapStyle(text line[pos ..< atPos], style) + let username = line[atPos + 1 ..< j] + let link = a.newVNode() + link.setAttr("href", "/" & username) + link.add wrapStyle(text ("@" & username), style) + target.add link + pos = j + +proc applyInlineStyles(target: VNode; runes: seq[Rune]; start, length: int; + styles: seq[ArticleStyle]) = + if styles.len == 0: + target.addContent($runes[start ..< start + length]) + return + + var + lastStyle = 0 + lastStart = start + let endPos = start + length + + for i in start ..< endPos: + var style = 0 + for sr in styles: + let + sStart = sr.offset + sEnd = sStart + sr.length + if sStart <= i and sEnd > i: + case sr.style + of "Bold": style.setBit(0) + of "Italic": style.setBit(1) + of "Strikethrough": style.setBit(2) + of "Underline": style.setBit(3) + of "Code": style.setBit(4) + else: discard + + if style != lastStyle: + if i > lastStart: + addContent(target, $runes[lastStart ..< i], lastStyle) + lastStyle = style + lastStart = i + + if lastStart < endPos: + addContent(target, $runes[lastStart ..< endPos], lastStyle) + +proc renderTextParagraph(paragraph: ArticleParagraph; article: Article): VNode = + let text = paragraph.text + + result = case paragraph.kind + of "header-one": h1.newVNode() + of "header-two": h2.newVNode() + of "header-three": h3.newVNode() + of "ordered-list-item", "unordered-list-item": li.newVNode() + of "blockquote": VNode(kind: VNodeKind.blockquote) + of "code-block": + let pre = pre.newVNode() + let code = code.newVNode() + code.add text text + pre.add code + return pre + else: p.newVNode() + + let + runes = text.toRunes + textLen = runes.len + var last = 0 + for er in paragraph.entityRanges: + if er.offset > last: + applyInlineStyles(result, runes, last, er.offset - last, + paragraph.inlineStyles) + + last = er.offset + er.length + + var target = result + if er.key in article.entities: + let entity = article.entities[er.key] + if entity.kind == "LINK": + let parsed = parseUri(entity.url) + if parsed.scheme in ["http", "https"]: + target = a.newVNode() + if parsed.isTwitterUrl: + target.setAttr("href", parsed.path) + else: + target.setAttr("href", entity.url) + + applyInlineStyles(target, runes, er.offset, er.length, + paragraph.inlineStyles) + if target != result: + result.add target + + if last < textLen: + applyInlineStyles(result, runes, last, textLen - last, + paragraph.inlineStyles) + + if paragraph.kind == "blockquote" and result.len > 0: + let lastChild = result[result.len - 1] + if lastChild.kind == VNodeKind.strong and lastChild.len > 0 and + lastChild[0].kind == VNodeKind.text: + lastChild.setAttr("class", "blockquote-attribution") + +proc renderArticle*(article: Article; tweets: Table[int64, Tweet]; + path: string; prefs: Prefs; tweetId=""): VNode = + let author = article.user + + let main = buildHtml(article(class="article-body")): + h1(class="article-title"): text article.title + + tdiv(class="article-author"): + tdiv(class="article-author-row"): + a(class="article-avatar", href=("/" & author.username)): + genImg(author.getUserPic("_bigger"), class=prefs.getAvatarClass) + tdiv(class="article-author-info"): + tdiv(class="article-author-name"): + linkUser(author, class="fullname") + verifiedIcon(author) + tdiv(class="article-author-meta"): + linkUser(author, class="username") + span(class="article-date-sep"): text " · " + a(class="article-date", + href=("/" & author.username & "/status/" & tweetId)): + text article.time.getShortTime + if not prefs.hideTweetStats: + renderStats(article.stats) + + var listKind = "" + var list: VNode = nil + + for paragraph in article.paragraphs: + let isListItem = paragraph.kind in [ + "ordered-list-item", "unordered-list-item"] + + if not isListItem and list != nil: + main.add list + list = nil + listKind = "" + + if paragraph.kind == "atomic": + main.add renderAtomicParagraph(paragraph, article, tweets, path, prefs) + elif isListItem: + if paragraph.kind != listKind: + if list != nil: + main.add list + list = if paragraph.kind == "ordered-list-item": ol.newVNode() + else: ul.newVNode() + listKind = paragraph.kind + list.add renderTextParagraph(paragraph, article) + else: + main.add renderTextParagraph(paragraph, article) + + if list != nil: + main.add list + + buildHtml(tdiv(class="article-page")): + if article.coverImage.len > 0: + a(href=getOrigPicUrl(article.coverImage), target="_blank"): + img(class="article-cover", src=getSmallPic(article.coverImage), alt="") + main + renderToTop() diff --git a/src/views/broadcast.nim b/src/views/broadcast.nim new file mode 100644 index 0000000..bfcb9ba --- /dev/null +++ b/src/views/broadcast.nim @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, strformat, times +import karax/[karaxdsl, vdom] + +import renderutils +import ".."/[types, utils, formatters] + +proc renderBroadcast*(bc: Broadcast; prefs: Prefs; path: string): VNode = + let + isLive = bc.state == "RUNNING" + thumb = getPicUrl(bc.thumb) + source = if prefs.proxyVideos and bc.m3u8Url.startsWith("http"): + getVidUrl(bc.m3u8Url) else: bc.m3u8Url + stateText = + if isLive: "LIVE" + elif bc.endTime.year > 1: "Ended " & bc.endTime.format("MMM d, YYYY") + elif bc.state.len > 0: bc.state + else: "Ended" + durationMs = + if bc.startTime.year > 1 and bc.endTime.year > 1: + int((bc.endTime - bc.startTime).inMilliseconds) - bc.replayStart * 1000 + else: 0 + duration = if durationMs > 0: getDuration(durationMs) else: "" + + buildHtml(tdiv(class="broadcast-page")): + tdiv(class="broadcast-panel"): + tdiv(class="broadcast-player"): + if bc.m3u8Url.len > 0 and prefs.hlsPlayback: + video(poster=thumb, data-url=source, data-autoload="false", + data-start=($bc.replayStart), muted=prefs.muteVideos) + verbatim "
" + tdiv(class="overlay-circle"): span(class="overlay-triangle") + if isLive: + tdiv(class="broadcast-live"): text "LIVE" + elif duration.len > 0: + tdiv(class="overlay-duration"): text duration + verbatim "
" + elif bc.m3u8Url.len > 0: + img(src=thumb, alt=bc.title) + tdiv(class="video-overlay"): + buttonReferer "/enablehls", "Enable hls playback", path + if isLive: + tdiv(class="broadcast-live"): text "LIVE" + elif duration.len > 0: + tdiv(class="overlay-duration"): text duration + elif bc.thumb.len > 0: + img(src=thumb, alt=bc.title) + tdiv(class="video-overlay"): + if bc.availableForReplay: + p: text "Stream unavailable" + else: + p: text "Replay is not available" + else: + tdiv(class="video-overlay"): + p: text "Broadcast not found" + + tdiv(class="broadcast-info"): + h2(class="broadcast-title"): text bc.title + + tdiv(class="broadcast-user-row"): + a(class="broadcast-user", href=("/" & bc.user.username)): + genImg(getUserPic(bc.user.userPic, "_bigger")) + tdiv: + tdiv: + strong: text bc.user.fullname + verifiedIcon(bc.user) + span(class="broadcast-username"): text "@" & bc.user.username + + tdiv(class="broadcast-meta"): + if bc.totalWatched > 0: + span: text insertSep($bc.totalWatched, ',') & " views" + if isLive: + span(class="broadcast-live"): text stateText + else: + span: text stateText diff --git a/src/views/community.nim b/src/views/community.nim new file mode 100644 index 0000000..52f9041 --- /dev/null +++ b/src/views/community.nim @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, strformat, times +import karax/[karaxdsl, vdom] + +import renderutils +import ".."/[types, utils, formatters] + +proc renderCommunityTabs*(kind: QueryKind; community: Community): VNode = + let + path = &"/i/communities/{community.id}" + q = Query(kind: kind) + buildHtml(tdiv): + ul(class="tab"): + li(class=q.getTabClass(posts)): + a(href=path): text "Top" + li(class=q.getTabClass(replies)): + a(href=(path & "/latest")): text "Latest" + li(class=q.getTabClass(media)): + a(href=(path & "/media")): text "Media" + li(class=q.getTabClass(userList)): + a(href=(path & "/about")): text "About" + if community.hashtags.len > 0: + tdiv(class="community-tags"): + for tag in community.hashtags: + let bare = tag.strip(chars={'#'}) + a(class="community-tag", + href=(&"/i/communities/{community.id}/hashtag/{bare}")): + text tag + +proc renderMemberTabs*(community: Community; isModerators: bool): VNode = + let path = &"/i/communities/{community.id}" + buildHtml(ul(class="tab")): + li(class=(if not isModerators: "tab-item active" else: "tab-item")): + a(href=(path & "/members")): text "All" + li(class=(if isModerators: "tab-item active" else: "tab-item")): + a(href=(path & "/moderators")): text "Moderators" + +proc renderHashtagHeader*(community: Community; tag: string): VNode = + buildHtml(tdiv(class="community-hashtag-header")): + h2(class="community-hashtag-title"): text "#" & tag + +proc renderCommunityAbout*(community: Community; moderators: seq[User]): VNode = + buildHtml(tdiv(class="community-about")): + tdiv(class="community-info"): + h2: text "Community Info" + tdiv(class="community-info-item"): + icon "group" + if community.joinPolicy == "Open": + text "Anyone can join this Community." + else: + text "Membership is by approval only." + + tdiv(class="community-info-item"): + icon "info" + text "All Communities are publicly visible." + + tdiv(class="community-info-item"): + icon "calendar" + let + date = community.createdAt.format("MMMM d, yyyy") + creator = community.creator.username + span: + text &"Created {date} by " + a(href=(&"/{creator}")): text &"@{creator}" + if community.creator.verifiedType != none: + verifiedIcon(community.creator) + + if community.rules.len > 0: + tdiv(class="community-rules"): + h2: text "Rules" + p(class="community-rules-intro"): + text "These are set and enforced by Community admins and are in addition to " + a(href="https://help.x.com/rules-and-policies/x-rules"): text "X's rules" + text "." + + for i, rule in community.rules: + tdiv(class="community-rule"): + span(class="community-rule-number"): text $(i + 1) + tdiv(class="community-rule-content"): + strong: text rule.name + if rule.description.len > 0: + p: text rule.description + + if moderators.len > 0: + tdiv(class="community-moderators"): + h2: + text "Moderators" + a(class="community-mods-link", + href=(&"/i/communities/{community.id}/moderators")): + text "See all" + for user in moderators: + tdiv(class="community-moderator"): + a(href=(&"/{user.username}")): + genImg(user.getUserPic("_bigger"), class="community-mod-avatar") + tdiv(class="community-mod-info"): + a(href=(&"/{user.username}"), class="community-mod-name"): + text user.fullname + if user.verifiedType != none: + verifiedIcon(user) + a(href=(&"/{user.username}"), class="community-mod-username"): + text &"@{user.username}" + +proc renderCommunity*(body, nav: VNode; community: Community): VNode = + buildHtml(tdiv(class="timeline-container")): + if community.banner.len > 0: + tdiv(class="timeline-banner"): + a(href=getPicUrl(community.banner), target="_blank"): + genImg(community.banner) + + tdiv(class="community-header"): + h1(class="community-name"): + a(href=(&"/i/communities/{community.id}")): text community.name + + if community.category.len > 0: + span(class="community-category"): text community.category + + if community.description.len > 0: + tdiv(class="community-description"): + text community.description + + tdiv(class="community-stats"): + a(class="community-member-count", + href=(&"/i/communities/{community.id}/members")): + text insertSep($community.memberCount, ',') + text " Members" + + nav + body diff --git a/src/views/embed.nim b/src/views/embed.nim index ba49f45..5136a35 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -1,22 +1,76 @@ # SPDX-License-Identifier: AGPL-3.0-only -import options import karax/[karaxdsl, vdom] from jester import Request -import ".."/[types, formatters] +import ".."/[types, formatters, prefs] import general, tweet -const doctype = "\n" +const + doctype = "\n" + embedResizeJs = staticRead("../../public/js/embedResize.js") proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = - let thumb = get(tweet.video).thumb - let vidUrl = getVideoEmbed(cfg, tweet.id) - let prefs = Prefs(hlsPlayback: true, mp4Playback: true) + let + video = tweet.getVideos()[0] + thumb = video.thumb + vidUrl = getVideoEmbed(cfg, tweet.id) + prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos) + tweetUrl = getLink(tweet) + let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) + base(target="_blank") body: tdiv(class="embed-video"): - renderVideo(get(tweet.video), prefs, "") + renderVideo(video, prefs, "") + a(class="video-overlay-link", href=tweetUrl): + text "Watch on " & cfg.hostname + + script: + verbatim embedResizeJs + + result = doctype & $node + +proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = + let node = buildHtml(html(lang="en")): + renderHead(prefs, cfg, req) + base(target="_blank") + + body: + tdiv(class="embed-wrapper"): + tdiv(class="tweet-embed"): + a(class="tweet-link", href=getLink(tweet)) + renderTweet(tweet, prefs, path, mainTweet=true) + a(class="embed-footer", href=getLink(tweet)): + text "Read more on " & cfg.hostname + + script: + verbatim embedResizeJs + + result = doctype & $node + +proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request; + tweetId = ""; username = ""): string = + let link = if tweetId.len > 0: + if username.len > 0: "/" & username & "/status/" & tweetId + else: "/i/status/" & tweetId + else: "/" + + let node = buildHtml(html(lang="en")): + renderHead(prefs, cfg, req) + base(target="_blank") + + body: + tdiv(class="embed-wrapper"): + tdiv(class="tweet-embed error-embed"): + a(class="tweet-link", href=link) + tdiv(class="error-panel"): + span: text error + a(class="embed-footer", href=link): + text "Read more on " & cfg.hostname + + script: + verbatim embedResizeJs result = doctype & $node diff --git a/src/views/general.nim b/src/views/general.nim index 0091c74..d979898 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -29,19 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = tdiv(class="nav-item right"): icon "search", title="Search", href="/search" - if cfg.enableRss and rss.len > 0: + if rss.len > 0: icon "rss", title="RSS Feed", href=rss - icon "bird", title="Open in Twitter", href=canonical + icon "bird", title="Open in X", href=canonical a(href="https://liberapay.com/zedeus"): 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 = - var theme = prefs.theme.toTheme - if "theme" in req.params: - theme = req.params["theme"].toTheme + rss=""; alternate=""; oembed=""): VNode = + let theme = prefs.theme.toTheme let ogType = if video.len > 0: "video" @@ -52,8 +50,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=3") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=106") + link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) @@ -66,15 +64,19 @@ 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: + if rss.len > 0: link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") + if oembed.len > 0: + let oembedTitle = if titleText.len > 0: titleText else: "oEmbed" + link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle) + if prefs.hlsPlayback: script(src="/js/hls.min.js", `defer`="") - script(src="/js/hlsPlayback.js", `defer`="") + script(src="/js/hlsPlayback.js?v=1", `defer`="") if prefs.infiniteScroll: script(src="/js/infiniteScroll.js", `defer`="") @@ -86,6 +88,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; text cfg.title meta(name="viewport", content="width=device-width, initial-scale=1.0") + meta(name="referrer", content="same-origin") meta(name="theme-color", content="#1F1F1F") meta(property="og:type", content=ogType) meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText)) @@ -98,6 +101,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; link(rel="preload", type="image/png", href=bannerUrl, `as`="image") for url in images: + if url.len == 0: continue let preloadUrl = if "400x400" in url: getPicUrl(url) else: getSmallPic(url) link(rel="preload", type="image/png", href=preloadUrl, `as`="image") @@ -119,20 +123,24 @@ 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?61663884", crossorigin="anonymous") + href="/fonts/fontello.woff2?59696369", crossorigin="anonymous") proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; - images: seq[string] = @[]; banner=""): string = + images: seq[string] = @[]; banner=""; + twitterLink=""; oembed=""): string = - let canonical = getTwitterLink(req.path, req.params) + let twitterLink = + if twitterLink.len > 0: twitterLink + else: 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, oembed) - body: - renderNavbar(cfg, req, rss, canonical) + let bodyClass = if prefs.stickyNav: "fixed-nav" else: "" + body(class=bodyClass): + renderNavbar(cfg, req, rss, twitterLink) tdiv(class="container"): body diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf new file mode 100644 index 0000000..4f7f947 --- /dev/null +++ b/src/views/oembed.nimf @@ -0,0 +1,7 @@ +#? stdtmpl(subsChar = '$', metaChar = '#') +## SPDX-License-Identifier: AGPL-3.0-only +#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string = +# result = "" + +# result = result.strip() +#end proc diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 1787704..b051a01 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -32,7 +32,8 @@ macro renderPrefs*(): untyped = result[2].add stmt -proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode = +proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]; + prefsUrl: string): VNode = buildHtml(tdiv(class="overlay-panel")): fieldset(class="preferences"): form(`method`="post", action="/saveprefs", autocomplete="off"): @@ -40,6 +41,14 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode renderPrefs() + legend: text "Bookmark" + p(class="bookmark-note"): + text "Save this URL to restore your preferences (?prefs works on all pages)" + pre(class="prefs-code"): + text prefsUrl + p(class="bookmark-note"): + verbatim "You can override preferences with query parameters (e.g. ?hlsPlayback=on). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name." + h4(class="note"): text "Preferences are stored client-side using cookies without any personal information." diff --git a/src/views/profile.nim b/src/views/profile.nim index 2b2e410..c9012ed 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -2,7 +2,7 @@ import strutils, strformat import karax/[karaxdsl, vdom, vstyles] -import renderutils, search +import renderutils, search, timeline import ".."/[types, utils, formatters] proc renderStat(num: int; class: string; text=""): VNode = @@ -12,7 +12,14 @@ proc renderStat(num: int; class: string; text=""): VNode = span(class="profile-stat-num"): text insertSep($num, ',') -proc renderUserCard*(user: User; prefs: Prefs): VNode = +proc renderStatLink(num: int; class, href: string): VNode = + buildHtml(li(class=class)): + a(href=href): + span(class="profile-stat-header"): text capitalizeAscii(class) + span(class="profile-stat-num"): + text insertSep($num, ',') + +proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode = buildHtml(tdiv(class="profile-card")): tdiv(class="profile-card-info"): let @@ -26,6 +33,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = tdiv(class="profile-card-tabs-name"): linkUser(user, class="profile-card-fullname") + verifiedIcon(user) linkUser(user, class="profile-card-username") tdiv(class="profile-card-extra"): @@ -45,6 +53,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = else: span: text place + if info.basedIn.len > 0: + tdiv(class="profile-location"): + span: icon "location" + span: text "Based in " & info.basedIn + if user.website.len > 0: tdiv(class="profile-website"): span: @@ -53,14 +66,14 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = a(href=url): text url.shortLink tdiv(class="profile-joindate"): - span(title=getJoinDateFull(user)): + a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)): icon "calendar", getJoinDate(user) tdiv(class="profile-card-extra-links"): ul(class="profile-statlist"): renderStat(user.tweets, "posts", text="Tweets") - renderStat(user.following, "following") - renderStat(user.followers, "followers") + renderStatLink(user.following, "following", &"/{user.username}/following") + renderStatLink(user.followers, "followers", &"/{user.username}/followers") renderStat(user.likes, "likes") proc renderPhotoRail(profile: Profile): VNode = @@ -93,7 +106,7 @@ proc renderBanner(banner: string): VNode = else: a(href=getPicUrl(banner), target="_blank"): genImg(banner) -proc renderProtected(username: string): VNode = +proc renderProtected*(username: string): VNode = buildHtml(tdiv(class="timeline-container")): tdiv(class="timeline-header timeline-protected"): h2: text "This account's tweets are protected." @@ -101,19 +114,52 @@ proc renderProtected(username: string): VNode = proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = profile.tweets.query.fromUser = @[profile.user.username] + let + isGalleryView = profile.tweets.query.kind == QueryKind.media and + profile.tweets.query.view == "gallery" + viewClass = if isGalleryView: " media-only" else: "" - buildHtml(tdiv(class="profile-tabs")): - if not prefs.hideBanner: + buildHtml(tdiv(class=("profile-tabs" & viewClass))): + if not isGalleryView and not prefs.hideBanner: tdiv(class="profile-banner"): renderBanner(profile.user.banner) - let sticky = if prefs.stickyProfile: " sticky" else: "" - tdiv(class=("profile-tab" & sticky)): - renderUserCard(profile.user, prefs) - if profile.photoRail.len > 0: - renderPhotoRail(profile) + if not isGalleryView: + let sticky = if prefs.stickyProfile: " sticky" else: "" + tdiv(class=("profile-tab" & sticky)): + renderUserCard(profile.user, prefs, profile.accountInfo) + if profile.photoRail.len > 0: + renderPhotoRail(profile) if profile.user.protected: renderProtected(profile.user.username) else: renderTweetSearch(profile.tweets, prefs, path, profile.pinned) + +proc renderFollowTabs(user: User; activeTab: string): VNode = + buildHtml(ul(class="tab")): + for tab in ["Following", "Followers"]: + li(class=(if activeTab == tab: "tab-item active" else: "tab-item")): + a(href=(&"/{user.username}/{tab.toLowerAscii()}")): text tab + +proc renderUserList*(user: User; results: Result[User]; prefs: Prefs; + path, activeTab: string): VNode = + # Check if we've loaded all results (for page 1) + var displayResults = results + if results.beginning: + let expectedCount = if activeTab == "Followers": user.followers else: user.following + if results.content.len >= expectedCount: + displayResults.bottom = "" + + buildHtml(tdiv(class="profile-tabs")): + if not prefs.hideBanner: + tdiv(class="profile-banner"): + renderBanner(user.banner) + + let sticky = if prefs.stickyProfile: " sticky" else: "" + tdiv(class=("profile-tab" & sticky)): + renderUserCard(user, prefs, AccountInfo()) + + tdiv(class="timeline-container"): + renderFollowTabs(user, activeTab) + renderTimelineUsers(displayResults, prefs, path) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 41ef8df..af2f05d 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -4,14 +4,23 @@ import karax/[karaxdsl, vdom, vstyles] import ".."/[types, utils] const smallWebp* = "?name=small&format=webp" +const mediumWebp* = "?name=medium&format=webp" proc getSmallPic*(url: string): string = + if url.len == 0: return result = url if "?" notin url and not url.endsWith("placeholder.png"): result &= smallWebp result = getPicUrl(result) -proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = +proc getMediumPic*(url: string): string = + if url.len == 0: return + result = url + if "?" notin url and not url.endsWith("placeholder.png"): + result &= mediumWebp + result = getPicUrl(result) + +proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode = var c = "icon-" & icon if class.len > 0: c = &"{c} {class}" buildHtml(tdiv(class="icon-container")): @@ -20,13 +29,15 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = else: span(class=c, title=title) - if text.len > 0: - text " " & text + if label.len > 0: + text " " & label template verifiedIcon*(user: User): untyped {.dirty.} = if user.verifiedType != VerifiedType.none: let lower = ($user.verifiedType).toLowerAscii() - icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") + buildHtml(tdiv(class=(&"verified-icon {lower}"))): + icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account") + icon "ok", class="verified-icon-check", title=(&"Verified {lower} account") else: text "" @@ -40,7 +51,6 @@ proc linkUser*(user: User, class=""): VNode = buildHtml(a(href=href, class=class, title=nameText)): text nameText if isName: - verifiedIcon(user) if user.protected: text " " icon "lock", title="Protected account" @@ -64,20 +74,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod text text proc genCheckbox*(pref, label: string; state: bool): VNode = - buildHtml(label(class="pref-group checkbox-container")): + buildHtml(label(class="pref-group checkbox-container", title=pref)): text label input(name=pref, `type`="checkbox", checked=state) span(class="checkbox") proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode = let p = placeholder - buildHtml(tdiv(class=("pref-group pref-input " & class))): + buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)): if label.len > 0: label(`for`=pref): text label 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", title=pref)): label(`for`=pref): text label select(name=pref): for opt in options: @@ -89,9 +99,16 @@ proc genDate*(pref, state: string): VNode = input(name=pref, `type`="date", value=state) icon "calendar" -proc genImg*(url: string; class=""): VNode = +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=""; alt=""): VNode = buildHtml(): - img(src=getPicUrl(url), class=class, alt="", loading="lazy") + img(src=getPicUrl(url), class=class, alt=alt, loading="lazy") proc getTabClass*(query: Query; tab: QueryKind): string = if query.kind == tab: "tab-item active" diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 819f99c..4738705 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -1,31 +1,140 @@ #? stdtmpl(subsChar = '$', metaChar = '#') ## SPDX-License-Identifier: AGPL-3.0-only -#import strutils, xmltree, strformat, options, unicode +#import strutils, sequtils, xmltree, strformat, options, unicode #import ../types, ../utils, ../formatters, ../prefs +## Snowflake ID cutoff for RSS GUID format transition +## Corresponds to approximately December 14, 2025 UTC +#const guidCutoff = 2000000000000000000'i64 # #proc getTitle(tweet: Tweet; retweet: string): string = -#if tweet.pinned: result = "Pinned: " -#elif retweet.len > 0: result = &"RT by @{retweet}: " -#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: " +#var prefix = "" +#if tweet.pinned: prefix = "Pinned: " +#elif retweet.len > 0: prefix = &"RT by @{retweet}: " +#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: " #end if -#var text = stripHtml(tweet.text) +#var text = strutils.splitWhitespace(stripHtml(tweet.text)).join(" ") ##if unicode.runeLen(text) > 32: ## text = unicode.runeSubStr(text, 0, 32) & "..." ##end if -#result &= xmltree.escape(text) -#if result.len > 0: return +#text = xmltree.escape(text) +## article tweets' text is just the article link; the title says more +#if tweet.articlePreview.isSome and tweet.articlePreview.get().title.len > 0: +# result = prefix & xmltree.escape(tweet.articlePreview.get().title) +# return #end if -#if tweet.photos.len > 0: -# result &= "Image" -#elif tweet.video.isSome: -# result &= "Video" -#elif tweet.gif.isSome: -# result &= "Gif" +#if text.len > 0: +# result = prefix & text +# return +#end if +#if tweet.media.len > 0: +# result = prefix +# let firstKind = tweet.media[0].kind +# if tweet.media.anyIt(it.kind != firstKind): +# result &= "Media" +# else: +# case firstKind +# of photoMedia: result &= "Image" +# of videoMedia: result &= "Video" +# of gifMedia: result &= "Gif" +# end case +# end if +#end if +#if result.len == 0 and tweet.card.isSome: +# let card = tweet.card.get() +# if card.kind notin {hidden, unknown} and card.title.len > 0: +# result = prefix & xmltree.escape(card.title) +# end if #end if #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 renderRssMedia(media: Media; tweet: Tweet; urlPrefix: string): string = +#case media.kind +#of photoMedia: +# let photo = media.photo + +#of videoMedia: +# let video = media.video + +
Video
+ +
+#of gifMedia: +# let gif = media.gif +# let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}" +# let url = &"{urlPrefix}{getPicUrl(gif.url)}" + +#end case +#end proc +# +#proc renderRssCard(card: Card; prefs: Prefs; urlPrefix: string): string = +#let cardLink = if card.url.startsWith("/"): urlPrefix & card.url else: replaceUrls(card.url, prefs) +#let title = xmltree.escape(card.title) +
+Link
+#if cardLink.len > 0: + +#end if +#if card.image.len > 0: + +#end if +#if title.len > 0: +# if card.image.len > 0: +
+# end if +${title} +#end if +#if cardLink.len > 0: +
+#end if +#if card.text.len > 0: +

${xmltree.escape(card.text)}

+#end if +#if cardLink.len > 0: +# let destText = if card.dest.len > 0: xmltree.escape(card.dest) else: xmltree.escape(cardLink) +${destText} +#elif card.dest.len > 0: +${xmltree.escape(card.dest)} +#end if +#end proc +# +#proc renderRssArticle(article: ArticlePreview; urlPrefix: string): string = +#let link = urlPrefix & "/i/article/" & $article.tweetId +
+Article
+ +#if article.coverImage.len > 0: + +#end if +#if article.title.len > 0: +# if article.coverImage.len > 0: +
+# end if +${xmltree.escape(article.title)} +#end if +
+#if article.previewText.len > 0: +

${xmltree.escape(article.previewText)}

+#end if +#end proc +# +#proc renderRssPoll(poll: Poll): string = +
+Poll +

+#for i in 0 ..< poll.options.len: +# let perc = if poll.votes > 0: poll.values[i] / poll.votes * 100 else: 0.0 +# let pct = (&"{perc:.0f}").strip(chars={'.'}) +# let line = pct & "% — " & xmltree.escape(poll.options[i]) +${line}
+#end for +#let votesStr = insertSep($poll.votes, ',') +${votesStr} votes • ${xmltree.escape(poll.status)} +

#end proc # #proc getTweetsWithPinned(profile: Profile): seq[Tweets] = @@ -46,35 +155,46 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #end if #end proc # -#proc renderRssTweet(tweet: Tweet; cfg: Config): string = +#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string = #let tweet = tweet.retweet.get(tweet) #let urlPrefix = getUrlPrefix(cfg) -#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix) +#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix) +#if text.len > 0:

${text.replace("\n", "
\n")}

-#if tweet.quote.isSome and get(tweet.quote).available: -# let quoteLink = getLink(get(tweet.quote)) -

${cfg.hostname}${quoteLink}

#end if -#if tweet.photos.len > 0: -# for photo in tweet.photos: - +#if tweet.media.len > 0: +# for media in tweet.media: +${renderRssMedia(media, tweet, urlPrefix)} # end for -#elif tweet.video.isSome: - -#elif tweet.gif.isSome: -# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" -# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" - -#elif tweet.card.isSome: -# let card = tweet.card.get() -# if card.image.len > 0: - -# end if +#elif tweet.card.isSome and tweet.card.get().kind notin {hidden, unknown}: +${renderRssCard(tweet.card.get(), prefs, urlPrefix)} +#end if +#if tweet.articlePreview.isSome: +${renderRssArticle(tweet.articlePreview.get(), urlPrefix)} +#end if +#if tweet.poll.isSome: +${renderRssPoll(tweet.poll.get())} +#end if +#if tweet.note.len > 0 and not prefs.hideCommunityNotes: +

Community note: ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}

+#end if +#if tweet.quote.isSome and get(tweet.quote).available: +# let quoteTweet = get(tweet.quote) +# let quoteLink = urlPrefix & getLink(quoteTweet) +
+
+${quoteTweet.user.fullname} (@${quoteTweet.user.username}) +

+${renderRssTweet(quoteTweet, cfg, prefs)} +

+ +
#end if #end proc # -#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = +#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string = #let urlPrefix = getUrlPrefix(cfg) #var links: seq[string] #for thread in tweets: @@ -88,19 +208,24 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} # if link in links: continue # end if # links.add link +# let useGlobalGuid = tweet.id >= guidCutoff ${getTitle(tweet, retweet)} @${tweet.user.username} - + ${getRfc822Time(tweet)} +#if useGlobalGuid: + ${tweet.id} +#else: ${urlPrefix & link} +#end if ${urlPrefix & link} # end for #end for #end proc # -#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string = +#proc renderTimelineRss*(profile: Profile; cfg: Config; prefs: Prefs; multi=false): string = #let urlPrefix = getUrlPrefix(cfg) #result = "" #let handle = (if multi: "" else: "@") & profile.user.username @@ -126,13 +251,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #let tweetsList = getTweetsWithPinned(profile) #if tweetsList.len > 0: -${renderRssTweets(tweetsList, cfg, userId=profile.user.id)} +${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)} #end if #end proc # -#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string = +#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config; prefs: Prefs): string = #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}" #result = "" @@ -144,12 +269,12 @@ ${renderRssTweets(tweetsList, cfg, userId=profile.user.id)} ${getDescription(&"{list.name} by @{list.username}", cfg)} en-us 40 -${renderRssTweets(tweets, cfg)} +${renderRssTweets(tweets, cfg, prefs)} #end proc # -#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string = +#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config; prefs: Prefs): string = #let link = &"{getUrlPrefix(cfg)}/search" #let escName = xmltree.escape(name) #result = "" @@ -162,7 +287,7 @@ ${renderRssTweets(tweets, cfg)} ${getDescription(&"Search \"{escName}\"", cfg)} en-us 40 -${renderRssTweets(tweets, cfg)} +${renderRssTweets(tweets, cfg, prefs)} #end proc diff --git a/src/views/search.nim b/src/views/search.nim index 9f7fc95..4d4ed5e 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -10,14 +10,12 @@ const toggles = { "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" + "spaces": "Spaces" }.toOrderedTable proc renderSearch*(): VNode = @@ -38,29 +36,62 @@ proc renderProfileTabs*(query: Query; username: string): VNode = a(href=(link & "/with_replies")): text "Tweets & Replies" li(class=query.getTabClass(media)): a(href=(link & "/media")): text "Media" + if query.fromUser.len == 1: + li(class=query.getTabClass(QueryKind.articles)): + a(href=(link & "/articles")): text "Articles" li(class=query.getTabClass(tweets)): a(href=(link & "/search")): text "Search" +proc mediaViewUrl(query: Query; view: string): string = + var q = query + q.view = view + "?" & genQueryUrl(q) + +proc renderMediaViewTabs*(query: Query): VNode = + let currentView = if query.view.len > 0: query.view else: "timeline" + func cls(view: string): string = + if currentView == view: "tab-item active" else: "tab-item" + buildHtml(ul(class="tab media-view-tabs")): + li(class=cls("timeline")): + a(href=query.mediaViewUrl("timeline")): text "Timeline" + li(class=cls("grid")): + a(href=query.mediaViewUrl("grid")): text "Grid" + li(class=cls("gallery")): + a(href=query.mediaViewUrl("gallery")): text "Gallery" + proc renderSearchTabs*(query: Query): VNode = var q = query + # the media view mode only applies to the Media tab + q.view = "" buildHtml(ul(class="tab")): + li(class=query.getTabClass(top)): + q.kind = top + a(href=("?" & genQueryUrl(q))): text "Top" li(class=query.getTabClass(tweets)): q.kind = tweets - a(href=("?" & genQueryUrl(q))): text "Tweets" + a(href=("?" & genQueryUrl(q))): text "Latest" + li(class=query.getTabClass(media)): + q.kind = media + q.view = query.view + a(href=("?" & genQueryUrl(q))): text "Media" li(class=query.getTabClass(users)): q.kind = users + q.view = "" a(href=("?" & genQueryUrl(q))): text "Users" + li(class=query.getTabClass(lists)): + q.kind = lists + a(href=("?" & genQueryUrl(q))): text "Lists" 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)) + @[q.minLikes, 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") + hiddenField("f", $query.kind) genInput("q", "", query.text, "Enter search...", class="pref-inline") button(`type`="submit"): icon "search" @@ -85,36 +116,58 @@ proc renderSearchPanel*(query: Query): VNode = span(class="search-title"): text "-" genDate("until", query.until) tdiv: - span(class="search-title"): text "Near" - genInput("near", "", query.near, "Location...", autofocus=false) + span(class="search-title"): text "Minimum likes" + genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false) proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = let query = results.query - buildHtml(tdiv(class="timeline-container")): + let containerClass = + if query.fromUser.len == 0 and query.kind == QueryKind.media and + query.view == "gallery": "timeline-container media-only" + else: "timeline-container" + buildHtml(tdiv(class=containerClass)): if query.fromUser.len > 1: tdiv(class="timeline-header"): text query.fromUser.join(" | ") if query.fromUser.len > 0: - renderProfileTabs(query, query.fromUser.join(",")) + if query.kind != QueryKind.media or query.view != "gallery": + renderProfileTabs(query, query.fromUser.join(",")) + if query.kind == QueryKind.media and query.fromUser.len == 1: + renderMediaViewTabs(query) - if query.fromUser.len == 0 or query.kind == tweets: + if query.fromUser.len == 0 or query.kind == QueryKind.tweets: tdiv(class="timeline-header"): renderSearchPanel(query) if query.fromUser.len == 0: renderSearchTabs(query) + if query.kind == QueryKind.media: + renderMediaViewTabs(query) renderTimelineTweets(results, prefs, path, pinned) +proc renderSearchForm(kind, placeholder, value: string): VNode = + buildHtml(form(`method`="get", action="/search", + class="search-field", autocomplete="off")): + hiddenField("f", kind) + genInput("q", "", value, placeholder, class="pref-inline") + button(`type`="submit"): icon "search" + 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" + renderSearchForm("users", "Enter username...", results.query.text) renderSearchTabs(results.query) renderTimelineUsers(results, prefs) + +proc renderListSearch*(results: Result[ListSearchResult]; prefs: Prefs; + path: string): VNode = + buildHtml(tdiv(class="timeline-container")): + tdiv(class="timeline-header"): + renderSearchForm("lists", "Enter search...", results.query.text) + + renderSearchTabs(results.query) + renderTimelineLists(results, prefs, path) diff --git a/src/views/space.nim b/src/views/space.nim new file mode 100644 index 0000000..a5cac7b --- /dev/null +++ b/src/views/space.nim @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, times +import karax/[karaxdsl, vdom] + +import renderutils +import ".."/[types, utils, formatters] + +proc renderParticipant(p: SpaceParticipant; role: string): VNode = + buildHtml(tdiv(class="space-participant")): + a(href=("/" & p.username)): + genImg(p.avatarUrl.replace("_normal", "_bigger")) + tdiv(class="participant-info"): + tdiv(class="participant-name"): + strong: text p.displayName + if p.isVerified: + tdiv(class="verified-icon blue"): + icon "circle", class="verified-icon-circle", title="Verified account" + icon "ok", class="verified-icon-check", title="Verified account" + if role.len > 0: + span(class="host-badge"): text role + span(class="participant-username"): text "@" & p.username + +proc renderSpace*(sp: AudioSpace; prefs: Prefs; path: string): VNode = + let + isLive = sp.state == "RUNNING" + source = if prefs.proxyVideos and sp.m3u8Url.startsWith("http"): + getVidUrl(sp.m3u8Url) else: sp.m3u8Url + stateText = + if isLive: "LIVE" + elif sp.endTime.year > 1: "Ended " & sp.endTime.format("MMM d, YYYY") + elif sp.state.len > 0: sp.state + else: "Ended" + durationMs = + if sp.startTime.year > 1 and sp.endTime.year > 1: + int((sp.endTime - sp.startTime).inMilliseconds) + else: 0 + duration = if durationMs > 0: getDuration(durationMs) else: "" + totalListeners = + if sp.totalReplayWatched > 0: sp.totalReplayWatched + else: sp.totalLiveListeners + + buildHtml(tdiv(class="space-page")): + tdiv(class="space-panel"): + tdiv(class="space-player"): + if sp.m3u8Url.len > 0 and prefs.hlsPlayback: + audio(data-url=source, data-autoload="false") + verbatim "
" + tdiv(class="overlay-circle"): span(class="overlay-triangle") + if isLive: + tdiv(class="space-live"): text "LIVE" + elif duration.len > 0: + tdiv(class="overlay-duration"): text duration + verbatim "
" + elif sp.m3u8Url.len > 0: + tdiv(class="video-overlay"): + buttonReferer "/enablehls", "Enable hls playback", path + if isLive: + tdiv(class="space-live"): text "LIVE" + elif duration.len > 0: + tdiv(class="overlay-duration"): text duration + elif sp.availableForReplay: + tdiv(class="video-overlay"): + p: text "Audio stream unavailable" + else: + tdiv(class="video-overlay"): + p: text "Replay is not available" + + tdiv(class="space-info"): + tdiv(class="space-header"): + h2(class="space-title"): text sp.title + tdiv(class="space-meta"): + if totalListeners > 0: + span(class="listener-count"): text insertSep($totalListeners, ',') & " listeners" + if isLive: + span(class="space-live"): text stateText + else: + span(class="space-state"): text stateText + + if sp.admins.len > 0 or sp.speakers.len > 0: + tdiv(class="space-participants"): + for admin in sp.admins: + let role = if admin.username == sp.creator.username: "Host" + else: "Co-host" + renderParticipant(admin, role) + for speaker in sp.speakers: + renderParticipant(speaker, "") diff --git a/src/views/status.nim b/src/views/status.nim index 71c2c67..b16c211 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -1,4 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only +import sequtils import karax/[karaxdsl, vdom] import ".."/[types, formatters] @@ -28,16 +29,46 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode = if thread.hasMore: renderMoreReplies(thread) -proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = +proc renderReplySort(sort: RankingMode): VNode = + buildHtml(tdiv(class="reply-sort")): + span(class="reply-sort-label"): text "Sort replies:" + for mode in RankingMode: + let + cls = if mode == sort: "reply-sort-option active" + else: "reply-sort-option" + label = case mode + of Relevance: "Relevant" + of Recency: "Recent" + of Likes: "Liked" + a(class=cls, href=("?sort=" & $mode & "#r")): + text label + +proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; + tweet: Tweet = nil; sort = Relevance): VNode = buildHtml(tdiv(class="replies", id="r")): + var hasReplies = false + var replyCount = 0 for thread in replies.content: - if thread.content.len == 0: continue + if thread.content.len == 0 or thread.related: continue + hasReplies = true + replyCount += thread.content.len renderReplyThread(thread, prefs, path) - if replies.bottom.len > 0: - renderMore(Query(), replies.bottom, focus="#r") + if hasReplies and replies.bottom.len > 0: + if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies: + let extra = if sort == Relevance: "" else: "sort=" & $sort & "&" + renderMore(Query(), replies.bottom, focus="#r", extra=extra) -proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = +proc renderRelated(replies: Result[Chain]; prefs: Prefs; path: string): VNode = + buildHtml(tdiv(class="related-tweets")): + tdiv(class="related-header"): + text "Related tweets" + for thread in replies.content: + if thread.content.len == 0 or not thread.related: continue + renderReplyThread(thread, prefs, path) + +proc renderConversation*(conv: Conversation; prefs: Prefs; path: string; + sort = Relevance): VNode = let hasAfter = conv.after.content.len > 0 let threadId = conv.tweet.threadId buildHtml(tdiv(class="conversation")): @@ -70,6 +101,25 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode if not conv.replies.beginning: renderNewer(Query(), getLink(conv.tweet), focus="#r") if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: - renderReplies(conv.replies, prefs, path) + renderReplySort(sort) + renderReplies(conv.replies, prefs, path, conv.tweet, sort) + + if not prefs.hideRelated: + if conv.replies.content.anyIt(it.related and it.content.len > 0): + renderRelated(conv.replies, prefs, path) renderToTop(focus="#m") + +proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode = + buildHtml(tdiv(class="edit-history")): + tdiv(class="latest-edit"): + tdiv(class="edit-history-header"): + text "Latest post" + renderTweet(edits.latest, prefs, path) + + tdiv(class="previous-edits"): + tdiv(class="edit-history-header"): + text "Version history" + for tweet in edits.history: + tdiv(class="tweet-edit"): + renderTweet(tweet, prefs, path) diff --git a/src/views/timeline.nim b/src/views/timeline.nim index abeb6d3..2890dd1 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -5,12 +5,38 @@ import karax/[karaxdsl, vdom] import ".."/[types, query, formatters] import tweet, renderutils +proc timelineViewClass(query: Query): string = + if query.kind != QueryKind.media: + return "timeline" + + case query.view + of "grid": "timeline media-grid-view" + of "gallery": "timeline media-gallery-view" + else: "timeline" + proc getQuery(query: Query): string = if query.kind != posts: result = genQueryUrl(query) if result.len > 0: result &= "&" +proc getSearchMaxId(results: Timeline; path: string): string = + if results.query.kind != tweets or results.content.len == 0 or + results.query.until.len == 0: + return + + let lastThread = results.content[^1] + if lastThread.len == 0 or lastThread[^1].id == 0: + return + + # 2000000 is the minimum decrement to guarantee no result overlap + var maxId = lastThread[^1].id - 2_000_000'i64 + if maxId <= 0: + maxId = lastThread[^1].id - 1 + + if maxId > 0: + return "maxid:" & $maxId + proc renderToTop*(focus="#"): VNode = buildHtml(tdiv(class="top-ref")): icon "down", href=focus @@ -24,9 +50,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode = a(href=(p & url)): text "Load newest" -proc renderMore*(query: Query; cursor: string; focus=""): VNode = +proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode = buildHtml(tdiv(class="show-more")): - a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): + a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): text "Load more" proc renderNoMore(): VNode = @@ -39,7 +65,7 @@ proc renderNoneFound(): VNode = h2(class="timeline-none"): text "No items found" -proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = +proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): VNode = buildHtml(tdiv(class="thread-line")): let sortedThread = thread.sortedByIt(it.id) for i, tweet in sortedThread: @@ -53,10 +79,10 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = let show = i == thread.high and sortedThread[0].id != tweet.threadId let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" renderTweet(tweet, prefs, path, class=(header & "thread"), - index=i, last=(i == thread.high), showThread=show) + index=i, last=(i == thread.high), bigThumb=bigThumb) proc renderUser(user: User; prefs: Prefs): VNode = - buildHtml(tdiv(class="timeline-item")): + buildHtml(tdiv(class="timeline-item", data-username=user.username)): a(class="tweet-link", href=("/" & user.username)) tdiv(class="tweet-body profile-result"): tdiv(class="tweet-header"): @@ -66,6 +92,7 @@ proc renderUser(user: User; prefs: Prefs): VNode = tdiv(class="tweet-name-row"): tdiv(class="fullname-and-username"): linkUser(user, class="fullname") + verifiedIcon(user) linkUser(user, class="username") tdiv(class="tweet-content media-body", dir="auto"): @@ -87,15 +114,106 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode = else: renderNoMore() +proc mentionUsername(word: string): string = + # "@user" -> "user" for well-formed mentions, "" otherwise + if word.len > 1 and word[0] == '@' and + word[1 .. ^1].allCharsInSet({'A'..'Z', 'a'..'z', '0'..'9', '_'}): + word[1 .. ^1] + else: "" + +proc mentionedUser(s: string): string = + # last @mention in strings like "65 followers including @user" + let words = s.split(' ') + for i in countdown(words.high, 0): + result = mentionUsername(words[i]) + if result.len > 0: return + +proc renderMentionedText(s: string): VNode = + # linkify @mentions in plain API strings like "65 followers including @user" + let words = s.split(' ') + buildHtml(span): + for i in 0 ..< words.len: + if i > 0: text " " + let username = mentionUsername(words[i]) + if username.len > 0: + a(href=("/" & username)): text words[i] + else: + text words[i] + +proc renderListCard(r: ListSearchResult): VNode = + let listUrl = "/i/lists/" & r.list.id + buildHtml(tdiv(class="timeline-item list-result")): + a(class="tweet-link", href=listUrl) + a(class="list-result-banner", href=listUrl): + if r.list.banner.len > 0: + genImg(r.list.banner) + tdiv(class="list-result-body"): + tdiv(class="list-result-title fullname-and-username"): + a(class="list-name fullname", href=listUrl): text r.list.name + span(class="list-members"): + text &"· {insertSep($r.list.members, ',')} members" + tdiv(class="list-result-context"): + if r.followersContext.len > 0: + # the first facepile belongs to the "including @user" account + let mentioned = mentionedUser(r.followersContext) + for i in 0 ..< r.facepiles.len: + if i == 0 and mentioned.len > 0: + a(class="facepile-link", href=("/" & mentioned)): + genImg(r.facepiles[i], class="list-facepile") + else: + genImg(r.facepiles[i], class="list-facepile") + renderMentionedText(r.followersContext) + else: + if r.owner.username.len > 0: + a(class="facepile-link", href=("/" & r.owner.username)): + genImg(r.owner.getUserPic("_mini"), class="list-facepile") + else: + genImg(r.owner.getUserPic("_mini"), class="list-facepile") + linkUser(r.owner, class="fullname") + linkUser(r.owner, class="username") + if r.list.description.len > 0: + tdiv(class="list-result-description"): + text r.list.description + +proc renderTimelineLists*(results: Result[ListSearchResult]; prefs: Prefs; + path=""): VNode = + buildHtml(tdiv(class="timeline")): + if not results.beginning: + renderNewer(results.query, path) + + if results.content.len > 0: + for list in results.content: + renderListCard(list) + if results.bottom.len > 0: + renderMore(results.query, results.bottom) + renderToTop() + elif results.beginning: + renderNoneFound() + else: + renderNoMore() + +proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] = + var retweets: seq[int64] + for thread in threads: + if thread.len == 1: + let tweet = thread[0] + let retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 + if retweetId in retweets or tweet.id in retweets or + tweet.pinned and prefs.hidePins: + continue + if retweetId != 0 and tweet.retweet.isSome: + retweets &= retweetId + result.add(thread) + proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = - buildHtml(tdiv(class="timeline")): + buildHtml(tdiv(class=results.query.timelineViewClass)): if not results.beginning: renderNewer(results.query, parseUri(path).path) if not prefs.hidePins and pinned.isSome: let tweet = get pinned - renderTweet(tweet, prefs, path, showThread=tweet.hasThread) + renderTweet(tweet, prefs, path) if results.content.len == 0: if not results.beginning: @@ -103,26 +221,24 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; else: renderNoneFound() else: - var retweets: seq[int64] + let filtered = filterThreads(results.content, prefs) - for thread in results.content: - if thread.len == 1: - let - tweet = thread[0] - retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 + if results.query.view == "gallery": + let bigThumb = prefs.gallerySize == "Large" + let galClass = if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry" + tdiv(class=galClass, `data-col-size`=prefs.gallerySize.toLowerAscii): + for thread in filtered: + if thread.len == 1: renderTweet(thread[0], prefs, path, bigThumb=bigThumb) + else: renderThread(thread, prefs, path, bigThumb) + else: + for thread in filtered: + if thread.len == 1: + renderTweet(thread[0], prefs, path) + else: renderThread(thread, prefs, path) - if retweetId in retweets or tweet.id in retweets or - tweet.pinned and prefs.hidePins: - continue - - var hasThread = tweet.hasThread - if retweetId != 0 and tweet.retweet.isSome: - retweets &= retweetId - hasThread = get(tweet.retweet).hasThread - renderTweet(tweet, prefs, path, showThread=hasThread) - else: - renderThread(thread, prefs, path) - - if results.bottom.len > 0: + var cursor = getSearchMaxId(results, path) + if cursor.len > 0: + renderMore(results.query, cursor) + elif results.bottom.len > 0: renderMore(results.query, results.bottom) renderToTop() diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 8ff8cb1..8971ab8 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -5,21 +5,39 @@ from jester import Request import renderutils import ".."/[types, utils, formatters] -import general const doctype = "\n" -proc renderMiniAvatar(user: User; prefs: Prefs): VNode = +proc renderMiniAvatar*(user: User; prefs: Prefs): VNode = genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini")) -proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode = +proc renderArticleCard(preview: ArticlePreview; prefs: Prefs): VNode = + let url = "/i/article/" & $preview.tweetId + buildHtml(tdiv(class="article-card card large")): + a(class="card-container", href=url): + if preview.coverImage.len > 0: + tdiv(class="card-image-container"): + tdiv(class="card-image"): + genImg(preview.coverImage) + span(class="article-card-badge"): text "Article" + tdiv(class="card-content-container"): + tdiv(class="card-content"): + h2(class="card-title"): text preview.title + if preview.previewText.len > 0: + p(class="card-description"): text preview.previewText + +proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs; + path = ""): VNode = buildHtml(tdiv): if pinned: + let pinnedLabel = + if "/i/communities/" in path: "Pinned by Community mods" + else: "Pinned Tweet" tdiv(class="pinned"): - span: icon "pin", "Pinned Tweet" + span: icon("pin", pinnedLabel) elif retweet.len > 0: tdiv(class="retweet-header"): - span: icon "retweet", retweet & " retweeted" + span: icon("retweet", retweet & " retweeted") tdiv(class="tweet-header"): a(class="tweet-avatar", href=("/" & tweet.user.username)): @@ -31,28 +49,28 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN tdiv(class="tweet-name-row"): tdiv(class="fullname-and-username"): linkUser(tweet.user, class="fullname") + verifiedIcon(tweet.user) linkUser(tweet.user, class="username") span(class="tweet-date"): a(href=getLink(tweet), title=tweet.getTime): text tweet.getShortTime -proc renderAlbum(tweet: Tweet): VNode = - let - groups = if tweet.photos.len < 3: @[tweet.photos] - else: tweet.photos.distribute(2) +proc renderAltText(altText: string): VNode = + buildHtml(p(class="alt-text")): + text "ALT " & altText - buildHtml(tdiv(class="attachments")): - for i, photos in groups: - let margin = if i > 0: ".25em" else: "" - tdiv(class="gallery-row", style={marginTop: margin}): - for photo in photos: - tdiv(class="attachment image"): - let - named = "name=" in photo - small = if named: photo else: photo & smallWebp - a(href=getOrigPicUrl(photo), class="still-image", target="_blank"): - genImg(small) +proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode = + buildHtml(tdiv(class="attachment")): + let + named = "name=" in photo.url + thumb = if named: photo.url + elif bigThumb: photo.url & mediumWebp + else: photo.url & smallWebp + a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"): + genImg(thumb, alt=photo.altText) + if photo.altText.len > 0: + renderAltText(photo.altText) proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = case playbackType @@ -62,11 +80,11 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = proc hasMp4Url(video: Video): bool = video.variants.anyIt(it.contentType == mp4) -proc renderVideoDisabled(playbackType: VideoType; path: string): VNode = +proc renderVideoDisabled(playbackType: VideoType; path=""): VNode = buildHtml(tdiv(class="video-overlay")): case playbackType of mp4: - p: text "mp4 playback disabled in preferences" + buttonReferer "/enablemp4", "Enable mp4 playback", path of m3u8, vmap: buttonReferer "/enablehls", "Enable hls playback", path @@ -78,51 +96,109 @@ proc renderVideoUnavailable(video: Video): VNode = else: p: text "This media is unavailable" -proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = +proc getVideoDownloadUrl(videoData: Video): string = + let mp4Vars = videoData.variants.filterIt(it.contentType == mp4) + if mp4Vars.len == 0: return "" + let best = mp4Vars.sortedByIt(it.bitrate)[^1].url + if best.startsWith("http"): getVidUrl(best) else: best + +proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode = let - container = if video.description.len == 0 and video.title.len == 0: "" - else: " card-container" - playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4 - else: video.playbackType + playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4 + else: videoData.playbackType + thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb) + + buildHtml(tdiv(class="attachment")): + if not videoData.available: + img(src=thumb, loading="lazy") + renderVideoUnavailable(videoData) + elif not prefs.isPlaybackEnabled(playbackType): + img(src=thumb, loading="lazy") + renderVideoDisabled(playbackType, path) + else: + let + vars = videoData.variants.filterIt(it.contentType == playbackType) + vidUrl = vars.sortedByIt(it.resolution)[^1].url + source = if prefs.proxyVideos and vidUrl.startsWith("http"): + getVidUrl(vidUrl) else: vidUrl + case playbackType + of mp4: + video(poster=thumb, controls="", muted=prefs.muteVideos): + source(src=source, `type`="video/mp4") + of m3u8, vmap: + video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) + verbatim "
" + tdiv(class="overlay-circle"): span(class="overlay-triangle") + if videoData.durationMs > 0: + tdiv(class="overlay-duration"): text getDuration(videoData) + verbatim "
" + if videoData.available: + let dlUrl = getVideoDownloadUrl(videoData) + if dlUrl.len > 0: + a(class="video-download", href=dlUrl, download="", + title="Download video"): icon "download-alt" + +proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode = + let hasCardContent = video.description.len > 0 or video.title.len > 0 buildHtml(tdiv(class="attachments card")): - tdiv(class="gallery-video" & container): - tdiv(class="attachment video-container"): - let thumb = getSmallPic(video.thumb) - if not video.available: - img(src=thumb, loading="lazy") - renderVideoUnavailable(video) - elif not prefs.isPlaybackEnabled(playbackType): - img(src=thumb, loading="lazy") - renderVideoDisabled(playbackType, path) - else: - let - vars = video.variants.filterIt(it.contentType == playbackType) - vidUrl = vars.sortedByIt(it.resolution)[^1].url - source = if prefs.proxyVideos: getVidUrl(vidUrl) - else: vidUrl - case playbackType - of mp4: - video(poster=thumb, controls="", muted=prefs.muteVideos): - source(src=source, `type`="video/mp4") - of m3u8, vmap: - video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) - verbatim "
" - tdiv(class="overlay-circle"): span(class="overlay-triangle") - verbatim "
" - if container.len > 0: + tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))): + renderVideoAttachment(video, prefs, path, bigThumb) + if hasCardContent: tdiv(class="card-content"): h2(class="card-title"): text video.title if video.description.len > 0: p(class="card-description"): text video.description -proc renderGif(gif: Gif; prefs: Prefs): VNode = +proc renderGifAttachment(gif: Gif; prefs: Prefs; path=""): VNode = + let thumb = getSmallPic(gif.thumb) + + buildHtml(tdiv(class="attachment")): + if not prefs.mp4Playback: + img(src=thumb, loading="lazy") + renderVideoDisabled(mp4, path) + elif prefs.autoplayGifs: + video(class="gif", poster=thumb, autoplay="", muted="", loop=""): + source(src=getPicUrl(gif.url), `type`="video/mp4") + else: + video(class="gif", poster=thumb, controls="", muted="", loop=""): + source(src=getPicUrl(gif.url), `type`="video/mp4") + if gif.altText.len > 0: + renderAltText(gif.altText) + +proc renderGif(gif: Gif; prefs: Prefs; path=""): VNode = buildHtml(tdiv(class="attachments media-gif")): - tdiv(class="gallery-gif", style={maxHeight: "unset"}): - tdiv(class="attachment"): - video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs, - controls="", muted="", loop=""): - source(src=getPicUrl(gif.url), `type`="video/mp4") + renderGifAttachment(gif, prefs, path) + +proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode = + if media.len == 0: + return nil + + if media.len == 1: + let item = media[0] + if item.kind == videoMedia: + return renderVideo(item.video, prefs, path, bigThumb) + if item.kind == gifMedia: + return renderGif(item.gif, prefs, path) + + let + groups = if media.len < 3: @[media] + else: media.distribute(2) + + buildHtml(tdiv(class="attachments")): + for i, mediaGroup in groups: + let margin = if i > 0: ".25em" else: "" + let rowClass = "gallery-row" & + (if mediaGroup.allIt(it.kind == photoMedia): "" else: " mixed-row") + tdiv(class=rowClass, style={marginTop: margin}): + for mediaItem in mediaGroup: + case mediaItem.kind + of photoMedia: + renderPhotoAttachment(mediaItem.photo, bigThumb) + of videoMedia: + renderVideoAttachment(mediaItem.video, prefs, path, bigThumb) + of gifMedia: + renderGifAttachment(mediaItem.gif, prefs, path) proc renderPoll(poll: Poll): VNode = buildHtml(tdiv(class="poll")): @@ -178,7 +254,7 @@ func formatStat(stat: int): string = if stat > 0: insertSep($stat, ',') else: "" -proc renderStats(stats: TweetStats): 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) @@ -192,8 +268,9 @@ proc renderReply(tweet: Tweet): VNode = if i > 0: text " " a(href=("/" & u)): text "@" & u -proc renderAttribution(user: User; prefs: Prefs): VNode = - buildHtml(a(class="attribution", href=("/" & user.username))): +proc renderAttribution(user: User; prefs: Prefs; link = ""): VNode = + let href = if link.len > 0: link else: "/" & user.username + buildHtml(a(class="attribution", href=href)): renderMiniAvatar(user, prefs) strong: text user.fullname verifiedIcon(user) @@ -207,19 +284,28 @@ proc renderMediaTags(tags: seq[User]): VNode = if i < tags.high: text ", " +proc renderLatestPost(username: string; id: int64): VNode = + buildHtml(tdiv(class="latest-post-version")): + text "There's a new version of this post. " + a(href=getLink(id, username)): + text "See the latest post" + +proc renderCommunityNote(note: string; prefs: Prefs): VNode = + buildHtml(tdiv(class="community-note")): + tdiv(class="community-note-header"): + icon "group" + span: text "Community note" + tdiv(class="community-note-text", dir="auto"): + verbatim replaceUrls(note, prefs) + proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = buildHtml(tdiv(class="quote-media-container")): - if quote.photos.len > 0: - renderAlbum(quote) - elif quote.video.isSome: - renderVideo(quote.video.get(), prefs, path) - elif quote.gif.isSome: - renderGif(quote.gif.get(), prefs) + renderMedia(quote.media, prefs, path) proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = if not quote.available: return buildHtml(tdiv(class="quote unavailable")): - tdiv(class="unavailable-quote"): + a(class="unavailable-quote", href=getLink(quote, focus=false)): if quote.tombstone.len > 0: text quote.tombstone elif quote.text.len > 0: @@ -234,6 +320,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="fullname-and-username"): renderMiniAvatar(quote.user, prefs) linkUser(quote.user, class="fullname") + verifiedIcon(quote.user) linkUser(quote.user, class="username") span(class="tweet-date"): @@ -247,12 +334,31 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="quote-text", dir="auto"): verbatim replaceUrls(quote.text, prefs) + if quote.media.len > 0: + renderQuoteMedia(quote, prefs, path) + + if quote.articlePreview.isSome: + renderArticleCard(quote.articlePreview.get(), prefs) + + if quote.note.len > 0 and not prefs.hideCommunityNotes: + renderCommunityNote(quote.note, prefs) + if quote.hasThread: a(class="show-thread", href=getLink(quote)): text "Show this thread" - if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: - renderQuoteMedia(quote, prefs, path) + if quote.history.len > 0 and quote.id != max(quote.history): + tdiv(class="quote-latest"): + text "There's a new version of this post" + +proc renderDisclosures*(tweet: Tweet): VNode = + buildHtml(tdiv(class="disclosures")): + if tweet.isAI: + span(data-disclosure="ai"): + icon "attention-circled", "Made with AI" + if tweet.isAd: + span(data-disclosure="ad"): + icon "attention-circled", "Paid partnership (ad)" proc renderLocation*(tweet: Tweet): string = let (place, url) = tweet.getLocation() @@ -266,14 +372,15 @@ proc renderLocation*(tweet: Tweet): string = return $node proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; - last=false; showThread=false; mainTweet=false; afterTweet=false): VNode = + last=false; mainTweet=false; afterTweet=false; + bigThumb=false): VNode = var divClass = class if index == -1 or last: divClass = "thread-last " & class if not tweet.available: - return buildHtml(tdiv(class=divClass & "unavailable timeline-item")): - tdiv(class="unavailable-box"): + return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)): + a(class="unavailable-box", href=getLink(tweet)): if tweet.tombstone.len > 0: text tweet.tombstone elif tweet.text.len > 0: @@ -294,15 +401,15 @@ 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"): - renderHeader(tweet, retweet, pinned, prefs) + renderHeader(tweet, retweet, pinned, prefs, path) if not afterTweet and index == 0 and tweet.reply.len > 0 and - (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username): + (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned): renderReply(tweet) var tweetClass = "tweet-content media-body" @@ -313,17 +420,16 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet) if tweet.attribution.isSome: - renderAttribution(tweet.attribution.get(), prefs) + renderAttribution(tweet.attribution.get(), prefs, tweet.attributionLink) if tweet.card.isSome and tweet.card.get().kind != hidden: renderCard(tweet.card.get(), prefs, path) - if tweet.photos.len > 0: - renderAlbum(tweet) - elif tweet.video.isSome: - renderVideo(tweet.video.get(), prefs, path) - elif tweet.gif.isSome: - renderGif(tweet.gif.get(), prefs) + if tweet.articlePreview.isSome: + renderArticleCard(tweet.articlePreview.get(), prefs) + + if tweet.media.len > 0: + renderMedia(tweet.media, prefs, path, bigThumb) if tweet.poll.isSome: renderPoll(tweet.poll.get()) @@ -331,25 +437,29 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.quote.isSome: renderQuote(tweet.quote.get(), prefs, path) + if tweet.note.len > 0 and not prefs.hideCommunityNotes: + renderCommunityNote(tweet.note, prefs) + + if tweet.isAI or tweet.isAd: + renderDisclosures(tweet) + + let + hasEdits = tweet.history.len > 1 + isLatest = hasEdits and tweet.id == max(tweet.history) + if mainTweet: - p(class="tweet-published"): text &"{getTime(tweet)}" + p(class="tweet-published"): + if hasEdits and isLatest: + a(href=(getLink(tweet, focus=false) & "/history")): + text &"Last edited {getTime(tweet)}" + else: + text &"{getTime(tweet)}" + + if hasEdits and not isLatest: + renderLatestPost(tweet.user.username, max(tweet.history)) if tweet.mediaTags.len > 0: renderMediaTags(tweet.mediaTags) if not prefs.hideTweetStats: renderStats(tweet.stats) - - if showThread: - a(class="show-thread", href=("/i/status/" & $tweet.threadId)): - text "Show this thread" - -proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = - let node = buildHtml(html(lang="en")): - renderHead(prefs, cfg, req) - - body: - tdiv(class="tweet-embed"): - renderTweet(tweet, prefs, path, mainTweet=true) - - result = doctype & $node diff --git a/tests/base.py b/tests/base.py index 010dfbb..841094d 100644 --- a/tests/base.py +++ b/tests/base.py @@ -54,6 +54,18 @@ class Timeline(object): none = '.timeline-none' protected = '.timeline-protected' photo_rail = '.photo-rail-grid' + media_view_tabs = '.media-view-tabs' + media_view_timeline = '.media-view-tabs a[href*="view=timeline"]' + media_view_grid = '.media-view-tabs a[href*="view=grid"]' + media_view_gallery = '.media-view-tabs a[href*="view=gallery"]' + media_view_active = '.media-view-tabs .tab-item.active a' + grid_view = '.timeline.media-grid-view' + gallery_view = '.timeline.media-gallery-view' + + +class Search(object): + tab_item = '.tab .tab-item' + tab_active = '.tab .tab-item.active a' class Conversation(object): @@ -64,6 +76,8 @@ class Conversation(object): thread = '.reply' tweet = '.timeline-item' tweet_text = '.tweet-content' + reply_sort = '.reply-sort' + reply_sort_active = '.reply-sort-option.active' class Poll(object): @@ -79,7 +93,7 @@ class Media(object): row = '.gallery-row' image = '.still-image' video = '.gallery-video' - gif = '.gallery-gif' + gif = '.media-gif' class BaseTestCase(BaseCase): diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d87c74 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +from seleniumbase.config import settings + +settings.SKIP_JS_WAITS = True +settings.WAIT_FOR_RSC_ON_PAGE_LOADS = False diff --git a/tests/poetry.lock b/tests/poetry.lock new file mode 100644 index 0000000..d13bfe0 --- /dev/null +++ b/tests/poetry.lock @@ -0,0 +1,1716 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "async-generator" +version = "1.10" +description = "Async generators and context managers for Python 3.5+" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, + {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, +] + +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.7.0" +groups = ["main"] +files = [ + {file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"}, + {file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"}, +] + +[package.dependencies] +soupsieve = ">=1.6.1" +typing-extensions = ">=4.0.0" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "behave" +version = "1.2.6" +description = "behave is behaviour-driven development, Python style" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c"}, + {file = "behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86"}, +] + +[package.dependencies] +parse = ">=1.8.2" +parse-type = ">=0.4.2" +six = ">=1.11" + +[package.extras] +develop = ["coverage", "invoke (>=0.21.0)", "modernize (>=0.5)", "path.py (>=8.1.2)", "pathlib", "pycmd", "pylint", "pytest (>=3.0)", "pytest-cov", "tox"] +docs = ["sphinx (>=1.6)", "sphinx-bootstrap-theme (>=0.6)"] + +[[package]] +name = "certifi" +version = "2026.1.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, +] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "os_name == \"nt\" and implementation_name != \"pypy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cssselect" +version = "1.4.0" +description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8"}, + {file = "cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "execnet" +version = "2.1.2" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, + {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + +[[package]] +name = "fasteners" +version = "0.20" +description = "A python package that provides useful locks" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "fasteners-0.20-py3-none-any.whl", hash = "sha256:9422c40d1e350e4259f509fb2e608d6bc43c0136f79a00db1b49046029d0b3b7"}, + {file = "fasteners-0.20.tar.gz", hash = "sha256:55dce8792a41b56f727ba6e123fcaee77fd87e638a6863cec00007bfea84c8d8"}, +] + +[[package]] +name = "filelock" +version = "3.24.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d"}, + {file = "filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa"}, +] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mouseinfo" +version = "0.1.3" +description = "An application to display XY position and RGB color information for the pixel currently under the mouse. Works on Python 2 and 3." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "MouseInfo-0.1.3.tar.gz", hash = "sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7"}, +] + +[package.dependencies] +pyperclip = "*" +python3-Xlib = {version = "*", markers = "platform_system == \"Linux\" and python_version >= \"3.0\""} + +[[package]] +name = "mycdp" +version = "1.3.2" +description = "Autogenerated CDP utilities for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mycdp-1.3.2-py3-none-any.whl", hash = "sha256:d097b12a494223b89a666c87915d8acd48d08b92770ff9f5955ab764676790a0"}, + {file = "mycdp-1.3.2.tar.gz", hash = "sha256:945c405eb35d9759bd24c3676b4633124fac222ac132f735e9d2d812b49f1b3d"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +description = "Patch asyncio to allow nested event loops" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, + {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +description = "Capture the outcome of Python function calls." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, + {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, +] + +[package.dependencies] +attrs = ">=19.2.0" + +[[package]] +name = "packaging" +version = "26.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "parameterized" +version = "0.9.0" +description = "Parameterized testing with any Python test framework" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, + {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, +] + +[package.extras] +dev = ["jinja2"] + +[[package]] +name = "parse" +version = "1.21.1" +description = "parse() is the opposite of format()" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "parse-1.21.1-py2.py3-none-any.whl", hash = "sha256:55339ca698019815df3b8e8b550e5933933527e623b0cdf1ca2f404da35ffb47"}, + {file = "parse-1.21.1.tar.gz", hash = "sha256:825e1a88e9d9fb481b8d2ca709c6195558b6eaa97c559ad3a9a20aa2d12815a3"}, +] + +[[package]] +name = "parse-type" +version = "0.6.6" +description = "Simplifies to build parse types based on the parse module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,>=2.7" +groups = ["main"] +files = [ + {file = "parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c"}, + {file = "parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2"}, +] + +[package.dependencies] +parse = {version = ">=1.18.0", markers = "python_version >= \"3.0\""} +six = ">=1.15" + +[package.extras] +develop = ["build (>=0.5.1)", "coverage (>=4.4)", "pylint", "pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-cov", "pytest-html (>=1.19.0)", "ruff ; python_version >= \"3.7\"", "setuptools", "setuptools-scm", "tox (>=2.8,<4.0)", "twine (>=1.13.0)", "virtualenv (<20.22.0) ; python_version <= \"3.6\"", "virtualenv (>=20.0.0) ; python_version > \"3.6\"", "wheel"] +docs = ["Sphinx (>=1.6)", "sphinx_bootstrap_theme (>=0.6.0)"] +testing = ["pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-html (>=1.19.0)"] + +[[package]] +name = "pdbp" +version = "1.8.2" +description = "pdbp (Pdb+): A drop-in replacement for pdb and pdbpp." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pdbp-1.8.2-py3-none-any.whl", hash = "sha256:d4fd05e177636b5ccd0b2e03e378cec57afc06149e5fd975de6f8ddb3d0109a8"}, + {file = "pdbp-1.8.2.tar.gz", hash = "sha256:367c25c17555d3ac1f024b9ad494ff50e6e20f6494a84741487f3e6596d88f94"}, +] + +[package.dependencies] +colorama = {version = ">=0.4.6", markers = "platform_system == \"Windows\""} +pygments = ">=2.19.2" +tabcompleter = ">=1.4.0" + +[[package]] +name = "pip" +version = "26.0.1" +description = "The PyPA recommended tool for installing Python packages." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b"}, + {file = "pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8"}, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd"}, + {file = "platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pyautogui" +version = "0.9.54" +description = "PyAutoGUI lets Python control the mouse and keyboard, and other GUI automation tasks. For Windows, macOS, and Linux, on Python 3 and 2." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "PyAutoGUI-0.9.54.tar.gz", hash = "sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2"}, +] + +[package.dependencies] +mouseinfo = "*" +pygetwindow = ">=0.0.5" +pymsgbox = "*" +pyscreeze = ">=0.1.21" +python3-Xlib = {version = "*", markers = "platform_system == \"Linux\" and python_version >= \"3.0\""} +pytweening = ">=1.0.4" + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "os_name == \"nt\" and implementation_name != \"pypy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + +[[package]] +name = "pygetwindow" +version = "0.0.9" +description = "A simple, cross-platform module for obtaining GUI information on application's windows." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "PyGetWindow-0.0.9.tar.gz", hash = "sha256:17894355e7d2b305cd832d717708384017c1698a90ce24f6f7fbf0242dd0a688"}, +] + +[package.dependencies] +pyrect = "*" + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pymsgbox" +version = "2.0.1" +description = "A simple, cross-platform, pure Python module for JavaScript-like message boxes." +optional = false +python-versions = ">=3.4" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "pymsgbox-2.0.1-py3-none-any.whl", hash = "sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b"}, + {file = "pymsgbox-2.0.1.tar.gz", hash = "sha256:98d055c49a511dcc10fa08c3043e7102d468f5e4b3a83c6d3c61df722c7d798d"}, +] + +[[package]] +name = "pynose" +version = "1.5.5" +description = "pynose fixes nose to extend unittest and make testing easier" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pynose-1.5.5-py3-none-any.whl", hash = "sha256:673751d53fcfc79b1e48c14f36c7a24779ad43676eeb85736934de6a2b3d8ec8"}, + {file = "pynose-1.5.5.tar.gz", hash = "sha256:81da4e26473f98dd37497248eef4352d3221d1d56edf874a00c6bdda6daf7f49"}, +] + +[[package]] +name = "pyotp" +version = "2.9.0" +description = "Python One Time Password Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612"}, + {file = "pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63"}, +] + +[package.extras] +test = ["coverage", "mypy", "ruff", "wheel"] + +[[package]] +name = "pyperclip" +version = "1.11.0" +description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273"}, + {file = "pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6"}, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +description = "A python implementation of GNU readline." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"}, + {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"}, +] + +[package.extras] +dev = ["build", "flake8", "mypy", "pytest", "twine"] + +[[package]] +name = "pyrect" +version = "0.2.0" +description = "PyRect is a simple module with a Rect class for Pygame-like rectangular areas." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "PyRect-0.2.0.tar.gz", hash = "sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78"}, +] + +[[package]] +name = "pyscreeze" +version = "1.0.1" +description = "A simple, cross-platform screenshot module for Python 2 and 3." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "pyscreeze-1.0.1.tar.gz", hash = "sha256:cf1662710f1b46aa5ff229ee23f367da9e20af4a78e6e365bee973cad0ead4be"}, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-html" +version = "4.0.2" +description = "pytest plugin for generating HTML reports" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pytest_html-4.0.2-py3-none-any.whl", hash = "sha256:907c3e68462df129d3ee96dee58bd63f70216b06421836b22fd3fd57ef314acb"}, + {file = "pytest_html-4.0.2.tar.gz", hash = "sha256:88682b9e8e51392472546a70a2139b27d6bc1834a4afd3e41da33c9d9f91e4a4"}, +] + +[package.dependencies] +jinja2 = ">=3.0.0" +pytest = ">=7.0.0" +pytest-metadata = ">=2.0.0" + +[package.extras] +docs = ["pip-tools (>=6.13.0)"] +test = ["assertpy (>=1.1)", "beautifulsoup4 (>=4.11.1)", "black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest-mock (>=3.7.0)", "pytest-rerunfailures (>=11.1.2)", "pytest-xdist (>=2.4.0)", "selenium (>=4.3.0)", "tox (>=3.24.5)"] + +[[package]] +name = "pytest-metadata" +version = "3.1.1" +description = "pytest plugin for test session metadata" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b"}, + {file = "pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "tox (>=3.24.5)"] + +[[package]] +name = "pytest-ordering" +version = "0.6" +description = "pytest plugin to run your tests in a specific order" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytest-ordering-0.6.tar.gz", hash = "sha256:561ad653626bb171da78e682f6d39ac33bb13b3e272d406cd555adb6b006bda6"}, + {file = "pytest_ordering-0.6-py2-none-any.whl", hash = "sha256:27fba3fc265f5d0f8597e7557885662c1bdc1969497cd58aff6ed21c3b617de2"}, + {file = "pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6"}, +] + +[package.dependencies] +pytest = "*" + +[[package]] +name = "pytest-rerunfailures" +version = "16.1" +description = "pytest plugin to re-run tests to eliminate flaky failures" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86"}, + {file = "pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e"}, +] + +[package.dependencies] +packaging = ">=17.1" +pytest = ">=7.4,<8.2.2 || >8.2.2" + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, + {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + +[[package]] +name = "python-xlib" +version = "0.33" +description = "Python X Library" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32"}, + {file = "python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398"}, +] + +[package.dependencies] +six = ">=1.10.0" + +[[package]] +name = "python3-xlib" +version = "0.15" +description = "Python3 X Library" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "python3-xlib-0.15.tar.gz", hash = "sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8"}, +] + +[[package]] +name = "pytweening" +version = "1.2.0" +description = "A collection of tweening (aka easing) functions." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "14.3.2" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"}, + {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "sbvirtualdisplay" +version = "1.4.0" +description = "A customized pyvirtualdisplay for SeleniumBase." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "sbvirtualdisplay-1.4.0-py3-none-any.whl", hash = "sha256:516de155219aa342c4e090a3c5126cfe6b12416334bcba3255268e44a5e8a206"}, + {file = "sbvirtualdisplay-1.4.0.tar.gz", hash = "sha256:29a365b509cd7bfde4f758603b7b75703909b11cdf4245abc8f828ed35660d9b"}, +] + +[package.extras] +coverage = ["coverage (>=7.6.1) ; python_version < \"3.9\"", "coverage (>=7.6.9) ; python_version >= \"3.9\"", "pytest-cov (>=5.0.0) ; python_version < \"3.9\"", "pytest-cov (>=6.0.0) ; python_version >= \"3.9\""] +flake8 = ["flake8 (==5.0.4) ; python_version < \"3.9\"", "flake8 (==7.1.1) ; python_version >= \"3.9\"", "mccabe (==0.7.0)", "pycodestyle (==2.12.1) ; python_version >= \"3.9\"", "pycodestyle (==2.9.1) ; python_version < \"3.9\"", "pyflakes (==2.5.0) ; python_version < \"3.9\"", "pyflakes (==3.2.0) ; python_version >= \"3.9\""] + +[[package]] +name = "selenium" +version = "4.40.0" +description = "Official Python bindings for Selenium WebDriver" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "selenium-4.40.0-py3-none-any.whl", hash = "sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729"}, + {file = "selenium-4.40.0.tar.gz", hash = "sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c"}, +] + +[package.dependencies] +certifi = ">=2026.1.4" +trio = ">=0.31.0,<1.0" +trio-typing = ">=0.10.0" +trio-websocket = ">=0.12.2,<1.0" +types-certifi = ">=2021.10.8.3" +types-urllib3 = ">=1.26.25.14" +typing_extensions = ">=4.15.0,<5.0" +urllib3 = {version = ">=2.6.3,<3.0", extras = ["socks"]} +websocket-client = ">=1.8.0,<2.0" + +[[package]] +name = "seleniumbase" +version = "4.46.5" +description = "A complete web automation framework for end-to-end testing." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "seleniumbase-4.46.5-py3-none-any.whl", hash = "sha256:d87ca08ed642c2ed5ddb0be74259f570ecdc443e0a99ea0076abde0d013c8586"}, + {file = "seleniumbase-4.46.5.tar.gz", hash = "sha256:a1e22217874da901d361ce7577bf8d4eda990563d3dd2cfada612663380036c6"}, +] + +[package.dependencies] +attrs = ">=25.4.0" +beautifulsoup4 = ">=4.14.3,<4.15.0" +behave = "1.2.6" +certifi = ">=2026.1.4" +chardet = "5.2.0" +charset-normalizer = ">=3.4.4,<4" +colorama = ">=0.4.6" +cssselect = {version = ">=1.4.0,<2", markers = "python_version >= \"3.10\""} +exceptiongroup = ">=1.3.1" +execnet = {version = "2.1.2", markers = "python_version >= \"3.10\""} +fasteners = ">=0.20" +filelock = {version = ">=3.20.3", markers = "python_version >= \"3.10\""} +h11 = "0.16.0" +idna = ">=3.11" +iniconfig = {version = "2.3.0", markers = "python_version >= \"3.10\""} +Jinja2 = ">=3.1.6" +markdown-it-py = {version = "4.0.0", markers = "python_version >= \"3.10\""} +MarkupSafe = ">=3.0.3" +mdurl = "0.1.2" +mycdp = ">=1.3.2" +nest-asyncio = "1.6.0" +outcome = "1.3.0.post0" +packaging = ">=26.0" +parameterized = "0.9.0" +parse = ">=1.21.0" +parse-type = ">=0.6.6" +pdbp = ">=1.8.2" +pip = ">=26.0.1" +platformdirs = {version = ">=4.5.1", markers = "python_version >= \"3.10\""} +pluggy = "1.6.0" +PyAutoGUI = {version = ">=0.9.54", markers = "platform_system == \"Linux\""} +pygments = ">=2.19.2" +pynose = ">=1.5.5" +pyotp = "2.9.0" +pyreadline3 = {version = ">=3.5.4", markers = "platform_system == \"Windows\""} +pytest = {version = "9.0.2", markers = "python_version >= \"3.11\""} +pytest-html = "4.0.2" +pytest-metadata = "3.1.1" +pytest-ordering = "0.6" +pytest-rerunfailures = {version = "16.1", markers = "python_version >= \"3.10\""} +pytest-xdist = "3.8.0" +python-xlib = {version = "0.33", markers = "platform_system == \"Linux\""} +pyyaml = ">=6.0.3" +requests = ">=2.32.5,<2.33.0" +rich = ">=14.3.2,<15" +sbvirtualdisplay = ">=1.4.0" +selenium = {version = "4.40.0", markers = "python_version >= \"3.10\""} +setuptools = {version = ">=82.0.0", markers = "python_version >= \"3.10\""} +six = ">=1.17.0" +sniffio = "1.3.1" +sortedcontainers = "2.4.0" +soupsieve = ">=2.8.3,<2.9.0" +tabcompleter = ">=1.4.0" +trio = {version = ">=0.32.0,<1", markers = "python_version >= \"3.10\""} +trio-websocket = ">=0.12.2,<0.13.0" +typing-extensions = ">=4.15.0" +urllib3 = {version = ">=1.26.20,<3", markers = "python_version >= \"3.10\""} +websocket-client = ">=1.9.0,<1.10.0" +websockets = {version = ">=16.0", markers = "python_version >= \"3.10\""} +wheel = ">=0.46.3" +wsproto = {version = ">=1.3.2,<1.4.0", markers = "python_version >= \"3.10\""} + +[package.extras] +allure = ["allure-behave (>=2.13.5)", "allure-pytest (>=2.13.5)", "allure-python-commons (>=2.13.5)"] +coverage = ["coverage (>=7.10.7) ; python_version < \"3.10\"", "coverage (>=7.13.4) ; python_version >= \"3.10\"", "pytest-cov (>=7.0.0)"] +flake8 = ["flake8 (==7.3.0)", "mccabe (==0.7.0)", "pycodestyle (==2.14.0)", "pyflakes (==3.4.0)"] +ipdb = ["ipdb (==0.13.13)", "ipython (==7.34.0)"] +mss = ["mss (==10.1.0)"] +pdfminer = ["cffi (==2.0.0)", "cryptography (==46.0.5)", "pdfminer.six (==20251107) ; python_version < \"3.10\"", "pdfminer.six (==20260107) ; python_version >= \"3.10\"", "pycparser (==2.23) ; python_version < \"3.10\"", "pycparser (==3.0) ; python_version >= \"3.10\""] +pillow = ["Pillow (>=11.3.0) ; python_version < \"3.10\"", "Pillow (>=12.1.1) ; python_version >= \"3.10\""] +pip-system-certs = ["pip-system-certs (==4.0) ; platform_system == \"Windows\""] +playwright = ["playwright (>=1.58.0)"] +proxy = ["proxy.py (==2.4.3)"] +psutil = ["psutil (>=7.2.2)"] +pyautogui = ["PyAutoGUI (>=0.9.54) ; platform_system != \"Linux\""] +selenium-stealth = ["selenium-stealth (==1.0.6)"] +selenium-wire = ["Brotli (==1.1.0)", "blinker (==1.7.0)", "h2 (==4.1.0)", "hpack (==4.0.0)", "hyperframe (==6.0.1)", "kaitaistruct (==0.10)", "pyOpenSSL (>=24.2.1)", "pyasn1 (==0.6.1)", "pyparsing (>=3.1.4)", "selenium-wire (==5.1.0)", "zstandard (>=0.23.0)"] + +[[package]] +name = "setuptools" +version = "82.0.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0"}, + {file = "setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"}, + {file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"}, +] + +[[package]] +name = "tabcompleter" +version = "1.4.0" +description = "tabcompleter --- Autocompletion in the Python console." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tabcompleter-1.4.0-py3-none-any.whl", hash = "sha256:d744aa735b49c0a6cc2fb8fcd40077fec47425e4388301010b14e6ce3311368b"}, + {file = "tabcompleter-1.4.0.tar.gz", hash = "sha256:7562a9938e62f8e7c3be612c3ac4e14c5ec4307b58ba9031c148260e866e8814"}, +] + +[package.dependencies] +pyreadline3 = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "trio" +version = "0.33.0" +description = "A friendly Python library for async concurrency and I/O" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b"}, + {file = "trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"}, +] + +[package.dependencies] +attrs = ">=23.2.0" +cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} +idna = "*" +outcome = "*" +sniffio = ">=1.3.0" +sortedcontainers = "*" + +[[package]] +name = "trio-typing" +version = "0.10.0" +description = "Static type checking support for Trio and related projects" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "trio-typing-0.10.0.tar.gz", hash = "sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3"}, + {file = "trio_typing-0.10.0-py3-none-any.whl", hash = "sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264"}, +] + +[package.dependencies] +async-generator = "*" +importlib-metadata = "*" +mypy-extensions = ">=0.4.2" +packaging = "*" +trio = ">=0.16.0" +typing-extensions = ">=3.7.4" + +[package.extras] +mypy = ["mypy (>=1.0)"] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +description = "WebSocket library for Trio" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"}, + {file = "trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae"}, +] + +[package.dependencies] +outcome = ">=1.2.0" +trio = ">=0.11" +wsproto = ">=0.14" + +[[package]] +name = "types-certifi" +version = "2021.10.8.3" +description = "Typing stubs for certifi" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f"}, + {file = "types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a"}, +] + +[[package]] +name = "types-urllib3" +version = "1.26.25.14" +description = "Typing stubs for urllib3" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f"}, + {file = "types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, +] + +[package.dependencies] +pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "websocket-client" +version = "1.9.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"}, + {file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["pytest", "websockets"] + +[[package]] +name = "websockets" +version = "16.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, +] + +[[package]] +name = "wheel" +version = "0.46.3" +description = "Command line tool for manipulating wheel files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d"}, + {file = "wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803"}, +] + +[package.dependencies] +packaging = ">=24.0" + +[package.extras] +test = ["pytest (>=6.0.0)", "setuptools (>=77)"] + +[[package]] +name = "wsproto" +version = "1.3.2" +description = "Pure-Python WebSocket protocol implementation" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"}, + {file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"}, +] + +[package.dependencies] +h11 = ">=0.16.0,<1" + +[[package]] +name = "zipp" +version = "3.23.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.14" +content-hash = "11e7820330aef0a91b8b1b35791aaa335cf1352f21316f2dedaf5439afe474ac" diff --git a/tests/poetry.toml b/tests/poetry.toml new file mode 100644 index 0000000..ab1033b --- /dev/null +++ b/tests/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000..1907e60 --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,11 @@ +[tool.poetry] +name = "nitter-tests" +version = "0.0.0" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.14" +seleniumbase = "4.46.5" + +[tool.pytest.ini_options] +addopts = "--pls=eager --rcs --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --only-rerun=WebDriverException --timeout_multiplier=5" diff --git a/tests/requirements.txt b/tests/requirements.txt index 56ea4c0..e47d1cc 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1 +1 @@ -seleniumbase +seleniumbase==4.46.5 diff --git a/tests/test_about_account.py b/tests/test_about_account.py new file mode 100644 index 0000000..2239d9d --- /dev/null +++ b/tests/test_about_account.py @@ -0,0 +1,76 @@ +from base import BaseTestCase, Profile +from parameterized import parameterized + + +class AboutAccount(object): + header = '.about-account-header' + name = '.about-account-name' + body = '.about-account-body' + row = '.about-account-row' + label = '.about-account-label' + value = '.about-account-value' + + +# (username, expected_labels) +# Each label is checked for presence in the page text +about_data = [ + ['jack', ['Date joined', 'Account based in', 'Connected via']], + ['NASA', ['Date joined']], + ['elonmusk', ['Date joined']], +] + +about_verified = [ + ['jack', 'Verified', 'Since '], +] + +about_affiliate = [ + ['jack', 'An affiliate of', 'Square'], + ['elonmusk', 'An affiliate of', 'X'], +] + + +class AboutAccountTest(BaseTestCase): + @parameterized.expand(about_data) + def test_about_page_has_labels(self, username, expected_labels): + """About page shows expected info labels""" + self.open_nitter(f'{username}/about') + self.assert_element_visible(AboutAccount.header) + self.assert_element_visible(AboutAccount.body) + for label in expected_labels: + self.assert_text(label, AboutAccount.body) + + @parameterized.expand(about_verified) + def test_about_verified(self, username, label, value_prefix): + """About page shows verification info for verified accounts""" + self.open_nitter(f'{username}/about') + self.assert_text(label, AboutAccount.body) + self.assert_text(value_prefix, AboutAccount.body) + + @parameterized.expand(about_affiliate) + def test_about_affiliate(self, username, label, affiliate): + """About page shows affiliate info""" + self.open_nitter(f'{username}/about') + self.assert_text(label, AboutAccount.body) + self.assert_text(f'@{affiliate}', AboutAccount.body) + + def test_about_page_title(self): + """Title contains account name""" + self.open_nitter('jack/about') + self.assert_text('jack', AboutAccount.name) + + def test_about_join_date(self): + """About page always shows join date""" + self.open_nitter('jack/about') + self.assert_text('Date joined', AboutAccount.body) + self.assert_text('March 2006', AboutAccount.body) + + def test_about_invalid_user(self): + """About page for non-existent user shows error""" + self.open_nitter('thisprofiledoesntexist/about') + self.assert_text('User "thisprofiledoesntexist" not found') + + def test_joindate_links_to_about(self): + """Join date on profile page links to about page""" + self.open_nitter('jack') + link = self.find_element(Profile.joinDate + ' a') + self.assertIn('/jack/about', link.get_attribute('href')) diff --git a/tests/test_article.py b/tests/test_article.py new file mode 100644 index 0000000..287b948 --- /dev/null +++ b/tests/test_article.py @@ -0,0 +1,327 @@ +from base import BaseTestCase +from parameterized import parameterized + + +class ArticleSelectors: + page = '.article-page' + cover = '.article-cover' + body = '.article-body' + title = '.article-title' + author = '.article-author' + fullname = '.article-author .fullname' + username = '.article-author .username' + date = '.article-author .article-date' + avatar = '.article-author img.avatar' + verified = '.article-author .verified-icon' + media = '.article-media' + caption = '.article-media-caption' + divider = '.article-divider' + + +articles = [ + ['2064166507438059759', + '1s,秒杀一切,开源一个 X 文章发布 Skill【重磅升级】', + 'punk2898', 'Punk'], + + ['2064689664213041529', + 'SpaceX Thesis & Valuation Memorandum', + 'Dialectic_Group', 'Dialectic'], + + ['2064691088636424322', + 'Consciousness and AI: The Problem of Inner Experience', + 'CosmicOrFun', 'Cosmic Orphan'], + + ['2064755789391110154', + 'DeFi Markets Update 2026-06-10', + 'SteakhouseFi', 'Steakhouse Financial'], + + ['2064755231901319527', + 'The machine economy has a killswitch and somebody just pulled it.', + '1914ad', 'Justin Bechler HMP-028'], + + ['2062858677149675788', + 'Yakshinis', + 'CosmicOrFun', 'Cosmic Orphan'], +] + +articles_with_media = [ + ['2064166507438059759', 6], + ['2064689664213041529', 11], + ['2064755789391110154', 5], +] + +articles_with_dividers = [ + ['2064166507438059759', 1], + ['2064689664213041529', 6], +] + + +class ArticleBasicTest(BaseTestCase): + @parameterized.expand(articles) + def test_article_loads(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + self.assert_element_visible(ArticleSelectors.page) + self.assert_element_visible(ArticleSelectors.body) + self.assert_text(title, ArticleSelectors.title) + + @parameterized.expand(articles) + def test_article_author(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + self.assert_element_visible(ArticleSelectors.author) + self.assert_text(f'@{username}', ArticleSelectors.username) + + @parameterized.expand(articles) + def test_article_has_cover(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + self.assert_element_visible(ArticleSelectors.cover) + src = self.get_attribute(ArticleSelectors.cover, 'src') + self.assertIn('/pic/', src) + + @parameterized.expand(articles) + def test_article_has_date(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + date_text = self.get_text(ArticleSelectors.date) + self.assertTrue(len(date_text) > 3) + + @parameterized.expand(articles) + def test_article_author_avatar(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + self.assert_element_visible(ArticleSelectors.avatar) + src = self.get_attribute(ArticleSelectors.avatar, 'src') + self.assertIn('/pic/', src) + self.assertGreater(len(src), len('/pic/')) + + @parameterized.expand(articles) + def test_article_author_verified(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + self.assert_element_visible(ArticleSelectors.verified) + + def test_article_author_verified_business(self): + self.open_nitter('i/article/2064755789391110154') + self.assert_element_visible('.article-author .verified-icon.business') + + +class ArticleContentTest(BaseTestCase): + def test_article_has_paragraphs(self): + self.open_nitter('i/article/2064689664213041529') + paragraphs = self.find_elements('.article-body p') + self.assertGreater(len(paragraphs), 10) + + def test_article_has_headers(self): + self.open_nitter('i/article/2064689664213041529') + headers = self.find_elements('.article-body h1, .article-body h2') + self.assertGreater(len(headers), 5) + + def test_article_has_bold_text(self): + self.open_nitter('i/article/2064166507438059759') + bold = self.find_elements('.article-body strong') + self.assertGreater(len(bold), 0) + + def test_article_has_italic_text(self): + self.open_nitter('i/article/2064166507438059759') + italic = self.find_elements('.article-body em') + self.assertGreater(len(italic), 0) + + def test_article_has_blockquotes(self): + self.open_nitter('i/article/2064166507438059759') + self.assert_element_visible('.article-body blockquote') + + def test_article_has_lists(self): + self.open_nitter('i/article/2064166507438059759') + self.assert_element_visible('.article-body ul') + + def test_article_has_emoji_text(self): + self.open_nitter('i/article/2064166507438059759') + body = self.get_text(ArticleSelectors.body) + self.assertTrue(any(ord(c) > 0x1F000 for c in body)) + + def test_article_has_links(self): + self.open_nitter('i/article/2064691088636424322') + links = self.find_elements('.article-body a[href]') + self.assertGreater(len(links), 0) + + def test_article_twitter_links_localized(self): + self.open_nitter('i/article/2064755789391110154') + links = self.find_elements('.article-body a[href^="https://x.com"]') + self.assertEqual(len(links), 0, 'x.com links should be converted to local paths') + + @parameterized.expand(articles_with_media) + def test_article_media_count(self, tweet_id, expected_count): + self.open_nitter(f'i/article/{tweet_id}') + media = self.find_elements(ArticleSelectors.media) + self.assertEqual(len(media), expected_count) + + @parameterized.expand(articles_with_dividers) + def test_article_divider_count(self, tweet_id, expected_count): + self.open_nitter(f'i/article/{tweet_id}') + dividers = self.find_elements(ArticleSelectors.divider) + self.assertEqual(len(dividers), expected_count) + + +class ArticleMediaTest(BaseTestCase): + def test_media_images_proxied(self): + self.open_nitter('i/article/2064689664213041529') + self.assert_element_visible(ArticleSelectors.media) + img = self.find_element(f'{ArticleSelectors.media} img') + src = img.get_attribute('src') + self.assertIn('/pic/', src) + self.assertFalse(src.startswith('https://pbs.twimg.com')) + + def test_cover_image_proxied(self): + self.open_nitter('i/article/2064689664213041529') + self.assert_element_visible(ArticleSelectors.cover) + src = self.get_attribute(ArticleSelectors.cover, 'src') + self.assertIn('/pic/', src) + self.assertFalse(src.startswith('https://pbs.twimg.com')) + + def test_embedded_tweet(self): + self.open_nitter('i/article/2064755789391110154') + self.assert_element_visible('.article-body .timeline-item') + + def test_multiple_embedded_tweets(self): + self.open_nitter('i/article/2064755231901319527') + tweets = self.find_elements('.article-body .timeline-item') + self.assertGreaterEqual(len(tweets), 3) + + def test_media_caption_displayed(self): + self.open_nitter('i/article/2064689664213041529') + self.assert_element_visible(ArticleSelectors.caption) + captions = self.find_elements(ArticleSelectors.caption) + self.assertGreaterEqual(len(captions), 5) + + def test_media_caption_text(self): + self.open_nitter('i/article/2064689664213041529') + self.assert_text_visible('FIGURE 1', ArticleSelectors.caption) + + def test_media_caption_alt_attribute(self): + self.open_nitter('i/article/2064689664213041529') + img = self.find_element(f'{ArticleSelectors.media} img') + alt = img.get_attribute('alt') + self.assertGreater(len(alt), 0) + + def test_no_caption_when_absent(self): + self.open_nitter('i/article/2062858677149675788') + captions = self.find_elements(ArticleSelectors.caption) + self.assertEqual(len(captions), 0) + + +class ArticleMentionTest(BaseTestCase): + def test_mention_linkified(self): + self.open_nitter('i/article/2064755231901319527') + link = self.find_element('.article-body a[href="/ZachXBT"]') + self.assertEqual(link.text, '@ZachXBT') + + def test_multiple_mentions_linkified(self): + self.open_nitter('i/article/2064755231901319527') + links = self.find_elements('.article-body a[href^="/"]') + mention_hrefs = [l.get_attribute('href') for l in links + if l.text.startswith('@')] + usernames = [h.split('/')[-1] for h in mention_hrefs] + self.assertIn('ZachXBT', usernames) + self.assertIn('River', usernames) + + def test_mention_in_different_article(self): + self.open_nitter('i/article/2064689664213041529') + link = self.find_element('.article-body a[href="/FutureJurvetson"]') + self.assertEqual(link.text, '@FutureJurvetson') + + def test_no_spurious_whitespace_in_styled_paragraph(self): + """Styled paragraphs should not have extra whitespace from VNode serialization.""" + self.open_nitter('i/article/2064166507438059759') + source = self.get_page_source() + self.assertNotIn('white-space: pre-wrap', source) + self.assertNotIn('white-space:pre-wrap', source) + + +class ArticleCardTest(BaseTestCase): + @parameterized.expand(articles) + def test_status_page_shows_article_card(self, tweet_id, title, username, fullname): + self.open_nitter(f'{username}/status/{tweet_id}') + self.assert_element_visible('.article-card') + self.assert_text(title, '.article-card .card-title') + + def test_article_card_has_cover_image(self): + self.open_nitter('Dialectic_Group/status/2064689664213041529') + self.assert_element_visible('.article-card .card-image img') + src = self.get_attribute('.article-card .card-image img', 'src') + self.assertIn('/pic/', src) + + def test_article_card_has_badge(self): + self.open_nitter('Dialectic_Group/status/2064689664213041529') + self.assert_element_visible('.article-card-badge') + self.assert_text('Article', '.article-card-badge') + + def test_article_card_has_preview_text(self): + self.open_nitter('CosmicOrFun/status/2064691088636424322') + self.assert_element_visible('.article-card .card-description') + + def test_article_card_links_to_article(self): + self.open_nitter('punk2898/status/2064166507438059759') + href = self.get_attribute('.article-card .card-container', 'href') + self.assertIn('/article/', href) + + def test_article_url_stripped_from_tweet_text(self): + self.open_nitter('punk2898/status/2064166507438059759') + self.assert_element_visible('.article-card') + source = self.get_page_source() + # Main tweet text should not contain article URL + import re + main = re.search(r'id="m".*?tweet-content[^>]*>(.*?)', source, re.DOTALL) + self.assertIsNotNone(main) + self.assertNotIn('/article/', main.group(1)) + + +class ArticleQuotedCardTest(BaseTestCase): + """Article cards inside quoted tweets (1914ad quoting own article).""" + quoted_tweet = '1914ad/status/2064789532071891085' + quoted_article_id = '2063677483548102688' + + def test_quoted_card_visible(self): + self.open_nitter(self.quoted_tweet) + self.assert_element_visible('.quote .article-card') + + def test_quoted_card_has_title(self): + self.open_nitter(self.quoted_tweet) + self.assert_text('David Bailey Already Won', '.quote .article-card .card-title') + + def test_quoted_card_has_badge(self): + self.open_nitter(self.quoted_tweet) + self.assert_element_visible('.quote .article-card-badge') + self.assert_text('Article', '.quote .article-card-badge') + + def test_quoted_card_has_cover_image(self): + self.open_nitter(self.quoted_tweet) + # Scroll to element to trigger lazy loading + self.scroll_to('.quote .article-card .card-image img') + self.assert_element_visible('.quote .article-card .card-image img') + src = self.get_attribute('.quote .article-card .card-image img', 'src') + self.assertIn('/pic/', src) + + def test_quoted_card_has_description(self): + self.open_nitter(self.quoted_tweet) + self.assert_element_visible('.quote .article-card .card-description') + + def test_quoted_card_links_to_article(self): + self.open_nitter(self.quoted_tweet) + href = self.get_attribute('.quote .article-card .card-container', 'href') + self.assertIn(f'/article/{self.quoted_article_id}', href) + + +class ArticleRoutingTest(BaseTestCase): + def test_username_article_route_redirects(self): + self.open_nitter('punk2898/article/2064166507438059759') + self.assert_element_visible(ArticleSelectors.page) + self.assert_text('1s', ArticleSelectors.title) + + def test_status_article_route_redirects(self): + self.open_nitter('punk2898/status/2064166507438059759/article') + self.assert_element_visible(ArticleSelectors.page) + self.assert_text('1s', ArticleSelectors.title) + + def test_invalid_id_returns_404(self): + self.open_nitter('i/article/notanumber') + self.assert_element_not_visible(ArticleSelectors.page) + + def test_nonexistent_article(self): + self.open_nitter('i/article/1') + self.assert_element_visible('.error-panel') diff --git a/tests/test_card.py b/tests/test_card.py index 504c079..daee099 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -1,3 +1,5 @@ +import os +import unittest from base import BaseTestCase, Card, Conversation from parameterized import parameterized @@ -13,21 +15,16 @@ card = [ '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] + ['NASA/status/2061872347477418301', + 'Nancy Grace Roman Space Telescope - NASA Science', + 'The Nancy Grace Roman Space Telescope will settle essential questions in the areas of dark energy, exoplanets, and astrophysics.', + 'science.nasa.gov', True] ] no_thumb = [ - ['FluentAI/status/1116417904831029248', - 'LinkedIn', - 'This link will take you to a page that’s not on LinkedIn', - 'lnkd.in'], - ['Thom_Wolf/status/1122466524860702729', - 'GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in', - '', + 'GitHub - facebookresearch/XLM: PyTorch original implementation of Cross-lingual Language Model', + 'PyTorch original implementation of Cross-lingual Language Model Pretraining.', 'github.com'], ['brent_p/status/1088857328680488961', @@ -42,20 +39,17 @@ no_thumb = [ ] playable = [ - ['nim_lang/status/1118234460904919042', - 'Nim development blog 2019-03', - 'Arne (aka Krux02)* debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g* bugs: * forwarding of .pure. * sizeof union* fe...', - 'youtube.com'], - - ['nim_lang/status/1121090879823986688', - 'Nim - First natively compiled language w/ hot code-reloading at...', - '#nim #c++ #ACCUConfNim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming cap...', + ['NASA/status/2047048645845897398', + 'NASA\'s Artemis II News Conference with Moon Astronauts', + 'Live from NASA\'s Johnson Space Center in Houston', 'youtube.com'] ] class CardTest(BaseTestCase): @parameterized.expand(card) def test_card(self, tweet, title, description, destination, large): + if os.environ.get('GITHUB_ACTIONS') == 'true' and '2061872347477418301' in tweet: + self.skipTest('Card image unreliable from GitHub datacenter IPs') self.open_nitter(tweet) c = Card(Conversation.main + " ") self.assert_text(title, c.title) @@ -77,7 +71,7 @@ class CardTest(BaseTestCase): if len(description) > 0: self.assert_text(description, c.description) - @parameterized.expand(playable) + @parameterized.expand(playable, skip_on_empty=True) def test_card_playable(self, tweet, title, description, destination): self.open_nitter(tweet) c = Card(Conversation.main + " ") diff --git a/tests/test_community.py b/tests/test_community.py new file mode 100644 index 0000000..b266044 --- /dev/null +++ b/tests/test_community.py @@ -0,0 +1,211 @@ +from base import BaseTestCase +from parameterized import parameterized + + +COMMUNITY_ID = '1493446837214187523' +COMMUNITY_PATH = f'i/communities/{COMMUNITY_ID}' + + +class CommunityTest(BaseTestCase): + def test_top_page_loads(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.community-header') + self.assert_text('Build in Public', '.community-name') + + def test_banner_visible(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.timeline-banner img') + + def test_member_count(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.community-member-count') + self.assert_text('Members', '.community-member-count') + + def test_description_visible(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.community-description') + + def test_tabs_present(self): + self.open_nitter(COMMUNITY_PATH) + tabs = self.find_elements('.tab a') + labels = [t.text for t in tabs] + self.assertEqual(labels, ['Top', 'Latest', 'Media', 'About']) + + def test_top_tab_active(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.tab .active a[href$="/' + COMMUNITY_ID + '"]') + + def test_top_has_tweets(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.timeline-item .tweet-body') + + def test_top_has_pagination(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.show-more') + self.assert_text('Load more', '.show-more') + + def test_latest_has_tweets(self): + self.open_nitter(f'{COMMUNITY_PATH}/latest') + self.assert_element_visible('.timeline-item .tweet-body') + + def test_latest_tab_active(self): + self.open_nitter(f'{COMMUNITY_PATH}/latest') + self.assert_element_visible('.tab .active a[href$="/latest"]') + + def test_media_has_tweets(self): + self.open_nitter(f'{COMMUNITY_PATH}/media') + self.assert_element_visible('.timeline-item .tweet-body') + + def test_media_tab_active(self): + self.open_nitter(f'{COMMUNITY_PATH}/media') + self.assert_element_visible('.tab .active a[href$="/media"]') + + def test_about_page(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_element_visible('.community-about') + self.assert_text('Community Info', '.community-info h2') + + def test_about_rules(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_element_visible('.community-rules') + self.assert_text('Rules', '.community-rules h2') + rules = self.find_elements('.community-rule') + self.assertGreater(len(rules), 0) + + def test_about_creator(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_text('Created', '.community-info') + link = self.find_element('.community-info-item a') + self.assertTrue(link.text.startswith('@')) + self.assertGreater(len(link.text), 1) + + def test_about_tab_active(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_element_visible('.tab .active a[href$="/about"]') + + def test_about_moderators(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_element_visible('.community-moderators') + self.assert_text('Moderators', '.community-moderators h2') + mods = self.find_elements('.community-moderator') + self.assertGreater(len(mods), 0) + + def test_about_moderators_have_avatars(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + avatars = self.find_elements('.community-mod-avatar') + self.assertGreater(len(avatars), 0) + + def test_about_moderators_link_to_profiles(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + links = self.find_elements('.community-mod-username') + self.assertGreater(len(links), 0) + for link in links: + self.assertTrue(link.text.startswith('@')) + self.assertTrue(link.get_attribute('href').startswith('http')) + + def test_about_see_all_link(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + link = self.find_element('.community-mods-link') + self.assertEqual(link.text, 'See all') + self.assertIn('/moderators', link.get_attribute('href')) + + def test_members_page(self): + self.open_nitter(f'{COMMUNITY_PATH}/members') + self.assert_element_visible('.timeline-item') + users = self.find_elements('.timeline-item .username') + self.assertGreater(len(users), 0) + + def test_members_has_member_tabs(self): + self.open_nitter(f'{COMMUNITY_PATH}/members') + tabs = self.find_elements('.tab a') + labels = [t.text for t in tabs] + self.assertEqual(labels, ['All', 'Moderators']) + + def test_members_all_tab_active(self): + self.open_nitter(f'{COMMUNITY_PATH}/members') + self.assert_element_visible('.tab .active a[href$="/members"]') + + def test_members_count_is_link(self): + self.open_nitter(COMMUNITY_PATH) + link = self.find_element('.community-member-count') + self.assertIn('Members', link.text) + self.assertIn('/members', link.get_attribute('href')) + + def test_moderators_page(self): + self.open_nitter(f'{COMMUNITY_PATH}/moderators') + self.assert_element_visible('.timeline-item') + users = self.find_elements('.timeline-item .username') + self.assertGreater(len(users), 0) + + def test_moderators_tab_active(self): + self.open_nitter(f'{COMMUNITY_PATH}/moderators') + self.assert_element_visible('.tab .active a[href$="/moderators"]') + + def test_moderators_has_member_tabs(self): + self.open_nitter(f'{COMMUNITY_PATH}/moderators') + tabs = self.find_elements('.tab a') + labels = [t.text for t in tabs] + self.assertEqual(labels, ['All', 'Moderators']) + + def test_pinned_tweet_label(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.pinned') + self.assert_text('Pinned by Community mods', '.pinned') + + def test_hashtags_visible(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.community-tags') + tags = self.find_elements('.community-tag') + self.assertGreater(len(tags), 0) + + def test_hashtags_are_links(self): + self.open_nitter(COMMUNITY_PATH) + tags = self.find_elements('.community-tag') + for tag in tags: + href = tag.get_attribute('href') + self.assertIn('/hashtag/', href) + self.assertTrue(tag.text.startswith('#')) + + def test_hashtag_page(self): + self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') + self.assert_element_visible('.timeline-item .tweet-body') + + def test_hashtag_shows_header(self): + self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') + self.assert_element_visible('.community-header') + self.assert_text('Build in Public', '.community-name') + + def test_hashtag_shows_tag_title(self): + self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') + self.assert_element_visible('.community-hashtag-header') + self.assert_text('#buildinpublic', '.community-hashtag-title') + + def test_hashtag_no_main_tabs(self): + self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') + tabs = self.find_elements('.tab a') + tab_labels = [t.text for t in tabs] + self.assertNotIn('Top', tab_labels) + self.assertNotIn('About', tab_labels) + + def test_category_visible(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.community-category') + + def test_about_join_policy(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_text('Anyone can join', '.community-info') + + def test_about_visibility_note(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_text('publicly visible', '.community-info') + + def test_404_invalid_id(self): + self.open_nitter('i/communities/999') + self.assert_element_visible('.error-panel') + self.assert_text('not found', '.error-panel') + + @parameterized.expand(['', '/latest', '/media', '/about', + '/members', '/moderators']) + def test_page_no_error(self, suffix): + self.open_nitter(f'{COMMUNITY_PATH}{suffix}') + self.assert_element_not_visible('.error-panel') diff --git a/tests/test_embed.py b/tests/test_embed.py new file mode 100644 index 0000000..cc7a28a --- /dev/null +++ b/tests/test_embed.py @@ -0,0 +1,282 @@ +import os +import unittest +import requests +from base import BaseTestCase, Media +from parameterized import parameterized + + +class Embed: + container = '.tweet-embed' + footer = '.embed-footer' + tweet_content = '.tweet-content' + tweet_header = '.tweet-header' + fullname = '.fullname' + username = '.username' + avatar = '.avatar' + stats = '.tweet-stats' + quote = '.quote' + error_panel = '.error-panel' + + +class TweetEmbedTest(BaseTestCase): + """Test tweet embed rendering.""" + tweet = 'elonmusk/status/1141367104702038016' + + def test_embed_container_visible(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.container) + + def test_embed_has_footer(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.footer) + self.assert_text_visible('Read more on', Embed.footer) + + def test_embed_has_tweet_content(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.tweet_content) + + def test_embed_has_avatar(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.avatar) + + def test_embed_has_username(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.username) + + def test_embed_has_stats(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.stats) + + def test_embed_footer_links_to_tweet(self): + self.open_nitter(self.tweet + '/embed') + href = self.get_attribute(Embed.footer, 'href') + self.assertIn('/elonmusk/status/1141367104702038016', href) + + +class TweetEmbedMediaTest(BaseTestCase): + """Test embed rendering with various media types.""" + + def test_embed_with_image(self): + self.open_nitter('mobile_test/status/519364660823207936/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.image) + + def test_embed_with_gif(self): + self.open_nitter('elonmusk/status/1141367104702038016/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.gif) + + @unittest.skipIf(os.environ.get('GITHUB_ACTIONS') == 'true', + 'TweetResultByRestId is Cloudflare-blocked from GitHub datacenter IPs') + def test_embed_with_video(self): + self.open_nitter('d0m96/status/1078373829917974528/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.video) + + def test_embed_with_gallery(self): + self.open_nitter('mobile_test/status/451108446603980803/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.row) + + +class TweetEmbedQuoteTest(BaseTestCase): + """Test embed rendering with quoted tweets.""" + + def test_embed_with_quote_shows_quote(self): + self.open_nitter('elonmusk/status/1138827760107790336/embed') + self.assert_element_visible(Embed.container) + self.assert_element_visible(Embed.quote) + + def test_embed_quote_has_content(self): + self.open_nitter('elonmusk/status/1138827760107790336/embed') + quote = self.find_element(Embed.quote) + self.assertIsNotNone(quote.text) + + +class EmbedErrorTest(BaseTestCase): + """Test embed error handling.""" + + def test_nonexistent_tweet_shows_error(self): + self.open_nitter('nobody/status/1/embed') + self.assert_element_visible('.tweet-embed.error-embed') + self.assert_text_visible('not found', Embed.error_panel) + + def test_protected_account_embed_shows_error(self): + self.open_nitter('mobile_test_7/status/1/embed') + self.assert_element_visible('.tweet-embed.error-embed') + + def test_invalid_tweet_id_shows_error(self): + self.open_nitter('jack/status/notanumber/embed') + self.assert_element_visible('.tweet-embed.error-embed') + + +class OEmbedApiTest(BaseTestCase): + """Test oEmbed API endpoint.""" + base_url = 'http://localhost:8080' + tweet_url = 'https://twitter.com/elonmusk/status/1141367104702038016' + + def test_oembed_returns_json(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.headers['Content-Type'], 'application/json') + + def test_oembed_has_required_fields(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertEqual(data['type'], 'rich') + self.assertEqual(data['version'], '1.0') + self.assertIn('html', data) + self.assertIn('author_name', data) + self.assertIn('provider_name', data) + + def test_oembed_html_contains_iframe(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertIn(' resolves the numeric id and redirects to the profile (issue #1433)""" + self.open_nitter(f'i/user/{user_id}') + self.assert_true(self.get_current_url().rstrip('/').endswith(f'/{username}')) + self.assert_exact_text(f'@{username}', Profile.username) + + @parameterized.expand(id_redirects) + def test_intent_user_redirect(self, user_id, username): + """/intent/user?user_id= redirects to the profile (issue #1433)""" + self.open_nitter(f'intent/user?user_id={user_id}') + self.assert_true(self.get_current_url().rstrip('/').endswith(f'/{username}')) + self.assert_exact_text(f'@{username}', Profile.username) diff --git a/tests/test_reply_sort.py b/tests/test_reply_sort.py new file mode 100644 index 0000000..1d89e5d --- /dev/null +++ b/tests/test_reply_sort.py @@ -0,0 +1,37 @@ +from parameterized import parameterized + +from base import BaseTestCase, Conversation + +sort_modes = [ + ['jack/status/20', 'Relevant'], + ['jack/status/20?sort=relevance', 'Relevant'], + ['jack/status/20?sort=recency', 'Recent'], + ['jack/status/20?sort=likes', 'Liked'], + ['jack/status/20?sort=garbage', 'Relevant'], + ['jack/status/20?sort=%3Cscript%3E', 'Relevant'], +] + + +class ReplySortTest(BaseTestCase): + @parameterized.expand(sort_modes) + def test_active_mode(self, page, expected_active): + self.open_nitter(page) + self.assert_element_visible(Conversation.reply_sort) + active = self.get_text(Conversation.reply_sort_active) + self.assert_equal(active.strip(), expected_active) + + def test_all_three_options_present(self): + self.open_nitter('jack/status/20') + options = self.find_elements('.reply-sort-option') + labels = [o.text.strip() for o in options] + self.assert_equal(labels, ['Relevant', 'Recent', 'Liked']) + + def test_option_links_carry_sort_param(self): + self.open_nitter('jack/status/20') + for slug in ['Relevance', 'Recency', 'Likes']: + self.assert_element(f'.reply-sort-option[href="?sort={slug}#r"]') + + def test_load_more_preserves_sort(self): + self.open_nitter('jack/status/20?sort=Likes') + href = self.get_attribute('.replies .show-more a', 'href') + self.assert_true('sort=Likes' in href, f'sort missing from: {href}') diff --git a/tests/test_search.py b/tests/test_search.py index 62c4640..0f5456f 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,9 +1,129 @@ -from base import BaseTestCase from parameterized import parameterized +from base import BaseTestCase, Search -#class SearchTest(BaseTestCase): - #@parameterized.expand([['@mobile_test'], ['@mobile_test_2']]) - #def test_username_search(self, username): - #self.search_username(username) - #self.assert_text(f'{username}') +# [url, expected active tab label] +active_tabs = [ + ['search?f=tweets&q=nasa', 'Latest'], + ['search?f=top&q=nasa', 'Top'], + ['search?f=media&q=nasa', 'Media'], + ['search?f=users&q=nasa', 'Users'], + ['search?f=lists&q=test', 'Lists'], + # unknown/hostile values fall back to Latest + ['search?f=garbage&q=nasa', 'Latest'], + ['search?f=%3Cscript%3E&q=nasa', 'Latest'], + # x.com URL compat: f=live/user/list (f=media/top match natively) + ['search?f=live&q=nasa', 'Latest'], + ['search?f=user&q=nasa', 'Users'], + ['search?f=list&q=test', 'Lists'], +] + +results_pages = [ + ['search?f=tweets&q=nasa'], + ['search?f=top&q=nasa'], + ['search?f=media&q=nasa'], +] + + +class SearchProductTest(BaseTestCase): + @parameterized.expand(active_tabs) + def test_active_tab(self, page, expected_active): + self.open_nitter(page) + active = self.get_text(Search.tab_active) + self.assert_equal(active.strip(), expected_active) + + def test_all_tabs_present(self): + self.open_nitter('search?f=tweets&q=nasa') + tabs = self.find_elements(Search.tab_item) + labels = [t.text.strip() for t in tabs] + self.assert_equal(labels, ['Top', 'Latest', 'Media', 'Users', 'Lists']) + + @parameterized.expand(results_pages) + def test_results_render(self, page): + self.open_nitter(page) + self.assert_element('.timeline .timeline-item') + + def test_tab_links_carry_kind(self): + self.open_nitter('search?f=tweets&q=nasa') + self.assert_element('.tab-item a[href="?f=top&q=nasa"]') + self.assert_element('.tab-item a[href="?f=media&q=nasa"]') + self.assert_element('.tab-item a[href="?f=tweets&q=nasa"]') + self.assert_element('.tab-item a[href="?f=users&q=nasa"]') + self.assert_element('.tab-item a[href="?f=lists&q=nasa"]') + + def test_show_more_preserves_kind(self): + self.open_nitter('search?f=media&q=nasa') + href = self.get_attribute('.show-more a', 'href') + self.assert_true('f=media' in href, f'f=media missing from: {href}') + + def test_search_form_preserves_kind(self): + self.open_nitter('search?f=top&q=nasa') + self.assert_element_present('.search-field input[name="f"][value="top"]') + + def test_media_operators_compose(self): + self.open_nitter('search?f=media&q=nasa&e-nativeretweets=on') + self.assert_element('.timeline .timeline-item') + + @parameterized.expand([['DAAC'], ['AB'], ['maxid:'], ['maxid:abc']]) + def test_garbage_cursor_no_crash(self, cursor): + # short/invalid cursors must render the page, not a 500 error + self.open_nitter(f'search?f=media&q=nasa&cursor={cursor}') + self.assert_element(Search.tab_active) + + def test_no_results(self): + self.open_nitter('search?f=media&q=xkqzjwv_no_results_2026') + self.assert_text('No items found', '.timeline-none') + + def test_list_results_render(self): + self.open_nitter('search?f=lists&q=test') + self.assert_element('.timeline-item.list-result') + self.assert_element('.list-result .list-name') + self.assert_element('.list-result .list-members') + + def test_list_card_links_to_list(self): + self.open_nitter('search?f=lists&q=test') + href = self.get_attribute('.list-result .list-name', 'href') + self.assert_true('/i/lists/' in href, f'unexpected list link: {href}') + + def test_list_row_clickable(self): + self.open_nitter('search?f=lists&q=test') + href = self.get_attribute('.list-result a.tweet-link', 'href') + self.assert_true('/i/lists/' in href, f'unexpected row link: {href}') + + def test_list_avatar_links_to_user(self): + self.open_nitter('search?f=lists&q=test') + # the avatar link in a row must point at the user named in that row + row = '.list-result:has(a.facepile-link)' + self.assert_element(f'{row} a.facepile-link > img') + href = self.get_attribute(f'{row} a.facepile-link', 'href') + ctx = self.get_text(f'{row} .list-result-context') + mentioned = ctx.split('@')[-1].strip() + self.assert_true(href.endswith('/' + mentioned), + f'avatar link {href} does not match @{mentioned}') + + def test_list_pagination_preserves_kind(self): + self.open_nitter('search?f=lists&q=test') + href = self.get_attribute('.show-more a', 'href') + self.assert_true('f=lists' in href, f'f=lists missing from: {href}') + + def test_list_garbage_cursor_no_crash(self): + self.open_nitter('search?f=lists&q=test&cursor=DAAC') + self.assert_element(Search.tab_active) + + def test_media_view_tabs_present(self): + self.open_nitter('search?f=media&q=nasa') + tabs = self.find_elements('.media-view-tabs .tab-item') + labels = [t.text.strip() for t in tabs] + self.assert_equal(labels, ['Timeline', 'Grid', 'Gallery']) + + def test_media_view_grid(self): + self.open_nitter('search?f=media&q=nasa&view=grid') + self.assert_element('.timeline.media-grid-view') + + def test_media_view_gallery(self): + self.open_nitter('search?f=media&q=nasa&view=gallery') + self.assert_element('.timeline.media-gallery-view .gallery-masonry') + + def test_media_view_tabs_only_on_media(self): + self.open_nitter('search?f=tweets&q=nasa') + self.assert_element_not_present('.media-view-tabs') diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..04ba680 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,60 @@ +import subprocess +from parameterized import parameterized + +BASE_URL = 'http://localhost:8080' + + +def curl_status(url): + """Get HTTP status code using curl to avoid URL normalization by Python libs.""" + result = subprocess.run( + ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', url], + capture_output=True, text=True, timeout=30 + ) + return int(result.stdout) + + +class TestMalformedPaths: + """Test that malformed paths don't crash the server. + + URLs like //foo are parsed as having 'foo' as the authority (host), + resulting in an empty path. Empty paths previously crashed jester's + static file handler. Now they return 400. + + URLs like //foo/bar are parsed as authority='foo', path='/bar', + so they route normally (not empty path). + """ + + @parameterized.expand([ + # These parse to empty paths -> 400 + ('//lefty_rae', 400), + ('//test', 400), + ('//anyuser', 400), + ]) + def test_empty_path_returns_400(self, path, expected_status): + """URLs that parse to empty paths should return 400, not crash.""" + status = curl_status(f'{BASE_URL}{path}') + assert status == expected_status, \ + f'Expected {expected_status} for {path}, got {status}' + + @parameterized.expand([ + ('/jack', 200), + ('/about', 200), + ('/', 200), + ]) + def test_normal_paths_work(self, path, expected_status): + """Normal paths should still work.""" + status = curl_status(f'{BASE_URL}{path}') + assert status == expected_status, \ + f'Expected {expected_status} for {path}, got {status}' + + def test_server_survives_malformed_requests(self): + """Server should handle malformed requests without crashing.""" + # These all parse to empty paths + malformed_paths = ['//a', '//b', '//c', '//user', '//test'] + for path in malformed_paths: + status = curl_status(f'{BASE_URL}{path}') + assert status == 400, f'Expected 400 for {path}, got {status}' + + # Verify server is still responding after malformed requests + status = curl_status(f'{BASE_URL}/') + assert status == 200, 'Server should still be alive' diff --git a/tests/test_space.py b/tests/test_space.py new file mode 100644 index 0000000..d2ba672 --- /dev/null +++ b/tests/test_space.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: AGPL-3.0-only +"""Integration tests for Twitter Spaces support.""" +import pytest +from seleniumbase import BaseCase + + +class TestSpacePage(BaseCase): + """Tests for /i/spaces/@id route.""" + + SPACE_ID = "1mxPaaRAwYjKN" + SPACE_URL = f"http://localhost:8080/i/spaces/{SPACE_ID}" + + def test_space_page_loads(self): + """Space page should load with title.""" + self.open(self.SPACE_URL) + self.assert_element(".space-page") + self.assert_element(".space-panel") + self.assert_text_visible("INTEL WILL MOON NEXT WEEK", ".space-title") + + def test_space_host_info(self): + """Space should display host in participants.""" + self.open(self.SPACE_URL) + self.assert_element(".space-participants") + self.assert_text_visible("bubble boi") + self.assert_element(".host-badge") + + def test_space_metadata(self): + """Space should display listener count and state.""" + self.open(self.SPACE_URL) + self.assert_element(".space-meta") + # Should show listener count (number format) + meta_text = self.get_text(".space-meta") + assert any(c.isdigit() for c in meta_text), "Should show listener count" + # Should show ended state + assert "Ended" in meta_text or "Jun" in meta_text, "Should show ended state" + + def test_space_participants(self): + """Space should display host and speakers.""" + self.open(self.SPACE_URL) + self.assert_element(".space-participants") + # Host should have badge + self.assert_element(".host-badge") + self.assert_text_visible("Host", ".host-badge") + # Should show speakers + self.assert_text_visible("CANTELOPEPEEL") + self.assert_text_visible("Based Burner Account") + self.assert_text_visible("anon invests") + + def test_space_participant_avatars(self): + """Participant avatars should load correctly.""" + self.open(self.SPACE_URL) + # Check avatars in participants section + avatars = self.find_elements(".space-participant img") + assert len(avatars) >= 4, "Should have at least 4 participant avatars" + for avatar in avatars: + src = avatar.get_attribute("src") + # Should NOT be double-encoded + assert "%2Fpic%2F" not in src, f"Avatar URL double-encoded: {src}" + # Should have valid path + assert "/pic/" in src, f"Avatar URL missing /pic/: {src}" + + def test_space_player_hls_disabled(self): + """Without HLS, should show enable button with video-overlay style.""" + self.open(self.SPACE_URL) + self.assert_element(".space-player") + self.assert_element(".video-overlay") + # Should show duration in overlay-duration + self.assert_element(".overlay-duration") + # Should have enable button + source = self.get_page_source() + assert "Enable hls playback" in source + + def test_space_player_hls_enabled(self): + """With HLS enabled, should have audio element in DOM.""" + self.open(self.SPACE_URL) + # Set HLS preference via cookie + self.add_cookie({"name": "hlsPlayback", "value": "on"}) + self.refresh() + # Check page source for audio element (hidden until play clicked) + source = self.get_page_source() + assert '