Full Code of zedeus/nitter for AI

master 7ce29bd8f172 cached
127 files
370.3 KB
104.7k tokens
98 symbols
1 requests
Download .txt
Showing preview only (399K chars total). Download the full file or copy to clipboard to get everything.
Repository: zedeus/nitter
Branch: master
Commit: 7ce29bd8f172
Files: 127
Total size: 370.3 KB

Directory structure:
gitextract_oep9sxz7/

├── .dockerignore
├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── build-docker.yml
│       └── run-tests.yml
├── .gitignore
├── .travis.yml
├── Dockerfile
├── Dockerfile.arm64
├── LICENSE
├── README.md
├── config.nims
├── docker-compose.yml
├── nitter.example.conf
├── nitter.nimble
├── public/
│   ├── browserconfig.xml
│   ├── css/
│   │   ├── fontello.css
│   │   └── themes/
│   │       ├── auto.css
│   │       ├── auto_(twitter).css
│   │       ├── black.css
│   │       ├── dracula.css
│   │       ├── mastodon.css
│   │       ├── nitter.css
│   │       ├── pleroma.css
│   │       ├── twitter.css
│   │       └── twitter_dark.css
│   ├── fonts/
│   │   └── LICENSE.txt
│   ├── js/
│   │   ├── hlsPlayback.js
│   │   └── infiniteScroll.js
│   ├── md/
│   │   └── about.md
│   ├── robots.txt
│   └── site.webmanifest
├── src/
│   ├── api.nim
│   ├── apiutils.nim
│   ├── auth.nim
│   ├── config.nim
│   ├── consts.nim
│   ├── experimental/
│   │   ├── parser/
│   │   │   ├── graphql.nim
│   │   │   ├── session.nim
│   │   │   ├── slices.nim
│   │   │   ├── tid.nim
│   │   │   ├── unifiedcard.nim
│   │   │   ├── user.nim
│   │   │   └── utils.nim
│   │   ├── parser.nim
│   │   └── types/
│   │       ├── common.nim
│   │       ├── graphlistmembers.nim
│   │       ├── graphuser.nim
│   │       ├── session.nim
│   │       ├── tid.nim
│   │       ├── unifiedcard.nim
│   │       └── user.nim
│   ├── formatters.nim
│   ├── http_pool.nim
│   ├── nitter.nim
│   ├── parser.nim
│   ├── parserutils.nim
│   ├── prefs.nim
│   ├── prefs_impl.nim
│   ├── query.nim
│   ├── redis_cache.nim
│   ├── routes/
│   │   ├── debug.nim
│   │   ├── embed.nim
│   │   ├── list.nim
│   │   ├── media.nim
│   │   ├── preferences.nim
│   │   ├── resolver.nim
│   │   ├── router_utils.nim
│   │   ├── rss.nim
│   │   ├── search.nim
│   │   ├── status.nim
│   │   ├── timeline.nim
│   │   └── unsupported.nim
│   ├── sass/
│   │   ├── general.scss
│   │   ├── include/
│   │   │   ├── _mixins.css
│   │   │   └── _variables.scss
│   │   ├── index.scss
│   │   ├── inputs.scss
│   │   ├── navbar.scss
│   │   ├── profile/
│   │   │   ├── _base.scss
│   │   │   ├── card.scss
│   │   │   └── photo-rail.scss
│   │   ├── search.scss
│   │   ├── timeline.scss
│   │   └── tweet/
│   │       ├── _base.scss
│   │       ├── card.scss
│   │       ├── embed.scss
│   │       ├── media.scss
│   │       ├── poll.scss
│   │       ├── quote.scss
│   │       ├── thread.scss
│   │       └── video.scss
│   ├── tid.nim
│   ├── types.nim
│   ├── utils.nim
│   └── views/
│       ├── about.nim
│       ├── embed.nim
│       ├── feature.nim
│       ├── general.nim
│       ├── list.nim
│       ├── opensearch.nimf
│       ├── preferences.nim
│       ├── profile.nim
│       ├── renderutils.nim
│       ├── rss.nimf
│       ├── search.nim
│       ├── status.nim
│       ├── timeline.nim
│       └── tweet.nim
├── tests/
│   ├── base.py
│   ├── poetry.toml
│   ├── pyproject.toml
│   ├── requirements.txt
│   ├── test_card.py
│   ├── test_profile.py
│   ├── test_quote.py
│   ├── test_search.py
│   ├── test_thread.py
│   ├── test_timeline.py
│   ├── test_tweet.py
│   └── test_tweet_media.py
└── tools/
    ├── create_session_browser.py
    ├── create_session_curl.py
    ├── create_sessions_browser.py
    ├── gencss.nim
    ├── get_session.py
    ├── rendermd.nim
    └── requirements.txt

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
*.png
*.md
LICENSE
docker-compose.yml
Dockerfile
tests/


================================================
FILE: .github/FUNDING.yml
================================================
github: zedeus
liberapay: zedeus
patreon: nitter


================================================
FILE: .github/workflows/build-docker.yml
================================================
name: Docker

on:
  push:
    paths-ignore:
      - "README.md"
    branches:
      - master

jobs:
  tests:
    uses: ./.github/workflows/run-tests.yml
    secrets: inherit

  build-docker-amd64:
    needs: [tests]
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v6
      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v3
        with:
          version: latest
      - name: Login to DockerHub
        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
        with:
          context: .
          file: ./Dockerfile
          platforms: linux/amd64
          push: true
          tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}

  build-docker-arm64:
    needs: [tests]
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@v6
      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v3
        with:
          version: latest
      - name: Login to DockerHub
        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


================================================
FILE: .github/workflows/run-tests.yml
================================================
name: Tests

on:
  push:
    paths-ignore:
      - "*.md"
    branches-ignore:
      - master
  workflow_call:

# Ensure that multiple runs on the same branch do not overlap.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

defaults:
  run:
    shell: bash

jobs:
  build-test:
    name: Build and test
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        nim: ["2.0.x", "2.2.x", "devel"]
    steps:
      - name: Checkout Code
        uses: actions/checkout@v6

      - name: Cache Nimble Dependencies
        id: cache-nimble
        uses: actions/cache@v5
        with:
          path: ~/.nimble
          key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }}
          restore-keys: |
            ${{ matrix.nim }}-nimble-v2-

      - name: Setup Nim
        uses: jiro4989/setup-nim-action@v2
        with:
          nim-version: ${{ matrix.nim }}
          use-nightlies: true
          repo-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Build Project
        run: nimble build -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: ubuntu-24.04

    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@v6

      - name: Cache pipx (poetry)
        uses: actions/cache@v5
        with:
          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
        uses: actions/cache@v5
        with:
          path: ~/.nimble
          key: 2.2.x-nimble-v2-${{ hashFiles('*.nimble') }}
          restore-keys: |
            2.2.x-nimble-v2-

      - name: Setup Nim
        uses: jiro4989/setup-nim-action@v2
        with:
          nim-version: 2.2.x
          use-nightlies: true
          repo-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Download 2.2.x build artifact
        uses: actions/download-artifact@v4
        with:
          name: nitter-linux-nim-2.2.x-${{ github.sha }}
          path: .

      - name: Make nitter binary executable
        run: chmod +x ./nitter

      - name: Prepare Nitter Environment
        run: |
          cp nitter.example.conf nitter.conf
          sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf

          # Run both Nimble tasks concurrently
          nim r tools/rendermd.nim &
          nim r tools/gencss.nim &
          wait

          echo '${{ secrets.SESSIONS }}' | head -n1
          echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl

      - name: Run Tests
        run: |
          ./nitter &
          cd tests
          poetry run pytest -n3 --reruns=3 --rs .


================================================
FILE: .gitignore
================================================
nitter
*.html
*.db
/tests/__pycache__
/tests/geckodriver.log
/tests/downloaded_files
/tests/latest_logs
/tools/gencss
/tools/rendermd
/public/css/style.css
/public/md/*.html
nitter.conf
guest_accounts.json*
sessions.json*
dump.rdb
*.bak
/tools/*.json*


================================================
FILE: .travis.yml
================================================
jobs:
  include:
    - stage: test
      if: (NOT type IN (pull_request)) AND (branch = master)
      dist: bionic
      language: python
      python:
        - 3.6
      services:
        - docker
        - xvfb
      script:
        - sudo apt update
        - sudo apt install --force-yes chromium-chromedriver
        - wget https://www.dropbox.com/s/ckuoaubd1crrj2k/Linux_x64_737173_chrome-linux.zip -O chrome.zip
        - unzip chrome.zip
        - cd chrome-linux
        - sudo rm /usr/bin/google-chrome
        - sudo ln -s chrome /usr/bin/google-chrome
        - cd ..
        - pip3 install --upgrade pip
        - pip3 install -U seleniumbase pytest
        - docker run -d -p 127.0.0.1:8080:8080/tcp $IMAGE_NAME:$TRAVIS_COMMIT
        - sleep 10
        - cd tests
        - pytest --headless -n 8 --reruns 10 --reruns-delay 2
    - stage: pr
      if: type IN (pull_request)
      dist: bionic
      language: python
      python:
        - 3.6
      services:
        - docker
        - xvfb
      script:
        - sudo apt update
        - sudo apt install --force-yes chromium-chromedriver
        - wget https://www.dropbox.com/s/ckuoaubd1crrj2k/Linux_x64_737173_chrome-linux.zip -O chrome.zip
        - unzip chrome.zip
        - cd chrome-linux
        - sudo rm /usr/bin/google-chrome
        - sudo ln -s chrome /usr/bin/google-chrome
        - cd ..
        - pip3 install --upgrade pip
        - pip3 install -U seleniumbase pytest
        - docker build -t $IMAGE_NAME:$TRAVIS_COMMIT .
        - docker run -d -p 127.0.0.1:8080:8080/tcp $IMAGE_NAME:$TRAVIS_COMMIT
        - sleep 10
        - cd tests
        - pytest --headless -n 8 --reruns 3 --reruns-delay 2


================================================
FILE: Dockerfile
================================================
FROM nimlang/nim:2.2.0-alpine-regular as nim
LABEL maintainer="setenforce@protonmail.com"

RUN apk --no-cache add libsass-dev pcre

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:latest
WORKDIR /src/
RUN apk --no-cache add pcre ca-certificates
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


================================================
FILE: Dockerfile.arm64
================================================
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


================================================
FILE: LICENSE
================================================
                    GNU AFFERO GENERAL PUBLIC LICENSE
                       Version 3, 19 November 2007

 Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
 Everyone is permitted to copy and distribute verbatim copies
 of this license document, but changing it is not allowed.

                            Preamble

  The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.

  The licenses for most software and other practical works are designed
to take away your freedom to share and change the works.  By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.

  When we speak of free software, we are referring to freedom, not
price.  Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.

  Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.

  A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate.  Many developers of free software are heartened and
encouraged by the resulting cooperation.  However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.

  The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community.  It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server.  Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.

  An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals.  This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.

  The precise terms and conditions for copying, distribution and
modification follow.

                       TERMS AND CONDITIONS

  0. Definitions.

  "This License" refers to version 3 of the GNU Affero General Public License.

  "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.

  "The Program" refers to any copyrightable work licensed under this
License.  Each licensee is addressed as "you".  "Licensees" and
"recipients" may be individuals or organizations.

  To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy.  The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.

  A "covered work" means either the unmodified Program or a work based
on the Program.

  To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy.  Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.

  To "convey" a work means any kind of propagation that enables other
parties to make or receive copies.  Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.

  An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License.  If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.

  1. Source Code.

  The "source code" for a work means the preferred form of the work
for making modifications to it.  "Object code" means any non-source
form of a work.

  A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.

  The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form.  A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.

  The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities.  However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work.  For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.

  The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.

  The Corresponding Source for a work in source code form is that
same work.

  2. Basic Permissions.

  All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met.  This License explicitly affirms your unlimited
permission to run the unmodified Program.  The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work.  This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.

  You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force.  You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright.  Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.

  Conveying under any other circumstances is permitted solely under
the conditions stated below.  Sublicensing is not allowed; section 10
makes it unnecessary.

  3. Protecting Users' Legal Rights From Anti-Circumvention Law.

  No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.

  When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.

  4. Conveying Verbatim Copies.

  You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.

  You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.

  5. Conveying Modified Source Versions.

  You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:

    a) The work must carry prominent notices stating that you modified
    it, and giving a relevant date.

    b) The work must carry prominent notices stating that it is
    released under this License and any conditions added under section
    7.  This requirement modifies the requirement in section 4 to
    "keep intact all notices".

    c) You must license the entire work, as a whole, under this
    License to anyone who comes into possession of a copy.  This
    License will therefore apply, along with any applicable section 7
    additional terms, to the whole of the work, and all its parts,
    regardless of how they are packaged.  This License gives no
    permission to license the work in any other way, but it does not
    invalidate such permission if you have separately received it.

    d) If the work has interactive user interfaces, each must display
    Appropriate Legal Notices; however, if the Program has interactive
    interfaces that do not display Appropriate Legal Notices, your
    work need not make them do so.

  A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit.  Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.

  6. Conveying Non-Source Forms.

  You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:

    a) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by the
    Corresponding Source fixed on a durable physical medium
    customarily used for software interchange.

    b) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by a
    written offer, valid for at least three years and valid for as
    long as you offer spare parts or customer support for that product
    model, to give anyone who possesses the object code either (1) a
    copy of the Corresponding Source for all the software in the
    product that is covered by this License, on a durable physical
    medium customarily used for software interchange, for a price no
    more than your reasonable cost of physically performing this
    conveying of source, or (2) access to copy the
    Corresponding Source from a network server at no charge.

    c) Convey individual copies of the object code with a copy of the
    written offer to provide the Corresponding Source.  This
    alternative is allowed only occasionally and noncommercially, and
    only if you received the object code with such an offer, in accord
    with subsection 6b.

    d) Convey the object code by offering access from a designated
    place (gratis or for a charge), and offer equivalent access to the
    Corresponding Source in the same way through the same place at no
    further charge.  You need not require recipients to copy the
    Corresponding Source along with the object code.  If the place to
    copy the object code is a network server, the Corresponding Source
    may be on a different server (operated by you or a third party)
    that supports equivalent copying facilities, provided you maintain
    clear directions next to the object code saying where to find the
    Corresponding Source.  Regardless of what server hosts the
    Corresponding Source, you remain obligated to ensure that it is
    available for as long as needed to satisfy these requirements.

    e) Convey the object code using peer-to-peer transmission, provided
    you inform other peers where the object code and Corresponding
    Source of the work are being offered to the general public at no
    charge under subsection 6d.

  A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.

  A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling.  In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage.  For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product.  A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.

  "Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source.  The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.

  If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information.  But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).

  The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed.  Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.

  Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.

  7. Additional Terms.

  "Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law.  If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.

  When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it.  (Additional permissions may be written to require their own
removal in certain cases when you modify the work.)  You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.

  Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:

    a) Disclaiming warranty or limiting liability differently from the
    terms of sections 15 and 16 of this License; or

    b) Requiring preservation of specified reasonable legal notices or
    author attributions in that material or in the Appropriate Legal
    Notices displayed by works containing it; or

    c) Prohibiting misrepresentation of the origin of that material, or
    requiring that modified versions of such material be marked in
    reasonable ways as different from the original version; or

    d) Limiting the use for publicity purposes of names of licensors or
    authors of the material; or

    e) Declining to grant rights under trademark law for use of some
    trade names, trademarks, or service marks; or

    f) Requiring indemnification of licensors and authors of that
    material by anyone who conveys the material (or modified versions of
    it) with contractual assumptions of liability to the recipient, for
    any liability that these contractual assumptions directly impose on
    those licensors and authors.

  All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10.  If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term.  If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.

  If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.

  Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.

  8. Termination.

  You may not propagate or modify a covered work except as expressly
provided under this License.  Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).

  However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.

  Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.

  Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License.  If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.

  9. Acceptance Not Required for Having Copies.

  You are not required to accept this License in order to receive or
run a copy of the Program.  Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance.  However,
nothing other than this License grants you permission to propagate or
modify any covered work.  These actions infringe copyright if you do
not accept this License.  Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.

  10. Automatic Licensing of Downstream Recipients.

  Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License.  You are not responsible
for enforcing compliance by third parties with this License.

  An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations.  If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.

  You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License.  For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.

  11. Patents.

  A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based.  The
work thus licensed is called the contributor's "contributor version".

  A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version.  For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.

  Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.

  In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement).  To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.

  If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients.  "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.

  If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.

  A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License.  You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.

  Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.

  12. No Surrender of Others' Freedom.

  If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License.  If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all.  For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.

  13. Remote Network Interaction; Use with the GNU General Public License.

  Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software.  This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.

  Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work.  The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.

  14. Revised Versions of this License.

  The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time.  Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.

  Each version is given a distinguishing version number.  If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation.  If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.

  If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.

  Later license versions may give you additional or different
permissions.  However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.

  15. Disclaimer of Warranty.

  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

  16. Limitation of Liability.

  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.

  17. Interpretation of Sections 15 and 16.

  If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.

                     END OF TERMS AND CONDITIONS

            How to Apply These Terms to Your New Programs

  If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.

  To do so, attach the following notices to the program.  It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.

    <one line to give the program's name and a brief idea of what it does.>
    Copyright (C) <year>  <name of author>

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published
    by the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.

    You should have received a copy of the GNU Affero General Public License
    along with this program.  If not, see <http://www.gnu.org/licenses/>.

Also add information on how to contact you by electronic and paper mail.

  If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source.  For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code.  There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.

  You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.

================================================
FILE: README.md
================================================
# 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).

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.

- No JavaScript or ads
- All requests go through the backend, client never talks to Twitter
- Prevents Twitter from tracking your IP or JavaScript fingerprint
- Uses Twitter's unofficial API (no developer account required)
- Lightweight (for [@nim_lang](https://nitter.net/nim_lang), 60KB vs 784KB from twitter.com)
- RSS feeds
- Themes
- Mobile support (responsive design)
- AGPLv3 licensed, no proprietary instances permitted

<details>
<summary>Donations</summary>
Liberapay: https://liberapay.com/zedeus<br>
Patreon: https://patreon.com/nitter<br>
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55<br>
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460<br>
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL<br>
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW<br>
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt
</details>

## Roadmap

- Embeds
- Account system with timeline support
- Archiving tweets/profiles
- Developer API

## Resources

The wiki contains
[a list of instances](https://github.com/zedeus/nitter/wiki/Instances) and
[browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
maintained by the community.

## Why?

It's impossible to use Twitter without JavaScript enabled, and as of 2024 you
need to sign up. For privacy-minded folks, preventing JavaScript analytics and
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix,
it's impossible. Despite being behind a VPN and using heavy-duty adblockers,
you can get accurately tracked with your [browser's
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no
JavaScript required](https://noscriptfingerprint.com/). This all became
particularly important after Twitter [removed the
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
for users to control whether their data gets sent to advertisers.

Using an instance of Nitter (hosted on a VPS for example), you can browse
Twitter without JavaScript while retaining your privacy. In addition to
respecting your privacy, Nitter is on average around 15 times lighter than
Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).

In the future a simple account system will be added that lets you follow Twitter
users, allowing you to have a clean chronological timeline without needing a
Twitter account.

## Screenshot

![nitter](/screenshot.png)

## Installation

### Dependencies

- libpcre
- libsass
- redis/valkey

To compile Nitter you need a Nim installation, see
[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible
to install it system-wide or in the user directory you create below.

To compile the scss files, you need to install `libsass`. On Ubuntu and Debian,
you can use `libsass-dev`.

Redis is required for caching and in the future for account info. As of 2024
Redis is no longer open source, so using the fork Valkey is recommended. It
should be available on most distros as `redis` or `redis-server`
(Ubuntu/Debian), or `valkey`/`valkey-server`. Running it with the default
config is fine, Nitter's default config is set to use the default port and
localhost.

Here's how to create a `nitter` user, clone the repo, and build the project
along with the scss and md files.

```bash
# useradd -m nitter
# su nitter
$ git clone https://github.com/zedeus/nitter
$ cd nitter
$ nimble build -d:danger --mm:refc
$ nimble scss
$ nimble md
$ cp nitter.example.conf nitter.conf
```

Set your hostname, port, HMAC key, https (must be correct for cookies), and
Redis info in `nitter.conf`. To run Redis, either run
`redis-server --daemonize yes`, or `systemctl enable --now redis` (or
redis-server depending on the distro). Run Nitter by executing `./nitter` or
using the systemd service below. You should run Nitter behind a reverse proxy
such as [Nginx](https://github.com/zedeus/nitter/wiki/Nginx) or
[Apache](https://github.com/zedeus/nitter/wiki/Apache) for security and
performance reasons.

### Docker

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).

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.

To build and run Nitter in Docker:

```bash
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
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host zedeus/nitter:latest
```

Using docker-compose to run both Nitter and Redis as different containers:
Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run:

```bash
docker-compose up -d
```

Note the Docker commands expect a `nitter.conf` file in the directory you run
them.

### systemd

To run Nitter via systemd you can use this service file:

```ini
[Unit]
Description=Nitter (An alternative Twitter front-end)
After=syslog.target
After=network.target

[Service]
Type=simple

# set user and group
User=nitter
Group=nitter

# configure location
WorkingDirectory=/home/nitter/nitter
ExecStart=/home/nitter/nitter/nitter

Restart=always
RestartSec=15

[Install]
WantedBy=multi-user.target
```

Then enable and run the service:
`systemctl enable --now nitter.service`

### Logging

Nitter currently prints some errors to stdout, and there is no real logging
implemented. If you're running Nitter with systemd, you can check stdout like
this: `journalctl -u nitter.service` (add `--follow` to see just the last 15
lines). If you're running the Docker image, you can do this:
`docker logs --follow *nitter container id*`

## Contact

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.


================================================
FILE: config.nims
================================================
--define:ssl
--define:useStdLib
--threads:off

# workaround httpbeast file upload bug
--assertions:off

# disable annoying warnings
warning("GcUnsafe2", off)
warning("HoleEnumConv", off)
hint("XDeclaredButNotUsed", off)
hint("XCannotRaiseY", off)
hint("User", off)


================================================
FILE: docker-compose.yml
================================================
version: "3"

services:

  nitter:
    image: zedeus/nitter:latest
    container_name: nitter
    ports:
      - "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
    volumes:
      - ./nitter.conf:/src/nitter.conf:Z,ro
      - ./sessions.jsonl:/src/sessions.jsonl:Z,ro # Run get_sessions.py to get the credentials
    depends_on:
      - nitter-redis
    restart: unless-stopped
    healthcheck:
      test: wget -nv --tries=1 --spider http://127.0.0.1:8080/Jack/status/20 || exit 1
      interval: 30s
      timeout: 5s
      retries: 2
    user: "998:998"
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL

  nitter-redis:
    image: redis:6-alpine
    container_name: nitter-redis
    command: redis-server --save 60 1 --loglevel warning
    volumes:
      - nitter-redis:/data
    restart: unless-stopped
    healthcheck:
      test: redis-cli ping
      interval: 30s
      timeout: 5s
      retries: 2
    user: "999:1000"
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL

volumes:
  nitter-redis:


================================================
FILE: nitter.example.conf
================================================
[Server]
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
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
redisPort = 6379
redisPassword = ""
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             # 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
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

# Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences]
theme = "Nitter"
replaceTwitter = "nitter.net"
replaceYouTube = "piped.video"
replaceReddit = "teddit.net"
proxyVideos = true
hlsPlayback = false
infiniteScroll = false


================================================
FILE: nitter.nimble
================================================
# Package

version       = "0.1.0"
author        = "zedeus"
description   = "An alternative front-end for Twitter"
license       = "AGPL-3.0"
srcDir        = "src"
bin           = @["nitter"]


# Dependencies

requires "nim >= 2.0.0"
requires "jester#baca3f"
requires "karax#5cf360c"
requires "sass#7dfdd03"
requires "nimcrypto#a079df9"
requires "markdown#158efe3"
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"

# Tasks

task scss, "Generate css":
  exec "nim r --hint[Processing]:off tools/gencss"

task md, "Render md":
  exec "nim r --hint[Processing]:off tools/rendermd"


================================================
FILE: public/browserconfig.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
    <msapplication>
        <tile>
            <TileColor>#2b5797</TileColor>
        </tile>
    </msapplication>
</browserconfig>


================================================
FILE: public/css/fontello.css
================================================
@font-face {
  font-family: "fontello";
  src: url("/fonts/fontello.eot?42791196");
  src:
    url("/fonts/fontello.eot?42791196#iefix") format("embedded-opentype"),
    url("/fonts/fontello.woff2?42791196") format("woff2"),
    url("/fonts/fontello.woff?42791196") format("woff"),
    url("/fonts/fontello.ttf?42791196") format("truetype"),
    url("/fonts/fontello.svg?42791196#fontello") format("svg");
  font-weight: normal;
  font-style: normal;
}

[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-group: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-ok:before {
  content: "\e811";
}

/* '' */
.icon-circle:before {
  content: "\f111";
}

