feat: add nitter

This commit is contained in:
Viren070
2025-07-08 23:32:44 +01:00
parent 9303aeee56
commit 78eea1c542
9 changed files with 311 additions and 0 deletions
+1
View File
@@ -142,6 +142,7 @@ LIBRESPEED_HOSTNAME=speedtest.${DOMAIN}
MEDIAFLOW_PROXY_HOSTNAME=mediaflow-proxy.${DOMAIN}
MEDIAFUSION_HOSTNAME=mediafusion.${DOMAIN}
MINECRAFT_HOSTNAME=mc.${DOMAIN}
NITTER_HOSTNAME=nitter.${DOMAIN}
NZBHYDRA2_HOSTNAME=nzbhydra2.${DOMAIN}
OMG_TV_STREMIO_ADDON_HOSTNAME=omg-tv-addon.${DOMAIN}
OVERSEERR_HOSTNAME=overseerr.${DOMAIN}
+1
View File
@@ -41,6 +41,7 @@ services:
${MEDIAFLOW_PROXY_HOSTNAME},
${MEDIAFUSION_HOSTNAME},
${MINECRAFT_HOSTNAME},
${NITTER_HOSTNAME},
${NZBHYDRA2_HOSTNAME},
${OMG_TV_STREMIO_ADDON_HOSTNAME},
${OVERSEERR_HOSTNAME},
+16
View File
@@ -0,0 +1,16 @@
# The tag to use for the Nitter Docker image.
# If not on an arm64 architecture, you can use the latest tag.
NITTER_TAG=latest-arm64
# In order to run Nitter, we need to obtain the session secrets from Twitter.
# To do this, fill in the following values with your Twitter credentials:
# NOTE: Please remove the username and password from here after running the containers once successfully.
# You will find the sessions in the ./sessions/sessions.jsonl file to see if it worked, you can also check the logs of the nitter_sessions container.
TW_USERNAME=
TW_PASSWORD=
TW_OTP_SECRET=
# Make sure to also edit the nitter.conf file to set the hostname to your own domain or IP address.
# And provide the hmacKey in the nitter.conf file, which is used for cryptographic signing of video URLs.
# You can generate a random key using the following command:
# openssl rand -base64 42 | tr -dc A-Za-z0-9 | cut -c -32 | tr -d '\n'; echo
+64
View File
@@ -0,0 +1,64 @@
services:
nitter:
image: zedeus/nitter:${NITTER_TAG:-latest}
container_name: nitter
restart: unless-stopped
user: ${PUID}:${PGID}
expose:
- 8080
labels:
- "traefik.enable=true"
- "traefik.http.routers.nitter.rule=Host(`${NITTER_HOSTNAME?}`)"
- "traefik.http.routers.nitter.entrypoints=websecure"
- "traefik.http.routers.nitter.tls.certresolver=letsencrypt"
- "traefik.http.services.nitter.loadbalancer.server.port=8080"
- "traefik.http.routers.nitter.middlewares=authelia@docker"
volumes:
- ./nitter.conf:/src/nitter.conf:Z,ro
- ./sessions/sessions.jsonl:/src/sessions.jsonl:Z,ro
depends_on:
nitter_redis:
condition: service_healthy
nitter_sessions:
condition: service_completed_successfully
healthcheck:
test: wget -nv --tries=1 --spider http://127.0.0.1:8080/Jack/status/20 || exit 1
interval: 30s
timeout: 5s
start_period: 10s
retries: 2
profiles:
- nitter
- all
nitter_redis:
image: redis:6-alpine
container_name: nitter_redis
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- ${DOCKER_DATA_DIR}/nitter:/data
healthcheck:
test: redis-cli ping
interval: 30s
timeout: 5s
retries: 2
profiles:
- nitter
- all
nitter_sessions:
build:
context: ./sessions
dockerfile: Dockerfile
container_name: nitter_sessions
restart: no
environment:
USERNAME: ${TW_USERNAME}
PASSWORD: ${TW_PASSWORD}
OTP_SECRET: ${TW_OTP_SECRET}
volumes:
- ./sessions:/app/sessions
profiles:
- nitter
- all
+38
View File
@@ -0,0 +1,38 @@
[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 = "nitter_redis"
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 = "80c6b3849c8f6e3013986eeecc1e70b06a1ef4e2168a37cb5b7b4385d241e13e" # random key for cryptographic signing of video urls
base64Media = false # use base64 encoding for proxied media urls
enableRSS = true # set this to false to disable RSS feeds
enableDebug = true # enable request logs and debug endpoints (/.sessions)
proxy = "http://warp:1080" # http/https url, SOCKS proxies are not supported
proxyAuth = ""
# 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
+14
View File
@@ -0,0 +1,14 @@
# Use a lightweight Python base image
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install dependencies
RUN pip install --no-cache-dir requests pyotp
# Copy the script into the container
COPY get_session.py .
# Default command (can be overridden)
ENTRYPOINT ["python3", "get_session.py"]
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
import requests
import json
import sys
import pyotp
import os
# NOTE: pyotp and requests are dependencies
# > pip install pyotp requests
TW_CONSUMER_KEY = '3nVuSoBZnx6U4vzUxf5w'
TW_CONSUMER_SECRET = 'Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys'
def auth(username, password, otp_secret):
bearer_token_req = requests.post("https://api.twitter.com/oauth2/token",
auth=(TW_CONSUMER_KEY, TW_CONSUMER_SECRET),
headers={"Content-Type": "application/x-www-form-urlencoded"},
data='grant_type=client_credentials'
).json()
bearer_token = ' '.join(str(x) for x in bearer_token_req.values())
guest_token = requests.post(
"https://api.twitter.com/1.1/guest/activate.json",
headers={'Authorization': bearer_token}
).json().get('guest_token')
if not guest_token:
print("Failed to obtain guest token.")
sys.exit(1)
twitter_header = {
'Authorization': bearer_token,
"Content-Type": "application/json",
"User-Agent": "TwitterAndroid/10.21.0-release.0 (310210000-r-0) ONEPLUS+A3010/9 (OnePlus;ONEPLUS+A3010;OnePlus;OnePlus3;0;;1;2016)",
"X-Twitter-API-Version": '5',
"X-Twitter-Client": "TwitterAndroid",
"X-Twitter-Client-Version": "10.21.0-release.0",
"OS-Version": "28",
"System-User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9; ONEPLUS A3010 Build/PKQ1.181203.001)",
"X-Twitter-Active-User": "yes",
"X-Guest-Token": guest_token,
"X-Twitter-Client-DeviceID": ""
}
session = requests.Session()
session.headers = twitter_header
task1 = session.post(
'https://api.twitter.com/1.1/onboarding/task.json',
params={
'flow_name': 'login',
'api_version': '1',
'known_device_token': '',
'sim_country_code': 'us'
},
json={
"flow_token": None,
"input_flow_data": {
"country_code": None,
"flow_context": {
"referrer_context": {
"referral_details": "utm_source=google-play&utm_medium=organic",
"referrer_url": ""
},
"start_location": {
"location": "deeplink"
}
},
"requested_variant": None,
"target_user_id": 0
}
}
)
session.headers['att'] = task1.headers.get('att')
task2 = session.post(
'https://api.twitter.com/1.1/onboarding/task.json',
json={
"flow_token": task1.json().get('flow_token'),
"subtask_inputs": [{
"enter_text": {
"suggestion_id": None,
"text": username,
"link": "next_link"
},
"subtask_id": "LoginEnterUserIdentifier"
}]
}
)
task3 = session.post(
'https://api.twitter.com/1.1/onboarding/task.json',
json={
"flow_token": task2.json().get('flow_token'),
"subtask_inputs": [{
"enter_password": {
"password": password,
"link": "next_link"
},
"subtask_id": "LoginEnterPassword"
}],
}
)
for t3_subtask in task3.json().get('subtasks', []):
if "open_account" in t3_subtask:
return t3_subtask["open_account"]
elif "enter_text" in t3_subtask:
response_text = t3_subtask["enter_text"]["hint_text"]
totp = pyotp.TOTP(otp_secret)
generated_code = totp.now()
task4resp = session.post(
"https://api.twitter.com/1.1/onboarding/task.json",
json={
"flow_token": task3.json().get("flow_token"),
"subtask_inputs": [
{
"enter_text": {
"suggestion_id": None,
"text": generated_code,
"link": "next_link",
},
"subtask_id": "LoginTwoFactorAuthChallenge",
}
],
}
)
task4 = task4resp.json()
for t4_subtask in task4.get("subtasks", []):
if "open_account" in t4_subtask:
return t4_subtask["open_account"]
return None
if __name__ == "__main__":
username = os.getenv("USERNAME")
password = os.getenv("PASSWORD")
otp_secret = os.getenv("OTP_SECRET")
path = os.getenv("SESSION_PATH", "/app/sessions/sessions.jsonl")
os.makedirs(os.path.dirname(path), exist_ok=True)
if not username or not password:
# parse session file
try:
with open(path, "r") as f:
sessions = [json.loads(line) for line in f if line.strip()]
# if no sessions found, exit
if not sessions:
print("No sessions found. Please provide username and password.")
sys.exit(1)
# exit with 0 status code
print("No username or password provided. Using existing sessions.")
sys.exit(0)
except FileNotFoundError:
print("Session file not found. Please provide username and password.")
sys.exit(1)
result = auth(username, password, otp_secret)
if result is None:
print("Authentication failed.")
sys.exit(1)
session_entry = {
"oauth_token": result.get("oauth_token"),
"oauth_token_secret": result.get("oauth_token_secret")
}
try:
with open(path, "a") as f:
f.write(json.dumps(session_entry) + "\n")
print("Authentication successful. Session appended to", path)
except Exception as e:
print(f"Failed to write session information: {e}")
sys.exit(1)
View File
+1
View File
@@ -31,6 +31,7 @@ include:
- apps/mediaflow-proxy/compose.yaml
- apps/mediafusion/compose.yaml
- apps/minecraft/compose.yaml
- apps/nitter/compose.yaml
- apps/nzbhydra2/compose.yaml
- apps/omg-tv-addon/compose.yaml
- apps/overseerr/compose.yaml