/* '' */
.icon-info:before {
  content: "\f128";
}

/* '' */
.icon-bird:before {
  content: "\f309";
}

/* '' */


================================================
FILE: public/css/themes/auto.css
================================================
@import "nitter.css" (prefers-color-scheme: dark);
@import "twitter.css" (prefers-color-scheme: light);


================================================
FILE: public/css/themes/auto_(twitter).css
================================================
@import "twitter_dark.css" (prefers-color-scheme: dark);
@import "twitter.css" (prefers-color-scheme: light);


================================================
FILE: public/css/themes/black.css
================================================
body {
    --bg_color: #000000;
    --fg_color: #FFFFFF;
    --fg_faded: #FFFFFFD4;
    --fg_dark: var(--accent);
    --fg_nav: var(--accent);

    --bg_panel: #0C0C0C;
    --bg_elements: #000000;
    --bg_overlays: var(--bg_panel);
    --bg_hover: #131313;

    --grey: #E2E2E2;
    --dark_grey: #4E4E4E;
    --darker_grey: #272727;
    --darkest_grey: #212121;
    --border_grey: #7D7D7D;

    --accent: #FF6C60;
    --accent_light: #FFACA0;
    --accent_dark: #909090;
    --accent_border: #FF6C6091;

    --play_button: var(--accent);
    --play_button_hover: var(--accent_light);

    --more_replies_dots: #A7A7A7;
    --error_red: #420A05;

    --verified_blue: #1DA1F2;
    --icon_text: var(--fg_color);

    --tab: var(--fg_color);
    --tab_selected: var(--accent);

    --profile_stat: var(--fg_color);
}


================================================
FILE: public/css/themes/dracula.css
================================================
body {
    --bg_color: #282a36;
    --fg_color: #f8f8f2;
    --fg_faded: #818eb6;
    --fg_dark: var(--fg_faded);
    --fg_nav: var(--accent);

    --bg_panel: #343746;
    --bg_elements: #292b36;
    --bg_overlays: #44475a;
    --bg_hover: #2f323f;

    --grey: var(--fg_faded);
    --dark_grey: #44475a;
    --darker_grey: #3d4051;
    --darkest_grey: #363948;
    --border_grey: #44475a;

    --accent: #bd93f9;
    --accent_light: #caa9fa;
    --accent_dark: var(--accent);
    --accent_border: #ff79c696;

    --play_button: #ffb86c;
    --play_button_hover: #ffc689;

    --more_replies_dots: #bd93f9;
    --error_red: #ff5555;

    --verified_blue: var(--accent);
    --icon_text: ##F8F8F2;

    --tab: #6272a4;
    --tab_selected: var(--accent);

    --profile_stat: #919cbf;
}

.search-bar > form input::placeholder{
    color: var(--fg_faded);
}

================================================
FILE: public/css/themes/mastodon.css
================================================
body {
    --bg_color: #191B22;
    --fg_color: #FFFFFF;
    --fg_faded: #F8F8F2CF;
    --fg_dark: var(--accent);
    --fg_nav: var(--accent);

    --bg_panel: #282C37;
    --bg_elements: #22252F;
    --bg_overlays: var(--bg_panel);
    --bg_hover: var(--bg_elements);

    --grey: #8595AB;
    --dark_grey: #393F4F;
    --darker_grey: #1C1E23;
    --darkest_grey: #1F2125;
    --border_grey: #393F4F;

    --accent: #9BAEC8;
    --accent_light: #CDDEF5;
    --accent_dark: #748294;
    --accent_border: var(--accent_dark);

    --play_button: var(--accent);
    --play_button_hover: var(--accent_light);

    --more_replies_dots: #7F8DA0;
    --error_red: #420A05;

    --verified_blue: #1DA1F2;
    --icon_text: var(--fg_color);

    --tab: var(--accent);
    --tab_selected: #D9E1E8;

    --profile_stat: var(--fg_color);
}


================================================
FILE: public/css/themes/nitter.css
================================================
body {
    /* uses default values */
}


================================================
FILE: public/css/themes/pleroma.css
================================================
body {
    --bg_color: #0A1117;
    --fg_color: #B9B9BA;
    --fg_faded: #B9B9BAFA;
    --fg_dark: var(--accent);
    --fg_nav: var(--accent);

    --bg_panel: #121A24;
    --bg_elements: #182230;
    --bg_overlays: var(--bg_elements);
    --bg_hover: #1B2735;

    --grey: #666A6F;
    --dark_grey: #42413D;
    --darker_grey: #293442;
    --darkest_grey: #202935;
    --border_grey: #1C2737;

    --accent: #D8A070;
    --accent_light: #DEB897;
    --accent_dark: #6D533C;
    --accent_border: #947050;

    --play_button: var(--accent);
    --play_button_hover: var(--accent_light);

    --more_replies_dots: #886446;
    --error_red: #420A05;

    --verified_blue: #1DA1F2;
    --icon_text: #F8F8F8;

    --tab: var(--fg_color);
    --tab_selected: var(--accent);

    --profile_stat: var(--fg_color);
}


================================================
FILE: public/css/themes/twitter.css
================================================
body {
    --bg_color: #E6ECF0;
    --fg_color: #0F0F0F;
    --fg_faded: #657786;
    --fg_dark: var(--fg_faded);
    --fg_nav: var(--accent);

    --bg_panel: #FFFFFF;
    --bg_elements: #FDFDFD;
    --bg_overlays: #FFFFFF;
    --bg_hover: #F5F8FA;

    --grey: var(--fg_faded);
    --dark_grey: #D6D6D6;
    --darker_grey: #CECECE;
    --darkest_grey: #ECECEC;
    --border_grey: #E6ECF0;

    --accent: #1DA1F2;
    --accent_light: #A0EDFF;
    --accent_dark: var(--accent);
    --accent_border: #1DA1F296;

    --play_button: #D84D4D;
    --play_button_hover: #FF6C60;

    --more_replies_dots: #0199F7;
    --error_red: #FF7266;

    --verified_blue: var(--accent);
    --icon_text: #F8F8F2;

    --tab: var(--accent);
    --tab_selected: #000000;

    --profile_stat: var(--fg_dark);
}


================================================
FILE: public/css/themes/twitter_dark.css
================================================
body {
    --bg_color: #101821;
    --fg_color: #FFFFFF;
    --fg_faded: #8899A6;
    --fg_dark: var(--fg_faded);
    --fg_nav: var(--accent);

    --bg_panel: #15202B;
    --bg_elements: var(--bg_panel);
    --bg_overlays: var(--bg_panel);
    --bg_hover: #192734;

    --grey: var(--fg_faded);
    --dark_grey: #38444D;
    --darker_grey: #2A343C;
    --darkest_grey:#1B2835;
    --border_grey: #38444D;

    --accent: #1B95E0;
    --accent_light: #80CEFF;
    --accent_dark: #2B608A;
    --accent_border: #1B95E096;

    --play_button: var(--accent);
    --play_button_hover: var(--accent_light);

    --more_replies_dots: #39719C;
    --error_red: #FF7266;

    --verified_blue: var(--accent);
    --icon_text: var(--fg_color);

    --tab: var(--grey);
    --tab_selected: var(--accent);

    --profile_stat: var(--fg_color);
}


================================================
FILE: public/fonts/LICENSE.txt
================================================
Font license info


## Modern Pictograms

   Copyright (c) 2012 by John Caserta. All rights reserved.

   Author:    John Caserta
   License:   SIL (http://scripts.sil.org/OFL)
   Homepage:  http://thedesignoffice.org/project/modern-pictograms/


## Entypo

   Copyright (C) 2012 by Daniel Bruce

   Author:    Daniel Bruce
   License:   SIL (http://scripts.sil.org/OFL)
   Homepage:  http://www.entypo.com


## Iconic

   Copyright (C) 2012 by P.J. Onori

   Author:    P.J. Onori
   License:   SIL (http://scripts.sil.org/OFL)
   Homepage:  http://somerandomdude.com/work/iconic/


## Font Awesome

   Copyright (C) 2016 by Dave Gandy

   Author:    Dave Gandy
   License:   SIL ()
   Homepage:  http://fortawesome.github.com/Font-Awesome/


## Elusive

   Copyright (C) 2013 by Aristeides Stathopoulos

   Author:    Aristeides Stathopoulos
   License:   SIL (http://scripts.sil.org/OFL)
   Homepage:  http://aristeides.com/




================================================
FILE: public/js/hlsPlayback.js
================================================
// @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", "");
    overlay.style.display = "none";

    if (Hls.isSupported()) {
        var hls = new Hls({autoStartLoad: false});
        hls.loadSource(url);
        hls.attachMedia(video);
        hls.on(Hls.Events.MANIFEST_PARSED, function () {
            hls.loadLevel = hls.levels.length - 1;
            hls.startLoad();
            video.play();
        });
    } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
        video.src = url;
        video.addEventListener('canplay', function() {
            video.play();
        });
    }
}
// @license-end


================================================
FILE: public/js/infiniteScroll.js
================================================
// @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]);
}

function getLoadMore(doc) {
  return doc.querySelector(".show-more:not(.timeline-item)");
}

function getHrefs(selector) {
  return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
}

function getTweetId(item) {
  const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
  return m ? m[1] : "";
}

function isDuplicate(item, hrefs) {
  return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
}

const GAP = 10;

class Masonry {
  constructor(container) {
    this.container = container;
    this.colHeights = [];
    this.colCounts = [];
    this.colCount = 0;
    this._lastWidth = 0;
    this._colWidthCache = 0;
    this._items = [];
    this._revealTimer = null;
    this.container.classList.add("masonry-active");

    let resizeTimer;
    window.addEventListener("resize", () => {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => this._rebuild(), 50);
    });

    // 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;

    this._rebuild();
  }

  // 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");
  }

  // 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);
  }

  // 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`;
  }

  // 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 n = Math.max(1, Math.floor(this.container.clientWidth / 350));
    const w = this.container.clientWidth;
    if (n === this.colCount && w === this._lastWidth) return;

    const isFirst = this.colCount === 0;

    if (isFirst) {
      this._items = [...this.container.querySelectorAll(".timeline-item")];
    }

    // 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


================================================
FILE: public/md/about.md
================================================
# About

Nitter is a free and open source alternative Twitter front-end focused on
privacy and performance. The source is available on GitHub at
<https://github.com/zedeus/nitter>

- No JavaScript or ads
- All requests go through the backend, client never talks to Twitter
- Prevents Twitter from tracking your IP or JavaScript fingerprint
- Uses Twitter's unofficial API (no developer account required)
- Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
- RSS feeds
- Themes
- Mobile support (responsive design)
- AGPLv3 licensed, no proprietary instances permitted

Nitter's GitHub wiki contains
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
[browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
maintained by the community.

## Why use Nitter?

It's impossible to use Twitter without JavaScript enabled, and as of 2024 you
need to sign up. For privacy-minded folks, preventing JavaScript analytics and
IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix,
it's impossible. Despite being behind a VPN and using heavy-duty adblockers,
you can get accurately tracked with your [browser's
fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no
JavaScript required](https://noscriptfingerprint.com/). This all became
particularly important after Twitter [removed the
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
for users to control whether their data gets sent to advertisers.

Using an instance of Nitter (hosted on a VPS for example), you can browse
Twitter without JavaScript while retaining your privacy. In addition to
respecting your privacy, Nitter is on average around 15 times lighter than
Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).

In the future a simple account system will be added that lets you follow Twitter
users, allowing you to have a clean chronological timeline without needing a
Twitter account.

## Donating

Liberapay: https://liberapay.com/zedeus \
Patreon: https://patreon.com/nitter \
BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55 \
ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460 \
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL \
SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW \
ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt

## Contact

Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org).


================================================
FILE: public/robots.txt
================================================
User-agent: *
Disallow: /
Crawl-delay: 1
User-agent: Twitterbot
Disallow:


================================================
FILE: public/site.webmanifest
================================================
{
    "name": "Nitter",
    "short_name": "Nitter",
    "icons": [
        {
            "src": "/android-chrome-192x192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "/android-chrome-384x384.png",
            "sizes": "384x384",
            "type": "image/png"
        },
        {
            "src": "/android-chrome-512x512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ],
    "theme_color": "#333333",
    "background_color": "#333333",
    "display": "standalone"
}


================================================
FILE: src/api.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, strutils, sequtils, sugar
import packedjson
import types, query, formatters, consts, apiutils, parser, utils
import experimental/parser as newParser

# Helper to generate params object for GraphQL requests
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
  result.add ("variables", variables)
  result.add ("features", gqlFeatures)
  if fieldToggles.len > 0:
    result.add ("fieldToggles", fieldToggles)

proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl =
  return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))

proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
  let url = apiUrl(endpoint, variables, fieldToggles)
  return ApiReq(cookie: url, oauth: url)

proc mediaUrl(id, cursor: string; count=20): ApiReq =
  result = ApiReq(
    cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]),
    oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, $count])
  )

proc userTweetsUrl(id: string; cursor: string): ApiReq =
  result = ApiReq(
    # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
    oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
  )
  # might change this in the future pending testing
  result.cookie = result.oauth

proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
  let cookieVars = userTweetsAndRepliesVars % [id, cursor]
  result = ApiReq(
    cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
    oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"])
  )

proc tweetDetailUrl(id: string; cursor: string): ApiReq =
  let cookieVars = tweetDetailVars % [id, cursor]
  result = ApiReq(
    # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
    cookie: apiUrl(graphTweet, tweetVars % [id, cursor]),
    oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
  )

proc userUrl(username: string): ApiReq =
  let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
  result = ApiReq(
    cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
    oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
  )

proc getGraphUser*(username: string): Future[User] {.async.} =
  if username.len == 0: return
  let 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 = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
    js = await fetchRaw(url)
  result = parseGraphUser(js)

proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
  if id.len == 0: return
  let
    cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
    url = case kind
      of TimelineKind.tweets: userTweetsUrl(id, cursor)
      of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
      of TimelineKind.media: mediaUrl(id, cursor, 100)
    js = await fetch(url)
  result = parseGraphTimeline(js, after)

proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
  if id.len == 0: return
  let
    cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
    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 = apiReq(graphListBySlug, $variables)
    js = await fetch(url)
  result = parseGraphList(js)

proc getGraphList*(id: string): Future[List] {.async.} =
  let 
    url = apiReq(graphListById, """{"listId": "$1"}""" % id)
    js = await fetch(url)
  result = parseGraphList(js)

proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
  if list.id.len == 0: return
  var
    variables = %*{
      "listId": list.id,
      "withBirdwatchPivots": false,
      "withDownvotePerspective": false,
      "withReactionsMetadata": false,
      "withReactionsPerspective": false
    }
  if after.len > 0:
    variables["cursor"] = % after
  let 
    url = apiReq(graphListMembers, $variables)
    js = await fetchRaw(url)
  result = parseGraphListMembers(js, after)

proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
  if id.len == 0: return
  let
    url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
    js = await fetch(url)
  result = parseGraphTweetResult(js)

proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
  if id.len == 0: return
  let
    cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
    js = await fetch(tweetDetailUrl(id, cursor))
  result = parseGraphConversation(js, id)

proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
  result = (await getGraphTweet(id, after)).replies
  result.beginning = after.len == 0

proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
  result = await getGraphTweet(id)
  if after.len > 0:
    result.replies = await getReplies(id, after)

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.} =
  # 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)

  var
    variables = %*{
      "rawQuery": q,
      "query_source": "typedQuery",
      "count": 20,
      "product": "Latest",
      "withDownvotePerspective": false,
      "withReactionsMetadata": false,
      "withReactionsPerspective": false
    }
  if after.len > 0 and maxId.len == 0:
    variables["cursor"] = % after
  let
    url = apiReq(graphSearchTimeline, $variables)
    js = await fetch(url)
  result = parseGraphSearch[Tweets](js, after)
  result.query = query

  # when no more items are available the API just returns the last page in
  # full. this detects that and clears the page instead.
  if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and
     after[0..<64] == result.bottom[0..<64]:
    result.content.setLen(0)

proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
  if query.text.len == 0:
    return Result[User](query: query, beginning: true)

  var
    variables = %*{
      "rawQuery": query.text,
      "query_source": "typedQuery",
      "count": 20,
      "product": "People",
      "withDownvotePerspective": false,
      "withReactionsMetadata": false,
      "withReactionsPerspective": false
    }
  if after.len > 0:
    variables["cursor"] = % after
    result.beginning = false

  let 
    url = apiReq(graphSearchTimeline, $variables)
    js = await fetch(url)
  result = parseGraphSearch[User](js, after)
  result.query = query

proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
  if id.len == 0: return
  let js = await fetch(mediaUrl(id, "", 30))
  result = parseGraphPhotoRail(js)

proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
  let client = newAsyncHttpClient(maxRedirects=0)
  try:
    let resp = await client.request(url, HttpHead)
    result = resp.headers["location"].replaceUrls(prefs)
  except:
    discard
  finally:
    client.close()


================================================
FILE: src/apiutils.nim
================================================
# 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, tid
import experimental/types/common

const
  rlRemaining = "x-rate-limit-remaining"
  rlReset = "x-rate-limit-reset"
  rlLimit = "x-rate-limit-limit"
  errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}

var 
  pool: HttpPool
  disableTid: bool
  apiProxy: string

proc setDisableTid*(disable: bool) =
  disableTid = disable

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 =
  case sessionKind
  of oauth:  
    let o = req.oauth
    parseUri("https://api.x.com/graphql")   / o.endpoint ? o.params
  of cookie: 
    let c = req.cookie
    parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params

proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
  let
    encodedUrl = url.replace(",", "%2C").replace("+", "%20")
    params = OAuth1Parameters(
      consumerKey: consumerKey,
      signatureMethod: "HMAC-SHA1",
      timestamp: $int(round(epochTime())),
      nonce: "0",
      isIncludeVersionToHeader: true,
      token: oauthToken
    )
    signature = getSignature(HttpGet, encodedUrl, "", params, consumerSecret, oauthTokenSecret)

  params.signature = percentEncode(signature)

  return getOauth1RequestHeader(params)["authorization"]

proc getCookieHeader(authToken, ct0: string): string =
  "auth_token=" & authToken & "; ct0=" & ct0

proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
  result = newHttpHeaders({
    "accept": "*/*",
    "accept-encoding": "gzip",
    "accept-language": "en-US,en;q=0.9",
    "connection": "keep-alive",
    "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"
  })

  case session.kind
  of SessionKind.oauth:
    result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
  of SessionKind.cookie:
    result["x-twitter-auth-type"] = "OAuth2Session"
    result["x-csrf-token"] = session.ct0
    result["cookie"] = getCookieHeader(session.authToken, session.ct0)
    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-site"
    if disableTid:
      result["authorization"] = bearerToken2
    else:
      result["authorization"] = bearerToken
      result["x-client-transaction-id"] = await genTid(url.path)

proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
  result = await getSession(req)
  case result.kind
  of SessionKind.oauth:
    if result.oauthToken.len == 0:
      echo "[sessions] Empty oauth token, session: ", result.pretty
      raise rateLimitError()
  of SessionKind.cookie:
    if result.authToken.len == 0 or result.ct0.len == 0:
      echo "[sessions] Empty cookie credentials, session: ", result.pretty
      raise rateLimitError()

template fetchImpl(result, fetchBody) {.dirty.} =
  once:
    pool = HttpPool()

  try:
    var resp: AsyncResponse
    pool.use(await genHeaders(session, url)):
      template getContent =
        # TODO: this is a temporary simple implementation
        if apiProxy.len > 0:
          resp = await c.get(($url).replace("https://", apiProxy))
        else:
          resp = await c.get($url)
        result = await resp.body

      getContent()

      if resp.status == $Http503:
        badClient = true
        raise newException(BadClientError, "Bad client")

    if resp.headers.hasKey(rlRemaining):
      let
        remaining = parseInt(resp.headers[rlRemaining])
        reset = parseInt(resp.headers[rlReset])
        limit = parseInt(resp.headers[rlLimit])
      session.setRateLimit(req, remaining, reset, limit)

    if result.len > 0:
      if resp.headers.getOrDefault("content-encoding") == "gzip":
        result = uncompress(result, dfGzip)

      if result.startsWith("{\"errors"):
        let errors = result.fromJson(Errors)
        if errors notin errorsToSkip:
          echo "Fetch error, API: ", url.path, ", errors: ", errors
          if errors in {expiredToken, badToken, locked}:
            invalidate(session)
            raise rateLimitError()
          elif errors in {rateLimited}:
            # rate limit hit, resets after 24 hours
            setLimited(session, req)
            raise rateLimitError()
      elif result.startsWith("429 Too Many Requests"):
        echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
        raise rateLimitError()

    fetchBody

    if resp.status == $Http400:
      echo "ERROR 400, ", url.path, ": ", result
      raise newException(InternalError, $url)
  except InternalError as e:
    raise e
  except BadClientError as e:
    raise e
  except OSError as e:
    raise e
  except Exception as e:
    let s = session.pretty
    echo "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", url
    raise rateLimitError()
  finally:
    release(session)

template retry(bod) =
  try:
    bod
  except RateLimitError:
    echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..."
    bod

proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
  retry:
    var 
      body: string
      session = await getAndValidateSession(req)

    let url = req.toUrl(session.kind)

    fetchImpl body:
      if body.startsWith('{') or body.startsWith('['):
        result = parseJson(body)
      else:
        echo resp.status, ": ", body, " --- url: ", url
        result = newJNull()

      let error = result.getError
      if error != null and error notin errorsToSkip:
        echo "Fetch error, API: ", url.path, ", error: ", error
        if error in {expiredToken, badToken, locked}:
          invalidate(session)
          raise rateLimitError()

proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
  retry:
    var session = await getAndValidateSession(req)
    let url = req.toUrl(session.kind)

    fetchImpl result:
      if not (result.startsWith('{') or result.startsWith('[')):
        echo resp.status, ": ", result, " --- url: ", url
        result.setLen(0)


================================================
FILE: src/auth.nim
================================================
#SPDX-License-Identifier: AGPL-3.0-only
import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os]
import types, consts
import experimental/parser/session

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 "<null>"

  if session.id > 0 and session.username.len > 0:
    result = $session.id & " (" & session.username & ")"
  elif session.username.len > 0:
    result = session.username
  elif session.id > 0:
    result = $session.id
  else:
    result = "<unknown>"
  result = $session.kind & " " & result

proc snowflakeToEpoch(flake: int64): int64 =
  int64(((flake shr 22) + 1288834974657) div 1000)

proc getSessionPoolHealth*(): JsonNode =
  let now = epochTime().int

  var
    totalReqs = 0
    limited: PackedSet[int64]
    reqsPerApi: Table[string, int]
    oldest = now.int64
    newest = 0'i64
    average = 0'i64

  for session in sessionPool:
    let created = snowflakeToEpoch(session.id)
    if created > newest:
      newest = created
    if created < oldest:
      oldest = created
    average += created

    if session.limited:
      limited.incl session.id

    for api in session.apis.keys:
      let
        apiStatus = session.apis[api]
        reqs = apiStatus.limit - apiStatus.remaining

      # no requests made with this session and endpoint since the limit reset
      if apiStatus.reset < now:
        continue

      reqsPerApi.mgetOrPut($api, 0).inc reqs
      totalReqs.inc reqs

  if sessionPool.len > 0:
    average = average div sessionPool.len
  else:
    oldest = 0
    average = 0

  return %*{
    "sessions": %*{
      "total": sessionPool.len,
      "limited": limited.card,
      "oldest": $fromUnix(oldest),
      "newest": $fromUnix(newest),
      "average": $fromUnix(average)
    },
    "requests": %*{
      "total": totalReqs,
      "apis": reqsPerApi
    }
  }

proc getSessionPoolDebug*(): JsonNode =
  let now = epochTime().int
  var list = newJObject()

  for session in sessionPool:
    let sessionJson = %*{
      "apis": newJObject(),
      "pending": session.pending,
    }

    if session.limited:
      sessionJson["limited"] = %true

    for api in session.apis.keys:
      let
        apiStatus = session.apis[api]
        obj = %*{}

      if apiStatus.reset > now.int:
        obj["remaining"] = %apiStatus.remaining
        obj["reset"] = %apiStatus.reset

      if "remaining" notin obj:
        continue

      sessionJson{"apis", $api} = obj
      list[$session.id] = sessionJson

  return %list

proc rateLimitError*(): ref RateLimitError =
  newException(RateLimitError, "rate limited")

proc noSessionsError*(): ref NoSessionsError =
  newException(NoSessionsError, "no sessions available")

proc isLimited(session: Session; req: ApiReq): bool =
  if session.isNil:
    return true

  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
      return false
    else:
      return true

  if api in session.apis:
    let limit = session.apis[api]
    return limit.remaining <= 10 and limit.reset > epochTime().int
  else:
    return false

proc isReady(session: Session; req: ApiReq): bool =
  not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))

proc invalidate*(session: var Session) =
  if session.isNil: return
  log "invalidating: ", session.pretty

  # TODO: This isn't sufficient, but it works for now
  let idx = sessionPool.find(session)
  if idx > -1: sessionPool.delete(idx)
  session = nil

proc release*(session: Session) =
  if session.isNil: return
  dec session.pending

proc getSession*(req: ApiReq): Future[Session] {.async.} =
  for i in 0 ..< sessionPool.len:
    if result.isReady(req): break
    result = sessionPool.sample()

  if not result.isNil and result.isReady(req):
    inc result.pending
  else:
    log "no sessions available for API: ", req.cookie.endpoint
    raise noSessionsError()

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; 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:
      return
    if rateLimit.reset == reset and rateLimit.remaining >= remaining:
      session.apis[api].remaining = remaining
      return

  session.apis[api] = RateLimit(limit: limit, remaining: remaining, reset: reset)

proc initSessionPool*(cfg: Config; path: string) =
  enableLogging = cfg.enableDebug

  if path.endsWith(".json"):
    log "ERROR: .json is not supported, the file must be a valid JSONL file ending in .jsonl"
    quit 1

  if not fileExists(path):
    log "ERROR: ", path, " not found. This file is required to authenticate API requests."
    quit 1

  log "parsing JSONL account sessions file: ", path
  for line in path.lines:
    sessionPool.add parseSession(line)

  log "successfully added ", sessionPool.len, " valid account sessions"


================================================
FILE: src/config.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import parsecfg except Config
import types, strutils

proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
  let val = config.getSectionValue(section, key)
  if val.len == 0: return default

  when T is int: parseInt(val)
  elif T is bool: parseBool(val)
  elif T is string: val

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"),
    port: cfg.get("Server", "port", 8080),
    useHttps: cfg.get("Server", "https", true),
    httpMaxConns: cfg.get("Server", "httpMaxConnections", 100),
    staticDir: cfg.get("Server", "staticDir", "./public"),
    title: cfg.get("Server", "title", "Nitter"),
    hostname: cfg.get("Server", "hostname", "nitter.net"),

    # Cache
    listCacheTime: cfg.get("Cache", "listMinutes", 120),
    rssCacheTime: cfg.get("Cache", "rssMinutes", 10),

    redisHost: cfg.get("Cache", "redisHost", "localhost"),
    redisPort: cfg.get("Cache", "redisPort", 6379),
    redisConns: cfg.get("Cache", "redisConnections", 20),
    redisMaxConns: cfg.get("Cache", "redisMaxConnections", 30),
    redisPassword: cfg.get("Cache", "redisPassword", ""),

    # Config
    hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
    base64Media: cfg.get("Config", "base64Media", false),
    minTokens: cfg.get("Config", "tokenCount", 10),
    enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true),
    enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true),
    enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", 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", ""),
    apiProxy: cfg.get("Config", "apiProxy", ""),
    disableTid: cfg.get("Config", "disableTid", false),
    maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2)
  )

  return (conf, cfg)


================================================
FILE: src/consts.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import strutils

const
  consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
  consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
  bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
  bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"

  graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
  graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
  graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
  graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
  graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
  graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
  graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
  graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
  graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
  graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
  graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
  graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
  graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory"
  graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
  graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
  graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
  graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
  graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"

  gqlFeatures* = """{
  "android_ad_formats_media_component_render_overlay_enabled": false,
  "android_graphql_skip_api_media_color_palette": false,
  "android_professional_link_spotlight_display_enabled": false,
  "articles_api_enabled": false,
  "articles_preview_enabled": true,
  "blue_business_profile_image_shape_enabled": false,
  "c9s_tweet_anatomy_moderator_badge_enabled": true,
  "commerce_android_shop_module_enabled": false,
  "communities_web_enable_tweet_community_results_fetch": true,
  "creator_subscriptions_quote_tweet_preview_enabled": false,
  "creator_subscriptions_subscription_count_enabled": false,
  "creator_subscriptions_tweet_preview_api_enabled": true,
  "freedom_of_speech_not_reach_fetch_enabled": true,
  "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
  "grok_android_analyze_trend_fetch_enabled": false,
  "grok_translations_community_note_auto_translation_is_enabled": false,
  "grok_translations_community_note_translation_is_enabled": false,
  "grok_translations_post_auto_translation_is_enabled": false,
  "grok_translations_timeline_user_bio_auto_translation_is_enabled": false,
  "hidden_profile_likes_enabled": false,
  "highlights_tweets_tab_ui_enabled": false,
  "immersive_video_status_linkable_timestamps": false,
  "interactive_text_enabled": false,
  "longform_notetweets_consumption_enabled": true,
  "longform_notetweets_inline_media_enabled": true,
  "longform_notetweets_richtext_consumption_enabled": true,
  "longform_notetweets_rich_text_read_enabled": true,
  "mobile_app_spotlight_module_enabled": false,
  "payments_enabled": false,
  "post_ctas_fetch_enabled": true,
  "premium_content_api_read_enabled": false,
  "profile_label_improvements_pcf_label_in_post_enabled": true,
  "profile_label_improvements_pcf_label_in_profile_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_grok_analysis_button_from_backend": true,
  "responsive_web_grok_analyze_button_fetch_trends_enabled": false,
  "responsive_web_grok_analyze_post_followups_enabled": true,
  "responsive_web_grok_annotations_enabled": true,
  "responsive_web_grok_community_note_auto_translation_is_enabled": false,
  "responsive_web_grok_image_annotation_enabled": true,
  "responsive_web_grok_imagine_annotation_enabled": true,
  "responsive_web_grok_share_attachment_enabled": true,
  "responsive_web_grok_show_grok_translated_post": false,
  "responsive_web_jetfuel_frame": true,
  "responsive_web_media_download_video_enabled": false,
  "responsive_web_profile_redirect_enabled": false,
  "responsive_web_text_conversations_enabled": false,
  "responsive_web_twitter_article_notes_tab_enabled": false,
  "responsive_web_twitter_article_tweet_consumption_enabled": true,
  "responsive_web_twitter_blue_verified_badge_is_enabled": true,
  "rweb_lists_timeline_redesign_enabled": true,
  "rweb_tipjar_consumption_enabled": true,
  "rweb_video_screen_enabled": false,
  "rweb_video_timestamps_enabled": false,
  "spaces_2022_h2_clipping": true,
  "spaces_2022_h2_spaces_communities": true,
  "standardized_nudges_misinfo": true,
  "subscriptions_feature_can_gift_premium": false,
  "subscriptions_verification_info_enabled": true,
  "subscriptions_verification_info_is_identity_verified_enabled": false,
  "subscriptions_verification_info_reason_enabled": true,
  "subscriptions_verification_info_verified_since_enabled": true,
  "super_follow_badge_privacy_enabled": false,
  "super_follow_exclusive_tweet_notifications_enabled": false,
  "super_follow_tweet_api_enabled": false,
  "super_follow_user_api_enabled": false,
  "tweet_awards_web_tipping_enabled": false,
  "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
  "tweetypie_unmention_optimization_enabled": false,
  "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
  "unified_cards_destination_url_params_enabled": false,
  "verified_phone_label_enabled": false,
  "vibe_api_enabled": false,
  "view_counts_everywhere_api_enabled": true,
  "hidden_profile_subscriptions_enabled": false
}""".replace(" ", "").replace("\n", "")

  tweetVars* = """{
  "postId": "$1",
  $2
  "includeHasBirdwatchNotes": false,
  "includePromotedContent": false,
  "withBirdwatchNotes": true,
  "withVoice": false,
  "withV2Timeline": true
}""".replace(" ", "").replace("\n", "")

  tweetDetailVars* = """{
  "focalTweetId": "$1",
  $2
  "referrer": "profile",
  "with_rux_injections": false,
  "rankingMode": "Relevance",
  "includePromotedContent": true,
  "withCommunity": true,
  "withQuickPromoteEligibilityTweetFields": true,
  "withBirdwatchNotes": true,
  "withVoice": true
}""".replace(" ", "").replace("\n", "")

  tweetEditHistoryVars* = """{
  "tweetId": "$1",
  "withQuickPromoteEligibilityTweetFields": true
}""".replace(" ", "").replace("\n", "")

  restIdVars* = """{
  "rest_id": "$1", $2
  "count": $3
}"""

  userMediaVars* = """{
  "userId": "$1", $2
  "count": $3,
  "includePromotedContent": false,
  "withClientEventToken": false,
  "withBirdwatchNotes": false,
  "withVoice": true
}""".replace(" ", "").replace("\n", "")

  userTweetsVars* = """{
  "userId": "$1", $2
  "count": 20,
  "includePromotedContent": false,
  "withQuickPromoteEligibilityTweetFields": true,
  "withVoice": true
}""".replace(" ", "").replace("\n", "")

  userTweetsAndRepliesVars* = """{
  "userId": "$1", $2
  "count": 20,
  "includePromotedContent": false,
  "withCommunity": true,
  "withVoice": true
}""".replace(" ", "").replace("\n", "")

  userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
  userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
  tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""


================================================
FILE: src/experimental/parser/graphql.nim
================================================
import options, strutils
import jsony
import user, utils, ../types/[graphuser, graphlistmembers]
from ../../types import User, VerifiedType, Result, Query, QueryKind

proc parseUserResult*(userResult: UserResult): User =
  result = userResult.legacy

  if result.verifiedType == none and userResult.isBlueVerified:
    result.verifiedType = blue

  if result.username.len == 0 and userResult.core.screenName.len > 0:
    result.id = userResult.restId
    result.username = userResult.core.screenName
    result.fullname = userResult.core.name
    result.userPic = userResult.avatar.imageUrl.replace("_normal", "")

    if userResult.privacy.isSome:
      result.protected = userResult.privacy.get.protected

    if userResult.location.isSome:
      result.location = userResult.location.get.location

    if userResult.core.createdAt.len > 0:
      result.joinDate = parseTwitterDate(userResult.core.createdAt)

    if userResult.verification.isSome:
      let v = userResult.verification.get
      if v.verifiedType != VerifiedType.none:
        result.verifiedType = v.verifiedType

    if userResult.profileBio.isSome and result.bio.len == 0:
      result.bio = userResult.profileBio.get.description

proc parseGraphUser*(json: string): User =
  if json.len == 0 or json[0] != '{':
    return

  let 
    raw = json.fromJson(GraphUser)
    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" or
     userResult.reason.get("") == "Suspended":
    return User(suspended: true)

  result = parseUserResult(userResult)

proc parseGraphListMembers*(json, cursor: string): Result[User] =
  result = Result[User](
    beginning: cursor.len == 0,
    query: Query(kind: userList)
  )

  let raw = json.fromJson(GraphListMembers)
  for instruction in raw.data.list.membersTimeline.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


================================================
FILE: src/experimental/parser/session.nim
================================================
import std/strutils
import jsony
import ../types/session
from ../../types import Session, SessionKind

proc parseSession*(raw: string): Session =
  let session = raw.fromJson(RawSession)
  let kind = if session.kind == "": "oauth" else: session.kind

  case kind
  of "oauth":
    let id = session.oauthToken[0 ..< session.oauthToken.find('-')]
    result = Session(
      kind: SessionKind.oauth,
      id: parseBiggestInt(id),
      username: session.username,
      oauthToken: session.oauthToken,
      oauthSecret: session.oauthTokenSecret
    )
  of "cookie":
    let id = if session.id.len > 0: parseBiggestInt(session.id) else: 0
    result = Session(
      kind: SessionKind.cookie,
      id: id,
      username: session.username,
      authToken: session.authToken,
      ct0: session.ct0
    )
  else:
    raise newException(ValueError, "Unknown session kind: " & kind)


================================================
FILE: src/experimental/parser/slices.nim
================================================
import std/[macros, htmlgen, unicode]
import ../types/common
import ".."/../[formatters, utils]

type
  ReplaceSliceKind = enum
    rkRemove, rkUrl, rkHashtag, rkMention

  ReplaceSlice* = object
    slice: Slice[int]
    kind: ReplaceSliceKind
    url, display: string

proc cmp*(x, y: ReplaceSlice): int = cmp(x.slice.a, y.slice.b)

proc dedupSlices*(s: var seq[ReplaceSlice]) =
  var
    len = s.len
    i = 0
  while i < len:
    var j = i + 1
    while j < len:
      if s[i].slice.a == s[j].slice.a:
        s.del j
        dec len
      else:
        inc j
    inc i

proc extractUrls*(result: var seq[ReplaceSlice]; url: Url;
                  textLen: int; hideTwitter = false) =
  let
    link = url.expandedUrl
    slice = url.indices[0] ..< url.indices[1]

  if hideTwitter and slice.b.succ >= textLen and link.isTwitterUrl:
    if slice.a < textLen:
      result.add ReplaceSlice(kind: rkRemove, slice: slice)
  else:
    result.add ReplaceSlice(kind: rkUrl, url: link,
                            display: link.shortLink, slice: slice)

proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice];
                   textSlice: Slice[int]): string =
  template extractLowerBound(i: int; idx): int =
    if i > 0: repls[idx].slice.b.succ else: textSlice.a

  result = newStringOfCap(runes.len)

  for i, rep in repls:
    result.add $runes[extractLowerBound(i, i - 1) ..< 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?f=tweets&q=%23" & name)
    of rkMention:
      result.add a($runes[rep.slice], href = rep.url, title = rep.display)
    of rkUrl:
      result.add a(rep.display, href = rep.url)
    of rkRemove:
      discard

  let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b
  if rest.a <= rest.b:
    result.add $runes[rest]


================================================
FILE: src/experimental/parser/tid.nim
================================================
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)


================================================
FILE: src/experimental/parser/unifiedcard.nim
================================================
import std/[options, tables, strutils, strformat, sugar]
import jsony
import user, ../types/unifiedcard
import ../../formatters
from ../../types import Card, CardKind, Video
from ../../utils import twimg, https

proc getImageUrl(entity: MediaEntity): string =
  entity.mediaUrlHttps.dup(removePrefix(twimg), removePrefix(https))

proc parseDestination(id: string; card: UnifiedCard; result: var Card) =
  let destination = card.destinationObjects[id].data
  result.dest = destination.urlData.vanity
  result.url = destination.urlData.url

proc parseDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
  data.destination.parseDestination(card, result)

  result.text = data.title
  if result.text.len == 0:
    result.text = data.name

proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
  data.destination.parseDestination(card, result)

  result.kind = summary
  result.image = card.mediaEntities[data.mediaId].getImageUrl
  result.text = data.topicDetail.title
  result.dest = "Topic"

proc parseJobDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
  data.destination.parseDestination(card, result)

  result.kind = CardKind.jobDetails
  result.title = data.title
  result.text = data.shortDescriptionText
  result.dest = &"@{data.profileUser.username} · {data.location}"

proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
  let app = card.appStoreData[data.appId][0]

  case app.kind
  of androidApp:
    result.url = "http://play.google.com/store/apps/details?id=" & app.id
  of iPhoneApp, iPadApp:
    result.url = "https://itunes.apple.com/app/id" & app.id

  result.text = app.title
  result.dest = app.category

proc parseListDetails(data: ComponentData; result: var Card) =
  result.dest = &"List · {data.memberCount} Members"

proc parseCommunityDetails(data: ComponentData; result: var Card) =
  result.dest = &"Community · {data.memberCount} Members"

proc parseMedia(component: Component; card: UnifiedCard; result: var Card) =
  let mediaId =
    if component.kind == swipeableMedia:
      component.data.mediaList[0].id
    else:
      component.data.id

  let rMedia = card.mediaEntities[mediaId]
  case rMedia.kind:
  of photo:
    result.kind = summaryLarge
    result.image = rMedia.getImageUrl
  of video:
    let videoInfo = rMedia.videoInfo.get
    result.kind = promoVideo
    result.video = some Video(
      available: true,
      thumb: rMedia.getImageUrl,
      durationMs: videoInfo.durationMillis,
      variants: videoInfo.variants
    )
  of model3d:
    result.title = "Unsupported 3D model ad"

proc parseGrokShare(data: ComponentData; card: UnifiedCard; result: var Card) =
  result.kind = summaryLarge

  data.destination.parseDestination(card, result)
  result.dest = "Answer by Grok"

  for msg in data.conversationPreview:
    if msg.sender == "USER":
      result.title = msg.message.shorten(70)
    elif msg.sender == "AGENT":
      result.text = msg.message.shorten(500)

proc parseUnifiedCard*(json: string): Card =
  let card = json.fromJson(UnifiedCard)

  for component in card.componentObjects.values:
    case component.kind
    of details, communityDetails, twitterListDetails:
      component.data.parseDetails(card, result)
    of appStoreDetails:
      component.data.parseAppDetails(card, result)
    of mediaWithDetailsHorizontal:
      component.data.parseMediaDetails(card, result)
    of media, swipeableMedia:
      component.parseMedia(card, result)
    of buttonGroup:
      discard
    of grokShare:
      component.data.parseGrokShare(card, result)
    of ComponentType.jobDetails:
      component.data.parseJobDetails(card, result)
    of ComponentType.hidden:
      result.kind = CardKind.hidden
    of ComponentType.unknown:
      echo "ERROR: Unknown component type: ", json

    case component.kind
    of twitterListDetails:
      component.data.parseListDetails(result)
    of communityDetails:
      component.data.parseCommunityDetails(result)
    else: discard


================================================
FILE: src/experimental/parser/user.nim
================================================
import std/[algorithm, unicode, re, strutils, strformat, options, nre]
import jsony
import utils, slices
import ../types/user as userType
from ../../types import Result, User, Error

let
  unRegex = re.re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
  unReplace = "$1<a href=\"/$2\">@$2</a>"

  htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!</a>|">|#)"""
  htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"

proc expandUserEntities(user: var User; raw: RawUser) =
  let
    orig = user.bio.toRunes
    ent = raw.entities

  if ent.url.urls.len > 0:
    user.website = ent.url.urls[0].expandedUrl

  var replacements = newSeq[ReplaceSlice]()

  for u in ent.description.urls:
    replacements.extractUrls(u, orig.high)

  replacements.dedupSlices
  replacements.sort(cmp)

  user.bio = orig.replacedWith(replacements, 0 .. orig.len)
                 .replacef(unRegex, unReplace)
                 .replace(htRegex, htReplace)

proc getBanner(user: RawUser): string =
  if user.profileBannerUrl.len > 0:
    return user.profileBannerUrl & "/1500x500"

  if user.profileLinkColor.len > 0:
    return '#' & user.profileLinkColor

  if user.profileImageExtensions.isSome:
    let ext = get(user.profileImageExtensions)
    if ext.mediaColor.r.ok.palette.len > 0:
      let color = ext.mediaColor.r.ok.palette[0].rgb
      return &"#{color.red:02x}{color.green:02x}{color.blue:02x}"

proc toUser*(raw: RawUser): User =
  result = User(
    id: raw.idStr,
    username: raw.screenName,
    fullname: raw.name,
    location: raw.location,
    bio: raw.description,
    following: raw.friendsCount,
    followers: raw.followersCount,
    tweets: raw.statusesCount,
    likes: raw.favouritesCount,
    media: raw.mediaCount,
    verifiedType: raw.verifiedType,
    protected: raw.protected,
    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)

proc parseHook*(s: string; i: var int; v: var User) =
  var u: RawUser
  parseHook(s, i, u)
  v = toUser u


================================================
FILE: src/experimental/parser/utils.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import std/[sugar, strutils, times]
import ../types/common
import ../../utils as uutils

template parseTime(time: string; f: static string; flen: int): DateTime =
  if time.len != flen: return
  parse(time, f, utc())

proc parseIsoDate*(date: string): DateTime =
  date.parseTime("yyyy-MM-dd\'T\'HH:mm:ss\'Z\'", 20)

proc parseTwitterDate*(date: string): DateTime =
  date.parseTime("ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)

proc getImageUrl*(url: string): string =
  url.dup(removePrefix(twimg), removePrefix(https))

template handleErrors*(body) =
  if json.startsWith("{\"errors"):
    for error {.inject.} in json.fromJson(Errors).errors:
      body


================================================
FILE: src/experimental/parser.nim
================================================
import parser/[user, graphql]
export user, graphql


================================================
FILE: src/experimental/types/common.nim
================================================
from ../../types import Error

type
  Url* = object
    url*: string
    expandedUrl*: string
    displayUrl*: string
    indices*: array[2, int]

  ErrorObj* = object
    code*: Error
    message*: string

  Errors* = object
    errors*: seq[ErrorObj]

proc contains*(codes: set[Error]; errors: Errors): bool =
  for e in errors.errors:
    if e.code in codes:
      return true


================================================
FILE: src/experimental/types/graphlistmembers.nim
================================================
import graphuser

type
  GraphListMembers* = object
    data*: tuple[list: List]

  List = object
    membersTimeline*: tuple[timeline: Timeline]

  Timeline = object
    instructions*: seq[Instruction]

  Instruction = object
    kind*: string
    entries*: seq[tuple[content: Content]]

  ContentEntryType* = enum
    TimelineTimelineItem
    TimelineTimelineCursor

  Content = object
    case entryType*: ContentEntryType
    of TimelineTimelineItem:
      itemContent*: tuple[userResults: UserData]
    of TimelineTimelineCursor:
      value*: string
      cursorType*: string

proc renameHook*(v: var Instruction; fieldName: var string) =
  if fieldName == "type":
    fieldName = "kind"


================================================
FILE: src/experimental/types/graphuser.nim
================================================
import options, strutils
from ../../types import User, VerifiedType

type
  GraphUser* = object
    data*: tuple[userResult: Option[UserData], user: Option[UserData]]

  UserData* = object
    result*: UserResult

  UserCore* = object
    name*: string
    screenName*: string
    createdAt*: string

  UserBio* = object
    description*: string

  UserAvatar* = object
    imageUrl*: string

  Verification* = object
    verifiedType*: VerifiedType

  Location* = object
    location*: string

  Privacy* = object
    protected*: bool

  UserResult* = object
    legacy*: User
    restId*: string
    isBlueVerified*: bool
    core*: UserCore
    avatar*: UserAvatar
    unavailableReason*: Option[string]
    reason*: Option[string]
    privacy*: Option[Privacy]
    profileBio*: Option[UserBio]
    verification*: Option[Verification]
    location*: Option[Location]

proc enumHook*(s: string; v: var VerifiedType) =
  v = try:
    parseEnum[VerifiedType](s)
  except:
    VerifiedType.none


================================================
FILE: src/experimental/types/session.nim
================================================
type
  RawSession* = object
    kind*: string
    id*: string
    username*: string
    oauthToken*: string
    oauthTokenSecret*: string
    authToken*: string
    ct0*: string


================================================
FILE: src/experimental/types/tid.nim
================================================
type
  TidPair* = object
    animationKey*: string
    verification*: string


================================================
FILE: src/experimental/types/unifiedcard.nim
================================================
import std/[options, tables, times]
import jsony
from ../../types import VideoType, VideoVariant, User

type
  Text* = distinct string

  UnifiedCard* = object
    componentObjects*: Table[string, Component]
    destinationObjects*: Table[string, Destination]
    mediaEntities*: Table[string, MediaEntity]
    appStoreData*: Table[string, seq[AppStoreData]]

  ComponentType* = enum
    details
    media
    swipeableMedia
    buttonGroup
    jobDetails
    appStoreDetails
    twitterListDetails
    communityDetails
    mediaWithDetailsHorizontal
    hidden
    grokShare
    unknown

  Component* = object
    kind*: ComponentType
    data*: ComponentData

  ComponentData* = object
    id*: string
    appId*: string
    mediaId*: string
    destination*: string
    location*: string
    title*: Text
    subtitle*: Text
    name*: Text
    memberCount*: int
    mediaList*: seq[MediaItem]
    topicDetail*: tuple[title: Text]
    profileUser*: User
    shortDescriptionText*: string
    conversationPreview*: seq[GrokConversation]

  MediaItem* = object
    id*: string
    destination*: string

  Destination* = object
    kind*: string
    data*: tuple[urlData: UrlData]

  UrlData* = object
    url*: string
    vanity*: string

  MediaType* = enum
    photo, video, model3d

  MediaEntity* = object
    kind*: MediaType
    mediaUrlHttps*: string
    videoInfo*: Option[VideoInfo]

  VideoInfo* = object
    durationMillis*: int
    variants*: seq[VideoVariant]

  AppType* = enum
    androidApp, iPhoneApp, iPadApp

  AppStoreData* = object
    kind*: AppType
    id*: string
    title*: Text
    category*: Text

  GrokConversation* = object
    message*: string
    sender*: string

  TypeField = Component | Destination | MediaEntity | AppStoreData

converter fromText*(text: Text): string = string(text)

proc renameHook*(v: var TypeField; fieldName: var string) =
  if fieldName == "type":
    fieldName = "kind"

proc enumHook*(s: string; v: var ComponentType) =
  v = case s
      of "details": details
      of "media": media
      of "swipeable_media": swipeableMedia
      of "button_group": buttonGroup
      of "job_details": jobDetails
      of "app_store_details": appStoreDetails
      of "twitter_list_details": twitterListDetails
      of "community_details": communityDetails
      of "media_with_details_horizontal": mediaWithDetailsHorizontal
      of "commerce_drop_details": hidden
      of "grok_share": grokShare
      else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown

proc enumHook*(s: string; v: var AppType) =
  v = case s
      of "android_app": androidApp
      of "iphone_app": iPhoneApp
      of "ipad_app": iPadApp
      else: echo "ERROR: Unknown enum value (AppType): ", s; androidApp

proc enumHook*(s: string; v: var MediaType) =
  v = case s
      of "video": video
      of "photo": photo
      of "model3d": model3d
      else: echo "ERROR: Unknown enum value (MediaType): ", s; photo

proc parseHook*(s: string; i: var int; v: var DateTime) =
  var str: string
  parseHook(s, i, str)
  v = parse(str, "yyyy-MM-dd hh:mm:ss")

proc parseHook*(s: string; i: var int; v: var Text) =
  if s[i] == '"':
    var str: string
    parseHook(s, i, str)
    v = Text(str)
  else:
    var t: tuple[content: string]
    parseHook(s, i, t)
    v = Text(t.content)


================================================
FILE: src/experimental/types/user.nim
================================================
import options
import common
from ../../types import VerifiedType

type
  RawUser* = object
    idStr*: string
    name*: string
    screenName*: string
    location*: string
    description*: string
    entities*: Entities
    createdAt*: string
    followersCount*: int
    friendsCount*: int
    favouritesCount*: int
    statusesCount*: int
    mediaCount*: int
    verifiedType*: VerifiedType
    protected*: bool
    profileLinkColor*: string
    profileBannerUrl*: string
    profileImageUrlHttps*: string
    profileImageExtensions*: Option[ImageExtensions]
    pinnedTweetIdsStr*: seq[string]

  Entities* = object
    url*: Urls
    description*: Urls

  Urls* = object
    urls*: seq[Url]

  ImageExtensions = object
    mediaColor*: tuple[r: Ok]

  Ok = object
    ok*: Palette

  Palette = object
    palette*: seq[tuple[rgb: Color]]

  Color* = object
    red*, green*, blue*: int


================================================
FILE: src/formatters.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
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://x.com")

let
  twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
  twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
  xRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?x\.com"
  xLinkRegex = re"""<a href="https:\/\/x.com([^"]+)">x\.com(\S+)</a>"""

  ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})

  rdRegex = re"(?<![.b])((www|np|new|amp|old)\.)?reddit.com"
  rdShortRegex = re"(?<![.b])redd\.it\/"
  # Videos cannot be supported uniformly between Teddit and Libreddit,
  # so v.redd.it links will not be replaced.
  # Images aren't supported due to errors from Teddit when the image
  # wasn't first displayed via a post on the Teddit instance.

  wwwRegex = re"https?://(www[0-9]?\.)?"
  m3u8Regex = re"""url="(.+.m3u8)""""
  userPicRegex = re"_(normal|bigger|mini|200x200|400x400)(\.[A-z]+)$"
  extRegex = re"(\.[A-z]+)$"
  illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]"

proc getUrlPrefix*(cfg: Config): string =
  if cfg.useHttps: https & cfg.hostname
  else: "http://" & cfg.hostname

proc shorten*(text: string; length=28): string =
  result = text
  if result.len > length:
    result = result[0 ..< length] & "…"

proc shortLink*(text: string; length=28): string =
  result = text.replace(wwwRegex, "").shorten(length)
    
proc stripHtml*(text: string; shorten=false): string =
  var html = parseHtml(text)
  for el in html.findAll("a"):
    let link = el.attr("href")
    if "http" in link:
      if el.len == 0: continue
      el[0].text =
        if shorten: link.shortLink
        else: link
  html.innerText()

proc sanitizeXml*(text: string): string =
  text.replace(illegalXmlRegex, "")

proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
  result = body

  if prefs.replaceYouTube.len > 0 and "youtu" in result:
    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 & twitterHost & "/t.co")
    if "x.com" in result:
      result = result.replace(xRegex, twitterHost)
      result = result.replacef(xLinkRegex, a(
        twitterHost & "$2", href = https & twitterHost & "$1"))
    if "twitter.com" in result:
      result = result.replace(cards, twitterHost & "/cards")
      result = result.replace(twRegex, twitterHost)
      result = result.replacef(twLinkRegex, a(
        twitterHost & "$2", href = https & twitterHost & "$1"))

  if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" 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:
    result = result.replace("href=\"/", &"href=\"{absolute}/")

proc getM3u8Url*(content: string): string =
  var matches: array[1, string]
  if re.find(content, m3u8Regex, matches) != -1:
    result = matches[0]

proc proxifyVideo*(manifest: string; proxy: bool): string =
  var replacements: seq[(string, string)]
  for line in manifest.splitLines:
    let url =
      if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
      elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
        line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
      else: line
    if url.startsWith('/'):
      let path = "https://video.twimg.com" & url
      replacements.add (url, if proxy: path.getVidUrl else: path)
  return manifest.multiReplace(replacements)

proc getUserPic*(userPic: string; style=""): string =
  userPic.replacef(userPicRegex, "$2").replacef(extRegex, style & "$1")

proc getUserPic*(user: User; style=""): string =
  getUserPic(user.userPic, style)

proc getVideoEmbed*(cfg: Config; id: int64): string =
  &"{getUrlPrefix(cfg)}/i/videos/{id}"

proc pageTitle*(user: User): string =
  &"{user.fullname} (@{user.username})"

proc pageTitle*(tweet: Tweet): string =
  &"{pageTitle(tweet.user)}: \"{stripHtml(tweet.text)}\""

proc pageDesc*(user: User): string =
  if user.bio.len > 0:
    stripHtml(user.bio)
  else:
    "The latest tweets from " & user.fullname

proc getJoinDate*(user: User): string =
  user.joinDate.format("'Joined' MMMM YYYY")

proc getJoinDateFull*(user: User): string =
  user.joinDate.format("h:mm tt - d MMM YYYY")

proc getTime*(tweet: Tweet): string =
  tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'")

proc getRfc822Time*(tweet: Tweet): string =
  tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")

proc getShortTime*(tweet: Tweet): string =
  let now = now()
  let since = now - tweet.time

  if now.year != tweet.time.year:
    result = tweet.time.format("d MMM yyyy")
  elif since.inDays >= 1:
    result = tweet.time.format("MMM d")
  elif since.inHours >= 1:
    result = $since.inHours & "h"
  elif since.inMinutes >= 1:
    result = $since.inMinutes & "m"
  elif since.inSeconds > 1:
    result = $since.inSeconds & "s"
  else:
    result = "now"

proc getDuration*(video: Video): string =
  let 
    ms = video.durationMs
    sec = int(round(ms / 1000))
    min = floorDiv(sec, 60)
    hour = floorDiv(min, 60)
  if hour > 0:
    return &"{hour}:{min mod 60}:{sec mod 60:02}"
  else:
    return &"{min mod 60}:{sec mod 60:02}"

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
  return getLink(tweet.id, username, focus)

proc getTwitterLink*(path: string; params: Table[string, string]): string =
  var
    username = params.getOrDefault("name")
    query = initQuery(params, username)
    path = path

  if "," in username:
    query.fromUser = username.split(",")
    path = "/search"

  if "/search" notin path and query.fromUser.len < 2:
    return $(twitter / path)

  let p = {
    "f": if query.kind == users: "user" else: "live",
    "q": genQueryParam(query),
    "src": "typed_query"
  }

  result = $(twitter / path ? p)
  if username.len > 0:
    result = result.replace("/" & username, "")

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?f=tweets&q=place:" & loc[1] else: ""
  (loc[0], url)

proc getSuspended*(username: string): string =
  &"User \"{username}\" has been suspended"

proc titleize*(str: string): string =
  const
    lowercase = {'a'..'z'}
    delims = {' ', '('}

  result = str
  for i, c in enumerate(str):
    if c in lowercase and (i == 0 or str[i - 1] in delims):
      result[i] = c.toUpperAscii


================================================
FILE: src/http_pool.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import httpclient

type
  HttpPool* = ref object
    conns*: seq[AsyncHttpClient]

var
  maxConns: int
  proxy: Proxy

proc setMaxHttpConns*(n: int) =
  maxConns = n

proc setHttpProxy*(url: string; auth: string) =
  if url.len > 0:
    proxy = newProxy(url, auth)
  else:
    proxy = nil

proc release*(pool: HttpPool; client: AsyncHttpClient; badClient=false) =
  if pool.conns.len >= maxConns or badClient:
    try: client.close()
    except: discard
  elif client != nil:
    pool.conns.insert(client)

proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient =
  if pool.conns.len == 0:
    result = newAsyncHttpClient(headers=heads, proxy=proxy)
  else:
    result = pool.conns.pop()
    result.headers = heads

template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped =
  var
    c {.inject.} = pool.acquire(heads)
    badClient {.inject.} = false

  try:
    body
  except BadClientError, ProtocolError:
    # Twitter returned 503 or closed the connection, we need a new client
    pool.release(c, true)
    badClient = false
    c = pool.acquire(heads)
    body
  finally:
    pool.release(c, badClient)


================================================
FILE: src/nitter.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strformat, logging
from net import Port
from htmlgen import a
from os import getEnv

import jester

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]

const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues"

let
  configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
  (cfg, fullCfg) = getConfig(configPath)

  sessionsPath = getEnv("NITTER_SESSIONS_FILE", "./sessions.jsonl")

initSessionPool(cfg, sessionsPath)

if not cfg.enableDebug:
  # Silence Jester's query warning
  addHandler(newConsoleLogger())
  setLogFilter(lvlError)

stdout.write &"Starting Nitter at {getUrlPrefix(cfg)}\n"
stdout.flushFile

updateDefaultPrefs(fullCfg)
setCacheTimes(cfg)
setHmacKey(cfg.hmacKey)
setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth)
setApiProxy(cfg.apiProxy)
setDisableTid(cfg.disableTid)
setMaxConcurrentReqs(cfg.maxConcurrentReqs)
initAboutPage(cfg.staticDir)

waitFor initRedisPool(cfg)
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
stdout.flushFile

createUnsupportedRouter(cfg)
createResolverRouter(cfg)
createPrefRouter(cfg)
createTimelineRouter(cfg)
createListRouter(cfg)
createStatusRouter(cfg)
createSearchRouter(cfg)
createMediaRouter(cfg)
createEmbedRouter(cfg)
createRssRouter(cfg)
createDebugRouter(cfg)

settings:
  port = Port(cfg.port)
  staticDir = cfg.staticDir
  bindAddr = cfg.address
  reusePort = true

routes:
  before:
    # skip all file URLs
    cond "." notin request.path
    applyUrlPrefs()

  get "/":
    resp renderMain(renderSearch(), request, cfg, requestPrefs())

  get "/about":
    resp renderMain(renderAbout(), request, cfg, requestPrefs())

  get "/explore":
    redirect("/about")

  get "/help":
    redirect("/about")

  get "/i/redirect":
    let url = decodeUrl(@"url")
    if url.len == 0: resp Http404
    redirect(replaceUrls(url, requestPrefs()))

  error Http404:
    resp Http404, showError("Page not found", cfg)

  error InternalError:
    echo error.exc.name, ": ", error.exc.msg
    const link = a("open a GitHub issue", href = issuesUrl)
    resp Http500, showError(
      &"An error occurred, please {link} with the URL you tried to visit.", cfg)

  error BadClientError:
    echo error.exc.name, ": ", error.exc.msg
    resp Http500, showError("Network error occurred, please try again.", cfg)

  error RateLimitError:
    const link = a("another instance", href = instancesUrl)
    resp Http429, showError(
      &"Instance has been rate limited.<br>Use {link} or try again later.", cfg)

  error NoSessionsError:
    const link = a("another instance", href = instancesUrl)
    resp Http429, showError(
      &"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg)

  extend rss, ""
  extend status, ""
  extend search, ""
  extend timeline, ""
  extend media, ""
  extend list, ""
  extend preferences, ""
  extend resolver, ""
  extend embed, ""
  extend debug, ""
  extend unsupported, ""


================================================
FILE: src/parser.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, options, times, math, tables
import packedjson, packedjson/deserialiser
import types, parserutils, utils
import experimental/parser/unifiedcard

proc parseGraphTweet(js: JsonNode): Tweet

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
  result = User(
    id: if id.len > 0: id else: js{"id_str"}.getStr,
    username: js{"screen_name"}.getStr,
    fullname: js{"name"}.getStr,
    location: js{"location"}.getStr,
    bio: js{"description"}.getStr,
    userPic: js{"profile_image_url_https"}.getImageStr.replace("_normal", ""),
    banner: js.getBanner,
    following: js{"friends_count"}.getInt,
    followers: js{"followers_count"}.getInt,
    tweets: js{"statuses_count"}.getInt,
    likes: js{"favourites_count"}.getInt,
    media: js{"media_count"}.getInt,
    protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
    joinDate: js{"created_at"}.getTime
  )

  if js{"is_blue_verified"}.getBool(false):
    result.verifiedType = blue

  with verifiedType, js{"verified_type"}:
    result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)

  result.expandUserEntities(js)

proc parseGraphUser(js: JsonNode): User =
  var user = js{"user_result", "result"}
  if user.isNull:
    user = ? js{"user_results", "result"}

  if user.isNull:
    if js{"core"}.notNull and js{"legacy"}.notNull:
      user = js
    else:
      return

  result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)

  if result.verifiedType == none and user{"is_blue_verified"}.getBool(false):
    result.verifiedType = blue

  # fallback to support UserMedia/recent GraphQL updates
  if result.username.len == 0:
    result.username = user{"core", "screen_name"}.getStr
    result.fullname = user{"core", "name"}.getStr
    result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")

    if user{"is_blue_verified"}.getBool(false):
      result.verifiedType = blue

    with verifiedType, user{"verification", "verified_type"}:
      result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)

proc parseGraphList*(js: JsonNode): List =
  if js.isNull: return

  var list = js{"data", "user_by_screen_name", "list"}
  if list.isNull:
    list = js{"data", "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
  )

proc parsePoll(js: JsonNode): Poll =
  let vals = js{"binding_values"}
  # name format is pollNchoice_*
  for i in '1' .. js{"name"}.getStr[4]:
    let choice = "choice" & i
    result.values.add parseInt(vals{choice & "_count"}.getStrVal("0"))
    result.options.add vals{choice & "_label"}.getStrVal

  let time = vals{"end_datetime_utc", "string_value"}.getDateTime
  if time > now():
    let timeLeft = $(time - now())
    result.status = timeLeft[0 ..< timeLeft.find(",")]
  else:
    result.status = "Final results"

  result.leader = result.values.find(max(result.values))
  result.votes = result.values.sum

proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] =
  result = @[]
  for v in variants:
    let
      url = v{"url"}.getStr
      contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4"))
      bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0))

    result.add VideoVariant(
      contentType: contentType,
      bitrate: bitrate,
      url: url,
      resolution: if contentType == mp4: getMp4Resolution(url) else: 0
    )

proc parseVideo(js: JsonNode): Video =
  result = Video(
    thumb: js{"media_url_https"}.getImageStr,
    available: true,
    title: js{"ext_alt_text"}.getStr,
    durationMs: js{"video_info", "duration_millis"}.getInt
    # playbackType: mp4
  )

  with status, js{"ext_media_availability", "status"}:
    if status.getStr.len > 0 and status.getStr.toLowerAscii != "available":
      result.available = false

  with title, js{"additional_media_info", "title"}:
    result.title = title.getStr

  with description, js{"additional_media_info", "description"}:
    result.description = description.getStr

  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.media.addMedia(Photo(
          url: m{"media_url_https"}.getImageStr,
          altText: m{"ext_alt_text"}.getStr
        ))
      of "video":
        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))
      of "animated_gif":
        result.media.addMedia(Gif(
          url: m{"video_info", "variants"}[0]{"url"}.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":
          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"}
          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"})
          ))
        of "ApiGif":
          parsedMedia.addMedia(Gif(
            url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
            thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
            altText: mediaInfo{"alt_text"}.getStr
          ))
        else: discard

    if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
      result.media = parsedMedia

  # Remove media URLs from text
  with mediaList, js{"legacy", "entities", "media"}:
    for url in mediaList:
      let expandedUrl = url.getExpandedUrl
      if result.text.endsWith(expandedUrl):
        result.text.removeSuffix(expandedUrl)
        result.text = result.text.strip()

proc parsePromoVideo(js: JsonNode): Video =
  result = Video(
    thumb: js{"player_image_large"}.getImageVal,
    available: true,
    durationMs: js{"content_duration_seconds"}.getStrVal("0").parseInt * 1000,
    playbackType: vmap
  )

  var variant = VideoVariant(
    contentType: vmap,
    url: js{"player_hls_url"}.getStrVal(js{"player_stream_url"}.getStrVal(
        js{"amplify_url_vmap"}.getStrVal()))
  )

  if "m3u8" in variant.url:
    variant.contentType = m3u8
    result.playbackType = m3u8

  result.variants.add variant

proc parseBroadcast(js: JsonNode): Card =
  let image = js{"broadcast_thumbnail_large"}.getImageVal
  result = Card(
    kind: broadcast,
    url: js{"broadcast_url"}.getStrVal,
    title: js{"broadcaster_display_name"}.getStrVal,
    text: js{"broadcast_title"}.getStrVal,
    image: image,
    video: some Video(thumb: image)
  )

proc parseCard(js: JsonNode; urls: JsonNode): Card =
  const imageTypes = ["summary_photo_image", "player_image", "promo_image",
                      "photo_image_full_size", "thumbnail_image", "thumbnail",
                      "event_thumbnail", "image"]
  let
    vals = ? js{"binding_values"}
    name = js{"name"}.getStr
    kind = parseEnum[CardKind](name[(name.find(":") + 1) ..< name.len], unknown)

  if kind == unified:
    return parseUnifiedCard(vals{"unified_card", "string_value"}.getStr)

  result = Card(
    kind: kind,
    url: vals.getCardUrl(kind),
    dest: vals.getCardDomain(kind),
    title: vals.getCardTitle(kind),
    text: vals{"description"}.getStrVal
  )

  if result.url.len == 0:
    result.url = js{"url"}.getStr

  case kind
  of promoVideo, promoVideoConvo, appPlayer, videoDirectMessage:
    result.video = some parsePromoVideo(vals)
    if kind == appPlayer:
      result.text = vals{"app_category"}.getStrVal(result.text)
  of broadcast:
    result = parseBroadcast(vals)
  of liveEvent:
    result.text = vals{"event_title"}.getStrVal
  of player:
    result.url = vals{"player_url"}.getStrVal
    if "youtube.com" in result.url:
      result.url = result.url.replace("/embed/", "/watch?v=")
  of audiospace, unknown:
    result.title = "This card type is not supported."
  else: discard

  for typ in imageTypes:
    with img, vals{typ & "_large"}:
      result.image = img.getImageVal
      break

  for u in ? urls:
    if u{"url"}.getStr == result.url:
      result.url = u.getExpandedUrl(result.url)
      break

  if kind in {videoDirectMessage, imageDirectMessage}:
    result.url.setLen 0

  if kind in {promoImageConvo, promoImageApp, imageDirectMessage} and
     result.url.len == 0 or result.url.startsWith("card://"):
    result.url = getPicUrl(result.image)

proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
                replyId: int64 = 0): Tweet =
  if js.isNull: return

  let time =
    if js{"created_at"}.notNull: js{"created_at"}.getTime
    else: js{"created_at_ms"}.getTimeFromMs

  result = Tweet(
    id: js{"id_str"}.getId,
    threadId: js{"conversation_id_str"}.getId,
    replyId: js{"in_reply_to_status_id_str"}.getId,
    text: js{"full_text"}.getStr,
    time: time,
    hasThread: js{"self_thread"}.notNull,
    available: true,
    user: User(id: js{"user_id_str"}.getStr),
    stats: TweetStats(
      replies: js{"reply_count"}.getInt,
      retweets: js{"retweet_count"}.getInt,
      likes: js{"favorite_count"}.getInt,
      views: js{"views_count"}.getInt
    )
  )

  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

  if "retweeted_status" in js:
    result.retweet = some Tweet()
  elif js{"is_quote_status"}.getBool:
    result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)

  # legacy
  with rt, js{"retweeted_status_id_str"}:
    result.retweet = some Tweet(id: rt.getId)
    return

  # 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:
      result.retweet = some parseGraphTweet(rt)
      return

  with reposts, js{"repostedStatusResults"}:
    with rt, reposts{"result"}:
      if "legacy" in rt:
        result.retweet = some parseGraphTweet(rt)
        return

  if jsCard.kind != JNull:
    let name = jsCard{"name"}.getStr
    if "poll" in name:
      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"}))
    else:
      result.card = some parseCard(jsCard, js{"entities", "urls"})

  result.expandTweetEntities(js)
  parseLegacyMediaEntities(js, result)

  with jsWithheld, js{"withheld_in_countries"}:
    let withheldInCountries: seq[string] =
      if jsWithheld.kind != JArray: @[]
      else: jsWithheld.to(seq[string])

    # XX - Content is withheld in all countries
    # XY - Content is withheld due to a DMCA request.
    if js{"withheld_copyright"}.getBool or
       withheldInCountries.len > 0 and ("XX" in withheldInCountries or
                                        "XY" in withheldInCountries or
                                        "withheld" in result.text):
      result.text.removeSuffix(" Learn more.")
      result.available = false

proc parseGraphTweet(js: JsonNode): Tweet =
  if js.kind == JNull:
    return Tweet()

  case js.getTypeName:
  of "TweetUnavailable":
    return Tweet()
  of "TweetTombstone":
    with text, select(js{"tombstone", "richText"}, js{"tombstone", "text"}):
      return Tweet(text: text.getTombstone)
    return Tweet()
  of "TweetPreviewDisplay":
    return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
  of "TweetWithVisibilityResults":
    return parseGraphTweet(js{"tweet"})
  else:
    discard

  if not js.hasKey("legacy"):
    return Tweet()

  var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
  if jsCard.kind != JNull:
    let legacyCard = jsCard{"legacy"}
    if legacyCard.kind != JNull:
      let bindingArray = legacyCard{"binding_values"}
      if bindingArray.kind == JArray:
        var bindingObj: seq[(string, JsonNode)]
        for item in bindingArray:
          bindingObj.add((item{"key"}.getStr, item{"value"}))
        # Create a new card object with flattened structure
        jsCard = %*{
          "name": legacyCard{"name"},
          "url": legacyCard{"url"},
          "binding_values": %bindingObj
        }

  var replyId = 0
  with restId, js{"reply_to_results", "rest_id"}:
    replyId = restId.getId

  result = parseTweet(js{"legacy"}, jsCard, replyId)
  result.id = js{"rest_id"}.getId
  result.user = parseGraphUser(js{"core"})

  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

  with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
    result.expandNoteTweetEntities(noteTweet)

  parseMediaEntities(js, result)

  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 parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
  for t in ? js{"content", "items"}:
    let entryId = t.getEntryId
    if "tweet-" in entryId and "promoted" notin entryId:
      let tweet = t.getTweetResult("item")
      if tweet.notNull:
        result.thread.content.add parseGraphTweet(tweet)

        let tweetDisplayType = select(
          t{"item", "content", "tweet_display_type"},
          t{"item", "itemContent", "tweetDisplayType"}
        )
        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 parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
  result = Conversation(replies: Result[Chain](beginning: true))

  let instructions = ? select(
    js{"data", "timelineResponse", "instructions"},
    js{"data", "timeline_response", "instructions"},
    js{"data", "threaded_conversation_with_injections_v2", "instructions"}
  )
  if instructions.len == 0:
    return

  for i in instructions:
    if i.getTypeName == "TimelineAddEntries":
      for e in i{"entries"}:
        let entryId = e.getEntryId
        if entryId.startsWith("tweet-"):
          let tweetResult = getTweetResult(e)
          if tweetResult.notNull:
            let tweet = parseGraphTweet(tweetResult)

            if not tweet.available:
              tweet.id = entryId.getId

            if entryId.endsWith(tweetId):
              result.tweet = tweet
            else:
              result.before.content.add tweet
          elif not entryId.endsWith(tweetId):
            result.before.content.add Tweet(id: entryId.getId)
        elif entryId.startsWith("conversationthread"):
          let (thread, self) = parseGraphThread(e)
          if self:
            result.after = thread
          elif thread.content.len > 0:
            result.replies.content.add thread
        elif entryId.startsWith("tombstone"):
          let
            content = select(e{"content", "content"}, e{"content", "itemContent"})
            tweet = Tweet(
              id: entryId.getId,
              available: false,
              text: content{"tombstoneInfo", "richText"}.getTombstone
            )

          if $tweet.id == tweetId:
            result.tweet = tweet
          else:
            result.before.content.add tweet
        elif entryId.startsWith("cursor-bottom"):
          var cursorValue = select(
            e{"content", "value"},
            e{"content", "content", "value"},
            e{"content", "itemContent", "value"}
          )
          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)

proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
  with tweetResult, getTweetResult(e):
    var tweet = parseGraphTweet(tweetResult)
    if not tweet.available:
      tweet.id = e.getEntryId.getId
    result.add tweet
    return

  for item in e{"content", "items"}:
    with tweetResult, item.getTweetResult("item"):
      var tweet = parseGraphTweet(tweetResult)
      if not tweet.available:
        tweet.id = item.getEntryId.getId
      result.add tweet

proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
  result = Profile(tweets: Timeline(beginning: after.len == 0))

  let instructions = ? select(
    js{"data", "list", "timeline_response", "timeline", "instructions"},
    js{"data", "user", "result", "timeline", "timeline", "instructions"},
    js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
  )
  if instructions.len == 0:
    return

  for i in instructions:
    if i{"moduleItems"}.notNull:
      for item in i{"moduleItems"}:
        with tweetResult, item.getTweetResult("item"):
          let tweet = parseGraphTweet(tweetResult)
          if not tweet.available:
            tweet.id = item.getEntryId.getId
          result.tweets.content.add tweet
      continue

    if i{"entries"}.notNull:
      for e in i{"entries"}:
        let entryId = e.getEntryId
        if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
          for tweet in extractTweetsFromEntry(e):
            result.tweets.content.add tweet
        elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
          let (thread, self) = parseGraphThread(e)
          result.tweets.content.add thread.content
        elif entryId.startsWith("cursor-bottom"):
          result.tweets.bottom = e{"content", "value"}.getStr

    if after.len == 0:
      if i.getTypeName == "TimelinePinEntry":
        let tweets = extractTweetsFromEntry(i{"entry"})
        if tweets.len > 0:
          var tweet = tweets[0]
          tweet.pinned = true
          result.pinned = some tweet

proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
  result = @[]

  let instructions = select(
    js{"data", "user", "result", "timeline", "timeline", "instructions"},
    js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
  )
  if instructions.len == 0:
    return

  for i in instructions:
    if i{"moduleItems"}.notNull:
      for item in i{"moduleItems"}:
        with tweetResult, item.getTweetResult("item"):
          let t = parseGraphTweet(tweetResult)
          if not t.available:
            t.id = item.getEntryId.getId

          let photo = extractGalleryPhoto(t)
          if photo.url.len > 0:
            result.add photo

          if result.len == 16:
            return
      continue

    if i.getTypeName != "TimelineAddEntries":
      continue

    for e in i{"entries"}:
      let entryId = e.getEntryId
      if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
        for t in extractTweetsFromEntry(e):
          let photo = extractGalleryPhoto(t)
          if photo.url.len > 0:
            result.add photo

          if result.len == 16:
            return

proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
  result = Result[T](beginning: after.len == 0)

  let instructions = select(
    js{"data", "search", "timeline_response", "timeline", "instructions"},
    js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
  )
  if instructions.len == 0:
    return

  for instruction in instructions:
    let typ = getTypeName(instruction)
    if typ == "TimelineAddEntries":
      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
              result.content.add tweet
        elif T is User:
          if entryId.startsWith("user"):
            with userRes, e{"content", "itemContent"}:
              result.content.add parseGraphUser(userRes)

        if entryId.startsWith("cursor-bottom"):
          result.bottom = e{"content", "value"}.getStr
    elif typ == "TimelineReplaceEntry":
      if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
        result.bottom = instruction{"entry", "content", "value"}.getStr


================================================
FILE: src/parserutils.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import std/[times, macros, htmlgen, options, algorithm, re]
import std/strutils except escape
import std/unicode except strip
from xmltree import escape
import packedjson
import types, utils, formatters

const
  unicodeOpen = "\uFFFA"
  unicodeClose = "\uFFFB"
  xmlOpen = escape("<")
  xmlClose = escape(">")

let
  unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
  unReplace = "$1<a href=\"/$2\">@$2</a>"

  htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)"
  htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"

type
  ReplaceSliceKind = enum
    rkRemove, rkUrl, rkHashtag, rkMention

  ReplaceSlice = object
    slice: Slice[int]
    kind: ReplaceSliceKind
    url, display: string

template isNull*(js: JsonNode): bool = js.kind == JNull
template notNull*(js: JsonNode): bool = js.kind != JNull

template `?`*(js: JsonNode): untyped =
  let j = js
  if j.isNull: return
  j

template select*(a, b: JsonNode): untyped =
  if a.notNull: a else: b

template select*(a, b, c: JsonNode): untyped =
  if a.notNull: a elif b.notNull: b else: c

template with*(ident, value, body): untyped =
  if true:
    let ident {.inject.} = value
    if ident != nil: body

template with*(ident; value: JsonNode; body): untyped =
  if true:
    let ident {.inject.} = value
    if value.notNull: body

template getCursor*(js: JsonNode): string =
  js{"content", "operation", "cursor", "value"}.getStr

template getError*(js: JsonNode): Error =
  if js.kind != JArray or js.len == 0: null
  else: Error(js[0]{"code"}.getInt)

proc getTweetResult*(js: JsonNode; root="content"): JsonNode =
  select(
    js{root, "content", "tweet_results", "result"},
    js{root, "itemContent", "tweet_results", "result"},
    js{root, "content", "tweetResult", "result"}
  )

template getTypeName*(js: JsonNode): string =
  js{"__typename"}.getStr(js{"type"}.getStr)

template getEntryId*(e: JsonNode): string =
  e{"entryId"}.getStr(e{"entry_id"}.getStr)

template parseTime(time: string; f: static string; flen: int): DateTime =
  if time.len != flen: return
  parse(time, f, utc())

proc getDateTime*(js: JsonNode): DateTime =
  parseTime(js.getStr, "yyyy-MM-dd\'T\'HH:mm:ss\'Z\'", 20)

proc getTime*(js: JsonNode): DateTime =
  parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)

proc getTimeFromMs*(js: JsonNode): DateTime =
  let ms = js.getInt(0)
  if ms == 0: return
  let seconds = ms div 1000
  return fromUnix(seconds).utc()

proc getId*(id: string): int64 {.inline.} =
  let start = id.rfind("-")
  if start < 0:
    return parseBiggestInt(id)
  return parseBiggestInt(id[start + 1 ..< id.len])

proc getId*(js: JsonNode): int64 {.inline.} =
  case js.kind
  of JString: return js.getStr("0").getId
  of JInt: return js.getBiggestInt()
  else: return 0

template getStrVal*(js: JsonNode; default=""): string =
  js{"string_value"}.getStr(default)

proc getImageStr*(js: JsonNode): string =
  result = js.getStr
  result.removePrefix(https)
  result.removePrefix(twimg)

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:
    result = js{"thank_you_url"}.getStrVal(result)
  if result.startsWith("card://"):
    result = ""

proc getCardDomain*(js: JsonNode; kind: CardKind): string =
  result = js{"vanity_url"}.getStrVal(js{"domain"}.getStr)
  if kind == promoVideoConvo:
    result = js{"thank_you_vanity_url"}.getStrVal(result)

proc getCardTitle*(js: JsonNode; kind: CardKind): string =
  result = js{"title"}.getStrVal
  if kind == promoVideoConvo:
    result = js{"thank_you_text"}.getStrVal(result)
  elif kind == liveEvent:
    result = js{"event_category"}.getStrVal
  elif kind in {videoDirectMessage, imageDirectMessage}:
    result = js{"cta1"}.getStrVal

proc getBanner*(js: JsonNode): string =
  let url = js{"profile_banner_url"}.getImageStr
  if url.len > 0:
    return url & "/1500x500"

  let color = js{"profile_link_color"}.getStr
  if color.len > 0:
    return '#' & color

  # use primary color from profile picture color histogram
  with p, js{"profile_image_extensions", "mediaColor", "r", "ok", "palette"}:
    if p.len > 0:
      let pal = p[0]{"rgb"}
      result = "#"
      result.add toHex(pal{"red"}.getInt, 2)
      result.add toHex(pal{"green"}.getInt, 2)
      result.add toHex(pal{"blue"}.getInt, 2)
      return

proc getTombstone*(js: JsonNode): string =
  result = js{"text"}.getStr
  result.removeSuffix(" Learn more")

proc getMp4Resolution*(url: string): int =
  # parses the height out of a URL like this one:
  # https://video.twimg.com/ext_tw_video/<tweet-id>/pu/vid/720x1280/<random>.mp4
  const vidSep = "/vid/"
  let
    vidIdx = url.find(vidSep) + vidSep.len
    resIdx = url.find('x', vidIdx) + 1
    res = url[resIdx ..< url.find("/", resIdx)]

  try:
    return parseInt(res)
  except ValueError:
    # cannot determine resolution (e.g. m3u8/non-mp4 video)
    return 0

proc extractSlice(js: JsonNode): Slice[int] =
  result = js["indices"][0].getInt ..< js["indices"][1].getInt

proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
                 textLen: int; hideTwitter = false) =
  let
    url = js.getExpandedUrl
    slice = js.extractSlice

  if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
    if slice.a < textLen:
      result.add ReplaceSlice(kind: rkRemove, slice: slice)
  else:
    result.add ReplaceSlice(kind: rkUrl, url: url,
                            display: url.shortLink, slice: slice)

proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
  result.add ReplaceSlice(kind: rkHashtag, slice: js.extractSlice)

proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
                  textSlice: Slice[int]): string =
  template extractLowerBound(i: int; idx): int =
    if i > 0: repls[idx].slice.b.succ else: textSlice.a

  result = newStringOfCap(runes.len)

  for i, rep in repls:
    result.add $runes[extractLowerBound(i, i - 1) ..< 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?f=tweets&q=%23" & name)
    of rkMention:
      result.add a($runes[rep.slice], href = rep.url, title = rep.display)
    of rkUrl:
      result.add a(rep.display, href = rep.url)
    of rkRemove:
      discard

  let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b
  if rest.a <= rest.b:
    result.add $runes[rest]

proc deduplicate(s: var seq[ReplaceSlice]) =
  var
    len = s.len
    i = 0
  while i < len:
    var j = i + 1
    while j < len:
      if s[i].slice.a == s[j].slice.a:
        s.del j
        dec len
      else:
        inc j
    inc i

proc cmp(x, y: ReplaceSlice): int = cmp(x.slice.a, y.slice.b)

proc expandUserEntities*(user: var User; js: JsonNode) =
  let
    orig = user.bio.toRunes
    ent = ? js{"entities"}

  with urls, ent{"url", "urls"}:
    user.website = urls[0].getExpandedUrl

  var replacements = newSeq[ReplaceSlice]()

  with urls, ent{"description", "urls"}:
    for u in urls:
      replacements.extractUrls(u, orig.high)

  replacements.deduplicate
  replacements.sort(cmp)

  user.bio = orig.replacedWith(replacements, 0 .. orig.len)
  user.bio = user.bio.replacef(unRegex, unReplace)
                     .replacef(htRegex, htReplace)

proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
                        replyTo=""; hasRedundantLink=false) =
  let hasCard = tweet.card.isSome

  var replacements = newSeq[ReplaceSlice]()

  with urls, entities{"urls"}:
    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)

      if hasCard and u{"url"}.getStr == get(tweet.card).url:
        get(tweet.card).url = u.getExpandedUrl

  with media, entities{"media"}:
    for m in media:
      replacements.extractUrls(m, textSlice.b, hideTwitter = true)

  if "hashtags" in entities:
    for hashtag in entities["hashtags"]:
      replacements.extractHashtags(hashtag)

  if "symbols" in entities:
    for symbol in entities["symbols"]:
      replacements.extractHashtags(symbol)

  if "user_mentions" in entities:
    for mention in entities["user_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)
        if idx > -1 and name != replyTo:
          tweet.reply.delete idx
      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 expandTweetEntities*(tweet: Tweet; js: JsonNode) =
  let
    entities = ? js{"entities"}
    textRange = js{"display_text_range"}
    textSlice = textRange{0}.getInt .. textRange{1}.getInt
    hasQuote = js{"is_quote_status"}.getBool
    hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails

  var replyTo = ""
  if tweet.replyId != 0:
    with reply, js{"in_reply_to_screen_name"}:
      replyTo = reply.getStr
      tweet.reply.add replyTo

  tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard)

proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
  let
    entities = ? js{"entity_set"}
    text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
    textSlice = 0..text.runeLen

  tweet.expandTextEntities(entities, text, textSlice)

  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.media.len > 0: t.media[0].getThumb
    elif t.card.isSome: get(t.card).image
    else: ""

  result = GalleryPhoto(url: url, tweetId: $t.id)


================================================
FILE: src/prefs.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import tables, strutils
import types, prefs_impl
from config import get
from parsecfg import nil

export genUpdatePrefs, genResetPrefs, genApplyPrefs

var defaultPrefs*: Prefs

proc updateDefaultPrefs*(cfg: parsecfg.Config) =
  genDefaultPrefs()

proc getPrefs*(cookies, params: Table[string, string]): Prefs =
  result = defaultPrefs
  genParsePrefs(cookies)
  genParsePrefs(params)

proc encodePrefs*(prefs: Prefs): string =
  var encPairs: seq[string]
  genEncodePrefs(prefs)
  encPairs.join(",")


================================================
FILE: src/prefs_impl.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import macros, tables, strutils, xmltree

type
  PrefKind* = enum
    checkbox, select, input

  Pref* = object
    name*: string
    label*: string
    kind*: PrefKind
    # checkbox
    defaultState*: bool
    # select
    defaultOption*: string
    options*: seq[string]
    # input
    defaultInput*: string
    placeholder*: string

  PrefList* = OrderedTable[string, seq[Pref]]

macro genPrefs*(prefDsl: untyped) =
  var table = nnkTableConstr.newTree()
  for category in prefDsl:
    table.add nnkExprColonExpr.newTree(newLit($category[0]))
    table[^1].add nnkPrefix.newTree(newIdentNode("@"), nnkBracket.newTree())
    for pref in category[1]:
      let
        name = newLit($pref[0])
        kind = pref[1]
        label = pref[3][0]
        default = pref[2]
        defaultField =
          case parseEnum[PrefKind]($kind)
          of checkbox: ident("defaultState")
          of select: ident("defaultOption")
          of input: ident("defaultInput")

      var newPref = quote do:
        Pref(kind: `kind`, name: `name`, label: `label`, `defaultField`: `default`)

      for node in pref[3]:
        if node.kind == nnkCall:
          newPref.add nnkExprColonExpr.newTree(node[0], node[1][0])
      table[^1][1][1].add newPref

  let name = ident("prefList")
  result = quote do:
    const `name`*: PrefList = toOrderedTable(`table`)

genPrefs:
  Display:
    theme(select, "Nitter"):
      "Theme"

    infiniteScroll(checkbox, false):
      "Infinite scrolling (experimental, requires JavaScript)"

    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)"

    hideTweetStats(checkbox, false):
      "Hide tweet stats (replies, retweets, likes)"

    hideBanner(checkbox, false):
      "Hide profile banner"

    hidePins(checkbox, false):
      "Hide pinned tweets"

    hideReplies(checkbox, false):
      "Hide tweet replies"

    hideCommunityNotes(checkbox, false):
      "Hide community notes"

    squareAvatars(checkbox, false):
      "Square profile pictures"

  Media:
    mp4Playback(checkbox, true):
      "Enable mp4 video playback (only for gifs)"

    hlsPlayback(checkbox, false):
      "Enable HLS video streaming (requires JavaScript)"

    proxyVideos(checkbox, true):
      "Proxy video streaming through the server (might be slow)"

    muteVideos(checkbox, false):
      "Mute videos by default"

    autoplayGifs(checkbox, true):
      "Autoplay gifs"

    compactGallery(checkbox, false):
      "Compact media gallery (no profile info or text)"

    mediaView(select, "Timeline"):
      "Default media view"
      options: @["Timeline", "Grid", "Gallery"]

  "Link replacements (blank to disable)":
    replaceTwitter(input, ""):
      "Twitter -> Nitter"
      placeholder: "Nitter hostname"

    replaceYouTube(input, ""):
      "YouTube -> Piped/Invidious"
      placeholder: "Piped hostname"

    replaceReddit(input, ""):
      "Reddit -> Teddit/Libreddit"
      placeholder: "Teddit hostname"

iterator allPrefs*(): Pref =
  for k, v in prefList:
    for pref in v:
      yield pref

macro genDefaultPrefs*(): untyped =
  result = nnkStmtList.newTree()
  for pref in allPrefs():
    let
      ident = ident(pref.name)
      name = newLit(pref.name)
      default =
        case pref.kind
        of checkbox: newLit(pref.defaultState)
        of select: newLit(pref.defaultOption)
        of input: newLit(pref.defaultInput)

    result.add quote do:
      defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)

macro genParsePrefs*(prefs): untyped =
  result = nnkStmtList.newTree()
  for pref in allPrefs():
    let
      name = pref.name
      ident = ident(pref.name)
      kind = newLit(pref.kind)
      options = pref.options

    result.add quote do:
      if `name` in `prefs`:
        when `kind` == input or `name` == "theme":
          result.`ident` = `prefs`[`name`]
        elif `kind` == checkbox:
          result.`ident` = `prefs`[`name`] == "on" or 
                           `prefs`[`name`] == "true" or
                           `prefs`[`name`] == "1"
        else:
          let value = `prefs`[`name`]
          if value in `options`: result.`ident` = value

macro genUpdatePrefs*(): untyped =
  result = nnkStmtList.newTree()
  let req = ident("request")
  for pref in allPrefs():
    let
      name = newLit(pref.name)
      kind = newLit(pref.kind)
      options = newLit(pref.options)
      default = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name))

    result.add quote do:
      let val = @`name`
      let isDefault =
        when `kind` == input or `name` == "theme":
          if `default`.len != val.len: false
          else: val == `default`
        elif `kind` == checkbox:
          (val == "on") == `default`
        else:
          val notin `options` or val == `default`

      if isDefault:
        savePref(`name`, "", `req`, expire=true)
      else:
        savePref(`name`, val, `req`)

macro genResetPrefs*(): untyped =
  result = nnkStmtList.newTree()
  let req = ident("request")
  for pref in allPrefs():
    let name = newLit(pref.name)
    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:
    type `name` = object
      discard

  for pref in allPrefs():
    result[0][2][2].add nnkIdentDefs.newTree(
      nnkPostfix.newTree(ident("*"), ident(pref.name)),
      (case pref.kind
       of checkbox: ident("bool")
       of input, select: ident("string")),
      newEmptyNode())


================================================
FILE: src/query.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, tables, uri

import types, utils

const
  validFilters* = @[
    "media", "images", "twimg", "videos",
    "native_video", "consumer_video", "spaces",
    "links", "news", "quote", "mentions",
    "replies", "retweets", "nativeretweets"
  ]

  emptyQuery* = "include:nativeretweets"

template `@`(param: string): untyped =
  if param in pms: pms[param]
  else: ""

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",
    minLikes: validateNumber(@"min_faves")
  )

  if name.len > 0:
    result.fromUser = name.split(",")

proc getMediaQuery*(name: string): Query =
  Query(
    kind: media,
    filters: @["twimg", "native_video"],
    fromUser: @[name],
    sep: "OR"
  )

proc getReplyQuery*(name: string): Query =
  Query(
    kind: replies,
    fromUser: @[name]
  )

proc genQueryParam*(query: Query; maxId=""): string =
  var
    filters: seq[string]
    param: string

  if query.kind == users:
    return query.text

  for i, user in query.fromUser:
    if i == 0:
      param = "("

    param &= &"from:{user}"
    if i < query.fromUser.high:
      param &= " OR "
    else:
      param &= ")"

  if query.fromUser.len > 0 and query.kind in {posts, media}:
    param &= " (filter:self_threads OR -filter:replies)"

  if "nativeretweets" notin query.excludes:
    param &= " include:nativeretweets"

  for f in query.filters:
    filters.add "filter:" & f
  for e in query.excludes:
    if e == "nativeretweets": continue
    filters.add "-filter:" & e
  for i in query.includes:
    filters.add "include:" & i

  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 and maxId.len == 0:
    result &= " until:" & query.until
  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 =
  var params: seq[string]

  if query.view.len > 0:
    params.add "view=" & encodeUrl(query.view)

  if query.kind in {tweets, users}:
    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("&")


================================================
FILE: src/redis_cache.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, times, strformat, strutils, tables, hashes
import redis, redpool, flatty, supersnappy

import types, api

const
  redisNil = "\0\0"
  baseCacheTime = 60 * 60

var
  pool: RedisPool
  rssCacheTime: int
  listCacheTime*: int

template dawait(future) =
  discard await future

# flatty can't serialize DateTime, so we need to define this
proc toFlatty*(s: var string, x: DateTime) =
  s.toFlatty(x.toTime().toUnix())

proc fromFlatty*(s: string, i: var int, x: var DateTime) =
  var unix: int64
  s.fromFlatty(i, unix)
  x = fromUnix(unix).utc()

proc setCacheTimes*(cfg: Config) =
  rssCacheTime = cfg.rssCacheTime * 60
  listCacheTime = cfg.listCacheTime * 60

proc migrate*(key, match: string) {.async.} =
  pool.withAcquire(r):
    let hasKey = await r.get(key)
    if hasKey == redisNil:
      let list = await r.scan(newCursor(0), match, 100000)
      r.startPipelining()
      for item in list:
        dawait r.del(item)
      await r.setk(key, "true")
      dawait r.flushPipeline()

proc initRedisPool*(cfg: Config) {.async.} =
  try:
    pool = await newRedisPool(cfg.redisConns, cfg.redisMaxConns,
                              host=cfg.redisHost, port=cfg.redisPort,
                              password=cfg.redisPassword)

    await migrate("flatty", "*:*")
    await migrate("snappyRss", "rss:*")
    await migrate("userBuckets", "p:*")
    await migrate("profileDates", "p:*")
    await migrate("profileStats", "p:*")
    await migrate("userType", "p:*")
    await migrate("verifiedType", "p:*")

    pool.withAcquire(r):
      # optimize memory usage for user ID buckets
      await r.configSet("hash-max-ziplist-entries", "1000")

  except OSError:
    stdout.write "Failed to connect to Redis.\n"
    stdout.flushFile
    quit(1)

template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000)
template userKey(name: string): string = "p:" & name
template listKey(l: List): string = "l:" & l.id
template tweetKey(id: int64): string = "t:" & $id

proc get(query: string): Future[string] {.async.} =
  pool.withAcquire(r):
    result = await r.get(query)

proc setEx(key: string; time: int; data: string) {.async.} =
  pool.withAcquire(r):
    dawait r.setEx(key, time, data)

proc cacheUserId(username, id: string) {.async.} =
  if username.len == 0 or id.len == 0: return
  let name = toLower(username)
  pool.withAcquire(r):
    dawait r.hSet(name.uidKey, name, id)

proc cache*(data: List) {.async.} =
  await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))

proc cache*(data: PhotoRail; name: string) {.async.} =
  await setEx("pr2:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))

proc cache*(data: User) {.async.} =
  if data.username.len == 0: return
  let name = toLower(data.username)
  await cacheUserId(name, data.id)
  pool.withAcquire(r):
    dawait r.setEx(name.userKey, baseCacheTime, compress(toFlatty(data)))

proc cache*(data: Tweet) {.async.} =
  if data.isNil or data.id == 0: return
  pool.withAcquire(r):
    dawait r.setEx(data.id.tweetKey, baseCacheTime, compress(toFlatty(data)))

proc cacheRss*(query: string; rss: Rss) {.async.} =
  let key = "rss:" & query
  pool.withAcquire(r):
    dawait r.hSet(key, "min", rss.cursor)
    if rss.cursor != "suspended":
      dawait r.hSet(key, "rss", compress(rss.feed))
    dawait r.expire(key, rssCacheTime)

template deserialize(data, T) =
  try:
    result = fromFlatty(uncompress(data), T)
  except:
    echo "Decompression failed($#): '$#'" % [astToStr(T), data]

proc getUserId*(username: string): Future[string] {.async.} =
  let name = toLower(username)
  pool.withAcquire(r):
    result = await r.hGet(name.uidKey, name)
    if result == redisNil:
      let user = await getGraphUser(username)
      if user.suspended:
        return "suspended"
      else:
        await all(cacheUserId(name, user.id), cache(user))
        return user.id

proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} =
  let prof = await get("p:" & toLower(username))
  if prof != redisNil:
    prof.deserialize(User)
  elif fetch:
    result = await getGraphUser(username)
    await cache(result)

proc getCachedUsername*(userId: string): Future[string] {.async.} =
  let
    key = "i:" & userId
    username = await get(key)

  if username != redisNil:
    result = username
  else:
    let user = await getGraphUserById(userId)
    result = user.username
    await setEx(key, baseCacheTime, result)
    if result.len > 0 and user.id.len > 0:
      await all(cacheUserId(result, user.id), cache(user))

# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
#   if id == 0: return
#   let tweet = await get(id.tweetKey)
#   if tweet != redisNil:
#     tweet.deserialize(Tweet)
#   else:
#     result = await getGraphTweetResult($id)
#     if not result.isNil:
#       await cache(result)

proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
  if id.len == 0: return
  let rail = await get("pr2:" & toLower(id))
  if rail != redisNil:
    rail.deserialize(PhotoRail)
  else:
    result = await getPhotoRail(id)
    await cache(result, id)

proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
  let list = if id.len == 0: redisNil
             else: await get("l:" & id)

  if list != redisNil:
    list.deserialize(List)
  else:
    if id.len > 0:
      result = await getGraphList(id)
    else:
      result = await getGraphListBySlug(username, slug)
    await cache(result)

proc getCachedRss*(key: string): Future[Rss] {.async.} =
  let k = "rss:" & key
  pool.withAcquire(r):
    result.cursor = await r.hGet(k, "min")
    if result.cursor.len > 2:
      if result.cursor != "suspended":
        let feed = await r.hGet(k, "rss")
        if feed.len > 0 and feed != redisNil:
          try: result.feed = uncompress feed
          except: echo "Decompressing RSS failed: ", feed
    else:
      result.cursor.setLen 0


================================================
FILE: src/routes/debug.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import jester
import router_utils
import ".."/[auth, types]

proc createDebugRouter*(cfg: Config) =
  router debug:
    get "/.health":
      respJson getSessionPoolHealth()

    get "/.sessions":
      cond cfg.enableDebug
      respJson getSessionPoolDebug()


================================================
FILE: src/routes/embed.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils, strformat, options
import jester, karax/vdom
import ".."/[types, api]
import ../views/[embed, tweet, general]
import router_utils

export api, embed, vdom, tweet, general, router_utils

proc createEmbedRouter*(cfg: Config) =
  router embed:
    get "/i/videos/tweet/@id":
      let tweet = await getGraphTweetResult(@"id")
      if tweet == nil or not tweet.hasVideos:
        resp Http404

      resp renderVideoEmbed(tweet, cfg, request)

    get "/@user/status/@id/embed":
      let
        tweet = await getGraphTweetResult(@"id")
        prefs = requestPrefs()
        path = getPath()

      if tweet == nil:
        resp Http404

      resp renderTweetEmbed(tweet, path, prefs, cfg, request)

    get "/embed/Tweet.html":
      let id = @"id"

      if id.len > 0:
        redirect(&"/i/status/{id}/embed")
      else:
        resp Http404


================================================
FILE: src/routes/list.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, uri

import jester

import router_utils
import ".."/[types, redis_cache, api]
import ../views/[general, timeline, list]

template respList*(list, timeline, title, vnode: typed) =
  if list.id.len == 0 or list.name.len == 0:
    resp Http404, showError(&"""List "{@"id"}" not found""", cfg)

  let
    html = renderList(vnode, timeline.query, list)
    rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: ""

  resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)

proc title*(list: List): string =
  &"@{list.username}/{list.name}"

proc createListRouter*(cfg: Config) =
  router list:
    get "/@name/lists/@slug/?":
      cond '.' notin @"name"
      cond @"name" != "i"
      cond @"slug" != "memberships"
      let
        slug = decodeUrl(@"slug")
        list = await getCachedList(@"name", slug)
      if list.id.len == 0:
        resp Http404, showError(&"""List "{@"slug"}" not found""", cfg)
      redirect(&"/i/lists/{list.id}")

    get "/i/lists/@id/?":
      cond '.' notin @"id"
      let
        prefs = requestPrefs()
        list = await getCachedList(id=(@"id"))
        timeline = await getGraphListTweets(list.id, getCursor())
        vnode = renderTimelineTweets(timeline, prefs, request.path)
      respList(list, timeline, list.title, vnode)

    get "/i/lists/@id/members":
      cond '.' notin @"id"
      let
        prefs = requestPrefs()
        list = await getCachedList(id=(@"id"))
        members = await getGraphListMembers(list, getCursor())
      respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))


================================================
FILE: src/routes/media.nim
================================================
# SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils, httpclient, os, hashes, base64, re
import asynchttpserver, asyncstreams, asyncfile, asyncnet

import jester

import router_utils
import ".."/[types, formatters, utils]

export asynchttpserver, asyncstreams, asyncfile, asyncnet
export httpclient, os, strutils, asyncstreams, base64, re

const
  m3u8Mime* = "application/vnd.apple.mpegurl"
  maxAge* = "max-age=604800"

proc safeFetch*(url: string): Future[string] {.async.} =
  let client = newAsyncHttpClient()
  try: result = await client.getContent(url)
  except: discard
  finally: client.close()

template respond*(req: asynchttpserver.Request; headers) =
  var msg = "HTTP/1.1 200 OK\c\L"
  for k, v in headers:
    msg.add(k & ": " & v & "\c\L")

  msg.add "\c\L"
  yield req.client.send(msg)

proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
  result = Http200
  let
    request = req.getNativeReq()
    client = newAsyncHttpClient()

  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]
      else:
        ""

    let headers = newHttpHeaders({
      "content-type": res.headers["content-type", 0],
      "content-length": contentLength,
      "cache-control": maxAge,
      "etag": hashed
    })

    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
  except HttpRequestError, ProtocolError, OSError:
    ec
Download .txt
gitextract_oep9sxz7/

├── .dockerignore
├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── build-docker.yml
│       └── run-tests.yml
├── .gitignore
├── .travis.yml
├── Dockerfile
├── Dockerfile.arm64
├── LICENSE
├── README.md
├── config.nims
├── docker-compose.yml
├── nitter.example.conf
├── nitter.nimble
├── public/
│   ├── browserconfig.xml
│   ├── css/
│   │   ├── fontello.css
│   │   └── themes/
│   │       ├── auto.css
│   │       ├── auto_(twitter).css
│   │       ├── black.css
│   │       ├── dracula.css
│   │       ├── mastodon.css
│   │       ├── nitter.css
│   │       ├── pleroma.css
│   │       ├── twitter.css
│   │       └── twitter_dark.css
│   ├── fonts/
│   │   └── LICENSE.txt
│   ├── js/
│   │   ├── hlsPlayback.js
│   │   └── infiniteScroll.js
│   ├── md/
│   │   └── about.md
│   ├── robots.txt
│   └── site.webmanifest
├── src/
│   ├── api.nim
│   ├── apiutils.nim
│   ├── auth.nim
│   ├── config.nim
│   ├── consts.nim
│   ├── experimental/
│   │   ├── parser/
│   │   │   ├── graphql.nim
│   │   │   ├── session.nim
│   │   │   ├── slices.nim
│   │   │   ├── tid.nim
│   │   │   ├── unifiedcard.nim
│   │   │   ├── user.nim
│   │   │   └── utils.nim
│   │   ├── parser.nim
│   │   └── types/
│   │       ├── common.nim
│   │       ├── graphlistmembers.nim
│   │       ├── graphuser.nim
│   │       ├── session.nim
│   │       ├── tid.nim
│   │       ├── unifiedcard.nim
│   │       └── user.nim
│   ├── formatters.nim
│   ├── http_pool.nim
│   ├── nitter.nim
│   ├── parser.nim
│   ├── parserutils.nim
│   ├── prefs.nim
│   ├── prefs_impl.nim
│   ├── query.nim
│   ├── redis_cache.nim
│   ├── routes/
│   │   ├── debug.nim
│   │   ├── embed.nim
│   │   ├── list.nim
│   │   ├── media.nim
│   │   ├── preferences.nim
│   │   ├── resolver.nim
│   │   ├── router_utils.nim
│   │   ├── rss.nim
│   │   ├── search.nim
│   │   ├── status.nim
│   │   ├── timeline.nim
│   │   └── unsupported.nim
│   ├── sass/
│   │   ├── general.scss
│   │   ├── include/
│   │   │   ├── _mixins.css
│   │   │   └── _variables.scss
│   │   ├── index.scss
│   │   ├── inputs.scss
│   │   ├── navbar.scss
│   │   ├── profile/
│   │   │   ├── _base.scss
│   │   │   ├── card.scss
│   │   │   └── photo-rail.scss
│   │   ├── search.scss
│   │   ├── timeline.scss
│   │   └── tweet/
│   │       ├── _base.scss
│   │       ├── card.scss
│   │       ├── embed.scss
│   │       ├── media.scss
│   │       ├── poll.scss
│   │       ├── quote.scss
│   │       ├── thread.scss
│   │       └── video.scss
│   ├── tid.nim
│   ├── types.nim
│   ├── utils.nim
│   └── views/
│       ├── about.nim
│       ├── embed.nim
│       ├── feature.nim
│       ├── general.nim
│       ├── list.nim
│       ├── opensearch.nimf
│       ├── preferences.nim
│       ├── profile.nim
│       ├── renderutils.nim
│       ├── rss.nimf
│       ├── search.nim
│       ├── status.nim
│       ├── timeline.nim
│       └── tweet.nim
├── tests/
│   ├── base.py
│   ├── poetry.toml
│   ├── pyproject.toml
│   ├── requirements.txt
│   ├── test_card.py
│   ├── test_profile.py
│   ├── test_quote.py
│   ├── test_search.py
│   ├── test_thread.py
│   ├── test_timeline.py
│   ├── test_tweet.py
│   └── test_tweet_media.py
└── tools/
    ├── create_session_browser.py
    ├── create_session_curl.py
    ├── create_sessions_browser.py
    ├── gencss.nim
    ├── get_session.py
    ├── rendermd.nim
    └── requirements.txt
Download .txt
SYMBOL INDEX (98 symbols across 14 files)

FILE: public/js/hlsPlayback.js
  function playVideo (line 3) | function playVideo(overlay) {

FILE: public/js/infiniteScroll.js
  function insertBeforeLast (line 4) | function insertBeforeLast(node, elem) {
  function getLoadMore (line 8) | function getLoadMore(doc) {
  function getHrefs (line 12) | function getHrefs(selector) {
  function getTweetId (line 16) | function getTweetId(item) {
  function isDuplicate (line 21) | function isDuplicate(item, hrefs) {
  constant GAP (line 25) | const GAP = 10;
  class Masonry (line 27) | class Masonry {
    method constructor (line 28) | constructor(container) {
    method _revealAll (line 57) | _revealAll() {
    method _pickCol (line 65) | _pickCol() {
    method _position (line 73) | _position(items, heights, colWidth) {
    method _place (line 85) | _place(items, heights, n, colWidth) {
    method _rebuild (line 92) | _rebuild() {
    method syncHeights (line 131) | syncHeights() {
    method addAll (line 140) | addAll(newItems) {
  function handleScroll (line 166) | function handleScroll(failed) {

FILE: tests/base.py
  class Card (line 4) | class Card(object):
    method __init__ (line 5) | def __init__(self, tweet=''):
  class Quote (line 14) | class Quote(object):
    method __init__ (line 15) | def __init__(self, tweet=''):
  class Tweet (line 26) | class Tweet(object):
    method __init__ (line 27) | def __init__(self, tweet=''):
  class Profile (line 37) | class Profile(object):
  class Timeline (line 50) | class Timeline(object):
  class Conversation (line 66) | class Conversation(object):
  class Poll (line 76) | class Poll(object):
  class Media (line 84) | class Media(object):
  class BaseTestCase (line 92) | class BaseTestCase(BaseCase):
    method setUp (line 93) | def setUp(self):
    method tearDown (line 96) | def tearDown(self):
    method open_nitter (line 99) | def open_nitter(self, page=''):
    method search_username (line 102) | def search_username(self, username):
  function get_timeline_tweet (line 108) | def get_timeline_tweet(num=1):

FILE: tests/test_card.py
  class CardTest (line 51) | class CardTest(BaseTestCase):
    method test_card (line 53) | def test_card(self, tweet, title, description, destination, large):
    method test_card_no_thumb (line 67) | def test_card_no_thumb(self, tweet, title, description, destination):
    method test_card_playable (line 76) | def test_card_playable(self, tweet, title, description, destination):

FILE: tests/test_profile.py
  class ProfileTest (line 37) | class ProfileTest(BaseTestCase):
    method test_data (line 39) | def test_data(self, username, fullname, bio, location, website, joinDa...
    method test_verified (line 59) | def test_verified(self, username):
    method test_protected (line 64) | def test_protected(self, username, fullname, bio):
    method test_invalid_username (line 76) | def test_invalid_username(self, username):
    method test_malformed_username (line 81) | def test_malformed_username(self, username):
    method test_suspended (line 87) | def test_suspended(self):
    method test_banner_image (line 92) | def test_banner_image(self, username, url):

FILE: tests/test_quote.py
  class QuoteTest (line 29) | class QuoteTest(BaseTestCase):
    method test_text (line 31) | def test_text(self, tweet, fullname, username, text):
    method test_image (line 39) | def test_image(self, tweet, url):
    method test_gif (line 46) | def test_gif(self, tweet, url):
    method test_video (line 53) | def test_video(self, tweet, url):

FILE: tests/test_thread.py
  class ThreadTest (line 28) | class ThreadTest(BaseTestCase):
    method find_tweets (line 29) | def find_tweets(self, selector):
    method compare_first_word (line 32) | def compare_first_word(self, tweets, selector):
    method test_thread (line 40) | def test_thread(self, tweet, before, main, after, replies):

FILE: tests/test_timeline.py
  class TweetTest (line 18) | class TweetTest(BaseTestCase):
    method test_timeline (line 20) | def test_timeline(self, username):
    method test_after (line 28) | def test_after(self, username, cursor):
    method test_no_more (line 36) | def test_no_more(self, username):
    method test_empty (line 43) | def test_empty(self, username):
    method test_protected (line 51) | def test_protected(self, username):
    method test_media_view_tabs (line 58) | def test_media_view_tabs(self):
    method test_media_view_grid_tab (line 66) | def test_media_view_grid_tab(self):
    method test_media_view_gallery_tab (line 71) | def test_media_view_gallery_tab(self):
    method test_media_view_tabs_not_on_posts (line 76) | def test_media_view_tabs_not_on_posts(self):

FILE: tests/test_tweet.py
  class TweetTest (line 79) | class TweetTest(BaseTestCase):
    method test_timeline (line 81) | def test_timeline(self, index, fullname, username, date, tid, text):
    method test_status (line 92) | def test_status(self, tid, fullname, username, date, text):
    method test_multiline_formatting (line 101) | def test_multiline_formatting(self, tid, username, text):
    method test_emoji (line 106) | def test_emoji(self, tweet, text):
    method test_link (line 111) | def test_link(self, tweet, links):
    method test_username (line 117) | def test_username(self, tweet, usernames):
    method test_retweet (line 124) | def test_retweet(self, index, url, retweet_by, fullname, username, text):
    method test_invalid_id (line 133) | def test_invalid_id(self, tweet):

FILE: tests/test_tweet_media.py
  class MediaTest (line 47) | class MediaTest(BaseTestCase):
    method test_poll (line 49) | def test_poll(self, tweet, text, votes, leader, choices):
    method test_image (line 70) | def test_image(self, tweet, url):
    method test_gif (line 79) | def test_gif(self, tweet, gif_id):
    method test_video_m3u8 (line 90) | def test_video_m3u8(self, tweet, thumb):
    method test_gallery (line 100) | def test_gallery(self, tweet, rows):

FILE: tools/create_session_browser.py
  function login_and_get_cookies (line 32) | async def login_and_get_cookies(username, password, totp_seed=None, head...
  function main (line 134) | async def main():

FILE: tools/create_session_curl.py
  function get_base_headers (line 47) | def get_base_headers(guest_token=None):
  function get_cookies_dict (line 63) | def get_cookies_dict(session):
  function make_request (line 68) | def make_request(session, headers, flow_token, subtask_data, print_msg):
  function get_guest_token (line 88) | def get_guest_token(session):
  function init_flow (line 102) | def init_flow(session, guest_token):
  function submit_username (line 128) | def submit_username(session, flow_token, headers, guest_token, username):
  function submit_password (line 155) | def submit_password(session, flow_token, headers, guest_token, password):
  function submit_2fa (line 174) | def submit_2fa(session, flow_token, headers, guest_token, totp_seed):
  function submit_js_instrumentation (line 194) | def submit_js_instrumentation(session, flow_token, headers, guest_token):
  function complete_flow (line 211) | def complete_flow(session, flow_token, headers):
  function extract_user_id (line 228) | def extract_user_id(cookies_dict):
  function login_and_get_cookies (line 239) | def login_and_get_cookies(username, password, totp_seed=None):
  function main (line 269) | def main():

FILE: tools/create_sessions_browser.py
  function login_and_get_cookies (line 40) | async def login_and_get_cookies(account, headless=False):
  function main (line 146) | async def main():

FILE: tools/get_session.py
  function auth (line 14) | def auth(username, password, otp_secret):
Condensed preview — 127 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (405K chars).
[
  {
    "path": ".dockerignore",
    "chars": 56,
    "preview": "*.png\n*.md\nLICENSE\ndocker-compose.yml\nDockerfile\ntests/\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 49,
    "preview": "github: zedeus\nliberapay: zedeus\npatreon: nitter\n"
  },
  {
    "path": ".github/workflows/build-docker.yml",
    "chars": 1624,
    "preview": "name: Docker\n\non:\n  push:\n    paths-ignore:\n      - \"README.md\"\n    branches:\n      - master\n\njobs:\n  tests:\n    uses: ."
  },
  {
    "path": ".github/workflows/run-tests.yml",
    "chars": 3612,
    "preview": "name: Tests\n\non:\n  push:\n    paths-ignore:\n      - \"*.md\"\n    branches-ignore:\n      - master\n  workflow_call:\n\n# Ensure"
  },
  {
    "path": ".gitignore",
    "chars": 252,
    "preview": "nitter\n*.html\n*.db\n/tests/__pycache__\n/tests/geckodriver.log\n/tests/downloaded_files\n/tests/latest_logs\n/tools/gencss\n/t"
  },
  {
    "path": ".travis.yml",
    "chars": 1691,
    "preview": "jobs:\n  include:\n    - stage: test\n      if: (NOT type IN (pull_request)) AND (branch = master)\n      dist: bionic\n     "
  },
  {
    "path": "Dockerfile",
    "chars": 611,
    "preview": "FROM nimlang/nim:2.2.0-alpine-regular as nim\nLABEL maintainer=\"setenforce@protonmail.com\"\n\nRUN apk --no-cache add libsas"
  },
  {
    "path": "Dockerfile.arm64",
    "chars": 628,
    "preview": "FROM alpine:3.20.6 as nim\nLABEL maintainer=\"setenforce@protonmail.com\"\n\nRUN apk --no-cache add libsass-dev pcre gcc git "
  },
  {
    "path": "LICENSE",
    "chars": 34519,
    "preview": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C)"
  },
  {
    "path": "README.md",
    "chars": 7171,
    "preview": "# Nitter\n\n[![Test Matrix](https://github.com/zedeus/nitter/workflows/Tests/badge.svg)](https://github.com/zedeus/nitter/"
  },
  {
    "path": "config.nims",
    "chars": 265,
    "preview": "--define:ssl\n--define:useStdLib\n--threads:off\n\n# workaround httpbeast file upload bug\n--assertions:off\n\n# disable annoyi"
  },
  {
    "path": "docker-compose.yml",
    "chars": 1128,
    "preview": "version: \"3\"\n\nservices:\n\n  nitter:\n    image: zedeus/nitter:latest\n    container_name: nitter\n    ports:\n      - \"127.0."
  },
  {
    "path": "nitter.example.conf",
    "chars": 2017,
    "preview": "[Server]\nhostname = \"nitter.net\"      # for generating links, change this to your own domain/ip\ntitle = \"nitter\"\naddress"
  },
  {
    "path": "nitter.nimble",
    "chars": 769,
    "preview": "# Package\n\nversion       = \"0.1.0\"\nauthor        = \"zedeus\"\ndescription   = \"An alternative front-end for Twitter\"\nlicen"
  },
  {
    "path": "public/browserconfig.xml",
    "chars": 187,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n    <msapplication>\n        <tile>\n            <TileColor>#2b5797"
  },
  {
    "path": "public/css/fontello.css",
    "chars": 2158,
    "preview": "@font-face {\n  font-family: \"fontello\";\n  src: url(\"/fonts/fontello.eot?42791196\");\n  src:\n    url(\"/fonts/fontello.eot?"
  },
  {
    "path": "public/css/themes/auto.css",
    "chars": 104,
    "preview": "@import \"nitter.css\" (prefers-color-scheme: dark);\n@import \"twitter.css\" (prefers-color-scheme: light);\n"
  },
  {
    "path": "public/css/themes/auto_(twitter).css",
    "chars": 110,
    "preview": "@import \"twitter_dark.css\" (prefers-color-scheme: dark);\n@import \"twitter.css\" (prefers-color-scheme: light);\n"
  },
  {
    "path": "public/css/themes/black.css",
    "chars": 815,
    "preview": "body {\n    --bg_color: #000000;\n    --fg_color: #FFFFFF;\n    --fg_faded: #FFFFFFD4;\n    --fg_dark: var(--accent);\n    --"
  },
  {
    "path": "public/css/themes/dracula.css",
    "chars": 855,
    "preview": "body {\n    --bg_color: #282a36;\n    --fg_color: #f8f8f2;\n    --fg_faded: #818eb6;\n    --fg_dark: var(--fg_faded);\n    --"
  },
  {
    "path": "public/css/themes/mastodon.css",
    "chars": 827,
    "preview": "body {\n    --bg_color: #191B22;\n    --fg_color: #FFFFFF;\n    --fg_faded: #F8F8F2CF;\n    --fg_dark: var(--accent);\n    --"
  },
  {
    "path": "public/css/themes/nitter.css",
    "chars": 39,
    "preview": "body {\n    /* uses default values */\n}\n"
  },
  {
    "path": "public/css/themes/pleroma.css",
    "chars": 808,
    "preview": "body {\n    --bg_color: #0A1117;\n    --fg_color: #B9B9BA;\n    --fg_faded: #B9B9BAFA;\n    --fg_dark: var(--accent);\n    --"
  },
  {
    "path": "public/css/themes/twitter.css",
    "chars": 792,
    "preview": "body {\n    --bg_color: #E6ECF0;\n    --fg_color: #0F0F0F;\n    --fg_faded: #657786;\n    --fg_dark: var(--fg_faded);\n    --"
  },
  {
    "path": "public/css/themes/twitter_dark.css",
    "chars": 832,
    "preview": "body {\n    --bg_color: #101821;\n    --fg_color: #FFFFFF;\n    --fg_faded: #8899A6;\n    --fg_dark: var(--fg_faded);\n    --"
  },
  {
    "path": "public/fonts/LICENSE.txt",
    "chars": 930,
    "preview": "Font license info\n\n\n## Modern Pictograms\n\n   Copyright (c) 2012 by John Caserta. All rights reserved.\n\n   Author:    Joh"
  },
  {
    "path": "public/js/hlsPlayback.js",
    "chars": 851,
    "preview": "// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0\n// SPDX-License-Identifier: AGPL-3.0-only\nfunction playVi"
  },
  {
    "path": "public/js/infiniteScroll.js",
    "chars": 7791,
    "preview": "// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0\n// SPDX-License-Identifier: AGPL-3.0-only\n\nfunction inser"
  },
  {
    "path": "public/md/about.md",
    "chars": 2692,
    "preview": "# About\n\nNitter is a free and open source alternative Twitter front-end focused on\nprivacy and performance. The source i"
  },
  {
    "path": "public/robots.txt",
    "chars": 74,
    "preview": "User-agent: *\nDisallow: /\nCrawl-delay: 1\nUser-agent: Twitterbot\nDisallow:\n"
  },
  {
    "path": "public/site.webmanifest",
    "chars": 573,
    "preview": "{\n    \"name\": \"Nitter\",\n    \"short_name\": \"Nitter\",\n    \"icons\": [\n        {\n            \"src\": \"/android-chrome-192x192"
  },
  {
    "path": "src/api.nim",
    "chars": 7687,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport asyncdispatch, httpclient, strutils, sequtils, sugar\nimport packedjson\ni"
  },
  {
    "path": "src/apiutils.nim",
    "chars": 6719,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport httpclient, asyncdispatch, options, strutils, uri, times, math, tables\ni"
  },
  {
    "path": "src/auth.nim",
    "chars": 5829,
    "preview": "#SPDX-License-Identifier: AGPL-3.0-only\nimport std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os"
  },
  {
    "path": "src/config.nim",
    "chars": 2259,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport parsecfg except Config\nimport types, strutils\n\nproc get*[T](config: pars"
  },
  {
    "path": "src/consts.nim",
    "chars": 7691,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils\n\nconst\n  consumerKey* = \"3nVuSoBZnx6U4vzUxf5w\"\n  consumerSecret"
  },
  {
    "path": "src/experimental/parser/graphql.nim",
    "chars": 2433,
    "preview": "import options, strutils\nimport jsony\nimport user, utils, ../types/[graphuser, graphlistmembers]\nfrom ../../types import"
  },
  {
    "path": "src/experimental/parser/session.nim",
    "chars": 881,
    "preview": "import std/strutils\nimport jsony\nimport ../types/session\nfrom ../../types import Session, SessionKind\n\nproc parseSession"
  },
  {
    "path": "src/experimental/parser/slices.nim",
    "chars": 1918,
    "preview": "import std/[macros, htmlgen, unicode]\nimport ../types/common\nimport \"..\"/../[formatters, utils]\n\ntype\n  ReplaceSliceKind"
  },
  {
    "path": "src/experimental/parser/tid.nim",
    "chars": 225,
    "preview": "import jsony\nimport ../types/tid\nexport TidPair\n\nproc parseTidPairs*(raw: string): seq[TidPair] =\n  result = raw.fromJso"
  },
  {
    "path": "src/experimental/parser/unifiedcard.nim",
    "chars": 4028,
    "preview": "import std/[options, tables, strutils, strformat, sugar]\nimport jsony\nimport user, ../types/unifiedcard\nimport ../../for"
  },
  {
    "path": "src/experimental/parser/user.nim",
    "chars": 2230,
    "preview": "import std/[algorithm, unicode, re, strutils, strformat, options, nre]\nimport jsony\nimport utils, slices\nimport ../types"
  },
  {
    "path": "src/experimental/parser/utils.nim",
    "chars": 696,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport std/[sugar, strutils, times]\nimport ../types/common\nimport ../../utils a"
  },
  {
    "path": "src/experimental/parser.nim",
    "chars": 51,
    "preview": "import parser/[user, graphql]\nexport user, graphql\n"
  },
  {
    "path": "src/experimental/types/common.nim",
    "chars": 380,
    "preview": "from ../../types import Error\n\ntype\n  Url* = object\n    url*: string\n    expandedUrl*: string\n    displayUrl*: string\n  "
  },
  {
    "path": "src/experimental/types/graphlistmembers.nim",
    "chars": 694,
    "preview": "import graphuser\n\ntype\n  GraphListMembers* = object\n    data*: tuple[list: List]\n\n  List = object\n    membersTimeline*: "
  },
  {
    "path": "src/experimental/types/graphuser.nim",
    "chars": 994,
    "preview": "import options, strutils\nfrom ../../types import User, VerifiedType\n\ntype\n  GraphUser* = object\n    data*: tuple[userRes"
  },
  {
    "path": "src/experimental/types/session.nim",
    "chars": 178,
    "preview": "type\n  RawSession* = object\n    kind*: string\n    id*: string\n    username*: string\n    oauthToken*: string\n    oauthTok"
  },
  {
    "path": "src/experimental/types/tid.nim",
    "chars": 77,
    "preview": "type\n  TidPair* = object\n    animationKey*: string\n    verification*: string\n"
  },
  {
    "path": "src/experimental/types/unifiedcard.nim",
    "chars": 3322,
    "preview": "import std/[options, tables, times]\nimport jsony\nfrom ../../types import VideoType, VideoVariant, User\n\ntype\n  Text* = d"
  },
  {
    "path": "src/experimental/types/user.nim",
    "chars": 895,
    "preview": "import options\nimport common\nfrom ../../types import VerifiedType\n\ntype\n  RawUser* = object\n    idStr*: string\n    name*"
  },
  {
    "path": "src/formatters.nim",
    "chars": 7344,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, m"
  },
  {
    "path": "src/http_pool.nim",
    "chars": 1181,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport httpclient\n\ntype\n  HttpPool* = ref object\n    conns*: seq[AsyncHttpClien"
  },
  {
    "path": "src/nitter.nim",
    "chars": 3301,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport asyncdispatch, strformat, logging\nfrom net import Port\nfrom htmlgen impo"
  },
  {
    "path": "src/parser.nim",
    "chars": 23780,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, options, times, math, tables\nimport packedjson, packedjson/des"
  },
  {
    "path": "src/parserutils.nim",
    "chars": 10882,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport std/[times, macros, htmlgen, options, algorithm, re]\nimport std/strutils"
  },
  {
    "path": "src/prefs.nim",
    "chars": 541,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport tables, strutils\nimport types, prefs_impl\nfrom config import get\nfrom pa"
  },
  {
    "path": "src/prefs_impl.nim",
    "chars": 6691,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport macros, tables, strutils, xmltree\n\ntype\n  PrefKind* = enum\n    checkbox,"
  },
  {
    "path": "src/query.nim",
    "chars": 3159,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, strformat, sequtils, tables, uri\n\nimport types, utils\n\nconst\n "
  },
  {
    "path": "src/redis_cache.nim",
    "chars": 5963,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport asyncdispatch, times, strformat, strutils, tables, hashes\nimport redis, "
  },
  {
    "path": "src/routes/debug.nim",
    "chars": 302,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport jester\nimport router_utils\nimport \"..\"/[auth, types]\n\nproc createDebugRo"
  },
  {
    "path": "src/routes/embed.nim",
    "chars": 920,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport asyncdispatch, strutils, strformat, options\nimport jester, karax/vdom\nim"
  },
  {
    "path": "src/routes/list.nim",
    "chars": 1685,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, strformat, uri\n\nimport jester\n\nimport router_utils\nimport \"..\""
  },
  {
    "path": "src/routes/media.nim",
    "chars": 3926,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport uri, strutils, httpclient, os, hashes, base64, re\nimport asynchttpserver"
  },
  {
    "path": "src/routes/preferences.nim",
    "chars": 1108,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, uri, os, algorithm\n\nimport jester\n\nimport router_utils\nimport "
  },
  {
    "path": "src/routes/resolver.nim",
    "chars": 656,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils\n\nimport jester\n\nimport router_utils\nimport \"..\"/[types, api]\nim"
  },
  {
    "path": "src/routes/router_utils.nim",
    "chars": 2040,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, sequtils, uri, tables, json\nfrom jester import Request, cookie"
  },
  {
    "path": "src/routes/rss.nim",
    "chars": 5431,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport asyncdispatch, tables, times, hashes, uri\n\nimport jester\n\nimport router_"
  },
  {
    "path": "src/routes/search.nim",
    "chars": 1636,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, uri\n\nimport jester\n\nimport router_utils\nimport \"..\"/[query, ty"
  },
  {
    "path": "src/routes/status.nim",
    "chars": 3292,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport asyncdispatch, strutils, sequtils, uri, options, sugar\n\nimport jester, k"
  },
  {
    "path": "src/routes/timeline.nim",
    "chars": 5397,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport asyncdispatch, strutils, sequtils, uri, options, times\nimport jester, ka"
  },
  {
    "path": "src/routes/unsupported.nim",
    "chars": 593,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport jester\n\nimport router_utils\nimport ../types\nimport ../views/[general, fe"
  },
  {
    "path": "src/sass/general.scss",
    "chars": 695,
    "preview": "@import \"_variables\";\n@import \"_mixins\";\n\n.panel-container {\n  margin: auto;\n  font-size: 130%;\n}\n\n.error-panel {\n  @inc"
  },
  {
    "path": "src/sass/include/_mixins.css",
    "chars": 1623,
    "preview": "@import '_variables';\n\n@mixin panel($width, $max-width) {\n    max-width: $max-width;\n    margin: 0 auto;\n    float: none"
  },
  {
    "path": "src/sass/include/_variables.scss",
    "chars": 765,
    "preview": "// colors\n$bg_color: #0f0f0f;\n$fg_color: #f8f8f2;\n$fg_faded: #f8f8f2cf;\n$fg_dark: #ff6c60;\n$fg_nav: #ff6c60;\n\n$bg_panel:"
  },
  {
    "path": "src/sass/index.scss",
    "chars": 3443,
    "preview": "@import \"_variables\";\n\n@import \"tweet/_base\";\n@import \"profile/_base\";\n@import \"general\";\n@import \"navbar\";\n@import \"inp"
  },
  {
    "path": "src/sass/inputs.scss",
    "chars": 3538,
    "preview": "@import \"_variables\";\n@import \"_mixins\";\n\nbutton {\n  @include input-colors;\n  background-color: var(--bg_elements);\n  co"
  },
  {
    "path": "src/sass/navbar.scss",
    "chars": 1250,
    "preview": "@import \"_variables\";\n\nnav {\n  display: flex;\n  align-items: center;\n  background-color: var(--bg_overlays);\n  box-shado"
  },
  {
    "path": "src/sass/profile/_base.scss",
    "chars": 1687,
    "preview": "@import \"_variables\";\n@import \"_mixins\";\n\n@import \"card\";\n@import \"photo-rail\";\n\n.profile-tabs {\n  @include panel(auto, "
  },
  {
    "path": "src/sass/profile/card.scss",
    "chars": 2186,
    "preview": "@import '_variables';\n@import '_mixins';\n\n.profile-card {\n    flex-wrap: wrap;\n    background: var(--bg_panel);\n    padd"
  },
  {
    "path": "src/sass/profile/photo-rail.scss",
    "chars": 1934,
    "preview": "@import '_variables';\n\n.photo-rail {\n    &-card {\n        float: left;\n        background: var(--bg_panel);\n        bord"
  },
  {
    "path": "src/sass/search.scss",
    "chars": 1962,
    "preview": "@import \"_variables\";\n@import \"_mixins\";\n\n.search-title {\n  font-weight: bold;\n  display: inline-block;\n  margin-top: 4p"
  },
  {
    "path": "src/sass/timeline.scss",
    "chars": 7954,
    "preview": "@import \"_variables\";\n\n.timeline-container {\n  @include panel(100%, 600px);\n}\n\n.timeline > div:not(:first-child) {\n  bor"
  },
  {
    "path": "src/sass/tweet/_base.scss",
    "chars": 4307,
    "preview": "@import \"_variables\";\n@import \"_mixins\";\n@import \"thread\";\n@import \"media\";\n@import \"video\";\n@import \"embed\";\n@import \"c"
  },
  {
    "path": "src/sass/tweet/card.scss",
    "chars": 1699,
    "preview": "@import \"_variables\";\n@import \"_mixins\";\n\n.card {\n  margin: 5px 0;\n  pointer-events: all;\n  max-height: unset;\n}\n\n.card-"
  },
  {
    "path": "src/sass/tweet/embed.scss",
    "chars": 258,
    "preview": "@import \"_variables\";\n@import \"_mixins\";\n\n.embed-video {\n  .gallery-video {\n    width: 100%;\n    height: 100%;\n    posit"
  },
  {
    "path": "src/sass/tweet/media.scss",
    "chars": 2862,
    "preview": "@import \"_variables\";\n\n.gallery-row {\n  display: flex;\n  flex-direction: row;\n  flex-wrap: nowrap;\n  overflow: hidden;\n "
  },
  {
    "path": "src/sass/tweet/poll.scss",
    "chars": 665,
    "preview": "@import \"_variables\";\n\n.poll-meter {\n  overflow: hidden;\n  position: relative;\n  margin: 6px 0;\n  height: 26px;\n  backgr"
  },
  {
    "path": "src/sass/tweet/quote.scss",
    "chars": 1900,
    "preview": "@import \"_variables\";\n\n.quote {\n  margin-top: 10px;\n  border: solid 1px var(--dark_grey);\n  border-radius: 10px;\n  backg"
  },
  {
    "path": "src/sass/tweet/thread.scss",
    "chars": 2449,
    "preview": "@import \"_variables\";\n@import \"_mixins\";\n\n.conversation,\n.edit-history {\n  @include panel(100%, 600px);\n\n  .show-more {\n"
  },
  {
    "path": "src/sass/tweet/video.scss",
    "chars": 1163,
    "preview": "@import \"_variables\";\n@import \"_mixins\";\n\nvideo {\n  height: 100%;\n  width: 100%;\n}\n\n.gallery-video {\n  display: flex;\n  "
  },
  {
    "path": "src/tid.nim",
    "chars": 1791,
    "preview": "import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]\nimport nimcrypto\nimport experimental/p"
  },
  {
    "path": "src/types.nim",
    "chars": 7259,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport times, sequtils, options, tables\nimport prefs_impl\n\ngenPrefsType()\n\ntype"
  },
  {
    "path": "src/utils.nim",
    "chars": 1556,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport sequtils, strutils, strformat, uri, tables, base64\nimport nimcrypto\n\nvar"
  },
  {
    "path": "src/views/about.nim",
    "chars": 770,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport os, strformat\nimport karax/[karaxdsl, vdom]\n\nconst\n  date = staticExec(\""
  },
  {
    "path": "src/views/embed.nim",
    "chars": 659,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport options\nimport karax/[karaxdsl, vdom]\nfrom jester import Request\n\nimport"
  },
  {
    "path": "src/views/feature.nim",
    "chars": 566,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport karax/[karaxdsl, vdom]\n\nproc renderFeature*(): VNode =\n  buildHtml(tdiv("
  },
  {
    "path": "src/views/general.nim",
    "chars": 5267,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport uri, strutils, strformat\nimport karax/[karaxdsl, vdom]\n\nimport renderuti"
  },
  {
    "path": "src/views/list.nim",
    "chars": 876,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strformat\nimport karax/[karaxdsl, vdom]\n\nimport renderutils\nimport \"..\"/"
  },
  {
    "path": "src/views/opensearch.nimf",
    "chars": 580,
    "preview": "#? stdtmpl(subsChar = '$', metaChar = '#')\n## SPDX-License-Identifier: AGPL-3.0-only\n#proc generateOpenSearchXML*(name, "
  },
  {
    "path": "src/views/preferences.nim",
    "chars": 2175,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport tables, macros, strutils\nimport karax/[karaxdsl, vdom]\n\nimport renderuti"
  },
  {
    "path": "src/views/profile.nim",
    "chars": 4396,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, strformat\nimport karax/[karaxdsl, vdom, vstyles]\n\nimport rende"
  },
  {
    "path": "src/views/renderutils.nim",
    "chars": 3794,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, strformat\nimport karax/[karaxdsl, vdom, vstyles]\nimport \"..\"/["
  },
  {
    "path": "src/views/rss.nimf",
    "chars": 7184,
    "preview": "#? stdtmpl(subsChar = '$', metaChar = '#')\n## SPDX-License-Identifier: AGPL-3.0-only\n#import strutils, sequtils, xmltree"
  },
  {
    "path": "src/views/search.nim",
    "chars": 4986,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, strformat, sequtils, unicode, tables, options\nimport karax/[ka"
  },
  {
    "path": "src/views/status.nim",
    "chars": 3541,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport karax/[karaxdsl, vdom]\n\nimport \"..\"/[types, formatters]\nimport tweet, ti"
  },
  {
    "path": "src/views/timeline.nim",
    "chars": 5608,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, strformat, algorithm, uri, options\nimport karax/[karaxdsl, vdo"
  },
  {
    "path": "src/views/tweet.nim",
    "chars": 14644,
    "preview": "# SPDX-License-Identifier: AGPL-3.0-only\nimport strutils, sequtils, strformat, options, algorithm\nimport karax/[karaxdsl"
  },
  {
    "path": "tests/base.py",
    "chars": 3238,
    "preview": "from seleniumbase import BaseCase\n\n\nclass Card(object):\n    def __init__(self, tweet=''):\n        card = tweet + '.card "
  },
  {
    "path": "tests/poetry.toml",
    "chars": 32,
    "preview": "[virtualenvs]\nin-project = true\n"
  },
  {
    "path": "tests/pyproject.toml",
    "chars": 144,
    "preview": "[tool.poetry]\nname = \"nitter-tests\"\nversion = \"0.0.0\"\npackage-mode = false\n\n[tool.poetry.dependencies]\npython = \"^3.14\"\n"
  },
  {
    "path": "tests/requirements.txt",
    "chars": 21,
    "preview": "seleniumbase==4.46.5\n"
  },
  {
    "path": "tests/test_card.py",
    "chars": 3460,
    "preview": "from base import BaseTestCase, Card, Conversation\nfrom parameterized import parameterized\n\n\ncard = [\n    ['nim_lang/stat"
  },
  {
    "path": "tests/test_profile.py",
    "chars": 3179,
    "preview": "from base import BaseTestCase, Profile\nfrom parameterized import parameterized\n\nprofiles = [\n        ['mobile_test', 'Te"
  },
  {
    "path": "tests/test_quote.py",
    "chars": 1773,
    "preview": "from base import BaseTestCase, Quote, Conversation\nfrom parameterized import parameterized\n\ntext = [\n    ['nim_lang/stat"
  },
  {
    "path": "tests/test_search.py",
    "chars": 300,
    "preview": "from base import BaseTestCase\nfrom parameterized import parameterized\n\n\n#class SearchTest(BaseTestCase):\n    #@parameter"
  },
  {
    "path": "tests/test_thread.py",
    "chars": 1924,
    "preview": "from base import BaseTestCase, Conversation\nfrom parameterized import parameterized\n\nthread = [\n    ['octonion/status/97"
  },
  {
    "path": "tests/test_timeline.py",
    "chars": 3462,
    "preview": "from base import BaseTestCase, Timeline\nfrom parameterized import parameterized\n\nnormal = [['jack'], ['elonmusk']]\n\nafte"
  },
  {
    "path": "tests/test_tweet.py",
    "chars": 5218,
    "preview": "from base import BaseTestCase, Tweet, Conversation, get_timeline_tweet\nfrom parameterized import parameterized\n\n# image "
  },
  {
    "path": "tests/test_tweet_media.py",
    "chars": 3871,
    "preview": "from base import BaseTestCase, Poll, Media\nfrom parameterized import parameterized\nfrom selenium.webdriver.common.by imp"
  },
  {
    "path": "tools/create_session_browser.py",
    "chars": 6426,
    "preview": "#!/usr/bin/env python3\n\"\"\"\nRequirements:\n  pip install -r tools/requirements.txt\n\nUsage:\n  python3 tools/create_session_"
  },
  {
    "path": "tools/create_session_curl.py",
    "chars": 11222,
    "preview": "#!/usr/bin/env python3\n\"\"\"\nRequirements:\n  pip install curl_cffi pyotp\n\nUsage:\n  python3 tools/create_session_curl.py <u"
  },
  {
    "path": "tools/create_sessions_browser.py",
    "chars": 7112,
    "preview": "#!/usr/bin/env python3\n\"\"\"\nRequirements:\n  pip install -r tools/requirements.txt\n\nUsage:\n  python3 tools/create_sessions"
  },
  {
    "path": "tools/gencss.nim",
    "chars": 188,
    "preview": "import sass\n\ncompileFile(\"src/sass/index.scss\",\n            outputPath = \"public/css/style.css\",\n            includePath"
  },
  {
    "path": "tools/get_session.py",
    "chars": 5574,
    "preview": "#!/usr/bin/env python3\nimport requests\nimport json\nimport sys\nimport pyotp\nimport cloudscraper\n\n# NOTE: pyotp, requests "
  },
  {
    "path": "tools/rendermd.nim",
    "chars": 221,
    "preview": "import std/[os, strutils]\nimport markdown\n\nfor file in walkFiles(\"public/md/*.md\"):\n  let\n    html = markdown(readFile(f"
  },
  {
    "path": "tools/requirements.txt",
    "chars": 33,
    "preview": "nodriver>=0.48.0\npyotp\ncurl_cffi\n"
  }
]

About this extraction

This page contains the full source code of the zedeus/nitter GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 127 files (370.3 KB), approximately 104.7k tokens, and a symbol index with 98 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!