Compare commits

...
72 Commits
Author SHA1 Message Date
Future 70487edc0b Merge pull request 'YOLO UPDATE' (#3) from update into alterware
Run Tests / build (push) Failing after 1m0s
Reviewed-on: #3
2026-09-01 11:55:14 +00:00
Future e9ef52b4e9 Merge branch 'main' of https://github.com/dylanpdx/BetterTwitFix into update
Run Tests / build (push) Failing after 1m5s
2026-09-01 13:52:40 +02:00
Dylan c1d3a7e2dd Fix HTML escape issues 2026-07-15 14:12:05 +01:00
Dylan 161cd4d4e2 Fix feed endpoints 2026-06-24 00:23:52 +01:00
Dylan 6ac40e4a52 Fix guest token fetch and restore original method 2026-06-23 22:28:55 +01:00
Dylan f06ef199bb Switch to syndication for anon extract 2026-06-23 18:19:17 +01:00
Dylan 662491f1b4 Add subdomains as input for translation #273 2026-04-19 15:27:34 +01:00
Dylan 0e3d24ad55 Fix for #305 2026-04-19 13:40:26 +01:00
Dylan fb1fdaf4b5 Fix reverted change 2026-04-19 01:39:52 +01:00
Dylan 7c7a6e6a59 Finish translation implementation 2026-04-18 23:50:09 +01:00
Dylan 1a6f424e69 Initial translation implementation #273 2026-04-18 20:11:03 +01:00
Dylan 4794857c0a Fix feed extract 2026-04-18 14:59:38 +01:00
Dylan a610cd8e29 Add cache headers 2026-04-18 14:26:29 +01:00
Dylan 3d4e42223f Update api calls 2026-02-01 22:39:04 +00:00
Dylan afcf6b10aa Update extractStatusV2Android 2026-02-01 19:12:18 +00:00
Dylan 68cc985b8c Small fix for Activity embeds + non-discord clients 2026-02-01 16:17:41 +00:00
Dylan fb8abeb904 Convert gifv to avif 2026-02-01 15:57:00 +00:00
Dylan d262fe1014 Update "gif" conversion logic 2026-01-30 22:52:38 +00:00
Future 80a12a4b63 Merge pull request 'November update' (#2) from github/origin into alterware
Run Tests / build (push) Failing after 1m13s
Reviewed-on: #2
2025-11-10 07:43:07 +00:00
Future 23d0564129 Merge branch 'main' of https://github.com/dylanpdx/BetterTwitFix into github/origin
Run Tests / build (push) Failing after 2m11s
2025-11-10 08:40:46 +01:00
Dylan 489bbdf026 Stop usage of tweetdeck bearer 2025-11-06 21:30:29 +00:00
Dylan 082cb17347 Don't use explicit bearer token 2025-11-04 17:41:08 +00:00
Dylan e5b9fb9824 Fix #303 2025-11-02 19:32:35 +00:00
Dylan 9d83c962e7 Re-enable extractStatusV2Android 2025-10-27 15:53:21 +00:00
Dylan 03ed130f7c Merge pull request #300 from Xeukxz/main
Support alternate card key
2025-10-19 22:18:01 +01:00
Xeukxz 7a78cc08ca Support alternate card key 2025-10-16 21:21:08 +01:00
Dylan c9b4f84248 Remove old test 2025-10-12 23:07:59 +01:00
Dylan 78e0ecfaa9 Fix small issue with card logic 2025-10-12 23:06:35 +01:00
Dylan e8720bf677 Merge pull request #297 from Xeukxz/main
Support for ad cards
2025-10-12 22:56:27 +01:00
Dylan d9101dc727 Fix media not being appended to 2025-10-12 22:55:19 +01:00
Xeukxz c37d7195a3 Support for ad cards 2025-10-12 21:41:42 +01:00
Dylan a27b768ff3 Use env variable for requests pool 2025-09-25 00:45:00 +01:00
Dylan b657ae0076 Return to using extractStatusV2Anon 2025-09-24 21:44:41 +01:00
Dylan 433f015c07 Temporary switch to syndication 2025-09-24 20:59:19 +01:00
Dylan acc81df27b Merge branch 'main' of github.com:dylanpdx/BetterTwitFix 2025-09-24 18:35:12 +01:00
Dylan 0fc3dce253 Update v2Anon Extraction 2025-09-24 18:35:07 +01:00
Dylan ab36a55603 Merge pull request #285 from xnand-dot-xyz/main
Fix getUserData when workaroundTokens is unset
2025-09-17 18:34:16 +01:00
xnand-dot-xyz 7791b56419 check if workaroundTokens is None before splitting 2025-09-12 15:35:40 +00:00
Dylan fcc36e6a68 Merge pull request #266 from dylanpdx/dependabot/pip/boto3-1.36.6
Bump boto3 from 1.35.18 to 1.36.6
2025-09-10 17:42:49 +01:00
Dylan 1bc50830f5 Add id_str for media: Fixes #283 2025-09-10 17:29:15 +01:00
Dylan 7a97adcf43 Tweet feed & User by ID working #284 2025-09-10 17:06:42 +01:00
Dylan cbf55e7429 User API working again, with_tweets still broken. #284 2025-09-10 16:46:05 +01:00
Dylan 98196b0e30 Reduce function memory size 2025-08-23 16:25:17 +01:00
Dylan a314d5f65e Temporarily remove unreliable methods 2025-08-23 16:24:34 +01:00
Dylan b34844e259 Avoid calling extractStatusV2Anon twice 2025-08-23 16:13:31 +01:00
Dylan 589abd68e9 Parallelize other extraction functions 2025-08-23 15:35:23 +01:00
Dylan efc03399ab Prioritize extractStatusV2 in extract logic 2025-08-23 15:05:21 +01:00
Dylan 4ac17cf451 parallelize extractStatusV2 2025-08-23 15:01:47 +01:00
Dylan f4d1308b93 Add Cache-Control to messages 2025-08-07 18:55:25 +01:00
Dylan 911a49b04f Use cache tags 2025-07-28 16:45:10 +01:00
Dylan a6be414129 Fix direct embeds redirecting to gif version (#281) 2025-07-11 18:36:19 +01:00
Dylan cd39216891 Revert "Updated tweet history endpoint & temporarily using guest token"
This reverts commit fc17870b06.
2025-06-22 13:20:01 +01:00
Future f7dddc42c1 Merge pull request 'chore: update my branch' (#1) from update/alterware into alterware
Run Tests / build (push) Failing after 1m31s
Reviewed-on: #1
2025-06-02 09:29:43 +00:00
alterware f849d24779 Merge branch 'main' into alterware
Run Tests / build (push) Failing after 2m47s
2025-06-02 09:25:02 +00:00
Dylan fc17870b06 Updated tweet history endpoint & temporarily using guest token 2025-05-24 00:38:17 +01:00
Dylan 7e8f7b87c9 Add logging for extractUserFeedFromId fail 2025-05-23 23:58:21 +01:00
Dylan f3adcf0c78 Add orig to image path 2025-05-14 16:26:16 +01:00
Dylan 72847c7993 Fix #268 2025-05-08 15:39:15 +01:00
Dylan 54ec334730 Provide better responses for errors (#196); Fix some extract methods 2025-04-29 13:30:57 +01:00
Dylan 1d03bf80e7 Support for getting RT information (#279) 2025-04-29 00:06:54 +01:00
Dylan e64aa41ff6 Fix #278 2025-04-28 23:34:23 +01:00
Dylan c67422d569 Update fetched_on #196 2025-04-28 22:21:07 +01:00
Dylan fa979086c9 Add additional api tests 2025-04-27 19:03:12 +01:00
Dylan 9ca4b31796 withFeed -> with_tweets 2025-04-27 17:50:11 +01:00
Dylan 9ea320eb9c latestTweets -> latest_tweets 2025-04-27 17:35:19 +01:00
Dylan f48a9b205d add fetched_on in API response 2025-04-27 17:33:08 +01:00
Dylan 441b620b87 Add support for ?withFeed in API user requests 2025-04-27 17:30:07 +01:00
Dylan 84b620894f Fix typo in PR 2025-04-24 13:23:42 +01:00
Dylan 9a4889fab3 Merge pull request #277 from kkiwior/main
Add replyingToID field in api response
2025-04-24 13:21:06 +01:00
Kamil Kiwior b87ad04e5f add replyingToID field in api response 2025-04-23 19:02:23 +02:00
Dylan c2ed8aa884 Merge pull request #276 from diamante0018/main
fix DB cache
2025-04-22 21:53:48 +01:00
dependabot[bot] 764e30be02 Bump boto3 from 1.35.18 to 1.36.6
Bumps [boto3](https://github.com/boto/boto3) from 1.35.18 to 1.36.6.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.35.18...1.36.6)

---
updated-dependencies:
- dependency-name: boto3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-01-27 01:24:25 +00:00
23 changed files with 871 additions and 437 deletions
+1
View File
@@ -71,3 +71,4 @@ jobs:
VXTWITTER_WORKAROUND_TOKENS: ${{ secrets.VXTWITTER_WORKAROUND_TOKENS }} VXTWITTER_WORKAROUND_TOKENS: ${{ secrets.VXTWITTER_WORKAROUND_TOKENS }}
VXTWITTER_PROXIES: ${{ secrets.VXTWITTER_PROXIES }} VXTWITTER_PROXIES: ${{ secrets.VXTWITTER_PROXIES }}
SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }} SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }}
DEEPL_API_KEY: ${{ secrets.DEEPL_API_KEY }}
+18 -3
View File
@@ -2,17 +2,27 @@ import datetime
import msgs import msgs
from utils import determineEmbedTweet, determineMediaToEmbed from utils import determineEmbedTweet, determineMediaToEmbed
from copy import deepcopy from copy import deepcopy
import html
def tweetDataToActivity(tweetData,embedIndex = -1): def tweetDataToActivity(tweetData,embedIndex = -1):
content="" content=""
if tweetData['replyingTo'] is not None: if tweetData['replyingTo'] is not None:
content += f"<blockquote>↪️ <i>Replying to @{tweetData['replyingTo']}</i></blockquote>" content += f"<blockquote>↪️ <i>Replying to @{tweetData['replyingTo']}</i></blockquote>"
content+=f"<p>{tweetData['text']}</p>"
mainText = html.escape(tweetData['text'])
if tweetData["translation"] is not None:
content += f"<blockquote>🌐 <i>{tweetData['translation']['source_language'].upper()}{tweetData['translation']['destination_language'].upper()}</i></blockquote>"
mainText=html.escape(tweetData['translation']["text"])
content+=f"<p>{mainText}</p>"
attachments=[] attachments=[]
if tweetData['qrt'] is not None: if tweetData['qrt'] is not None:
content += f"<blockquote><b>QRT: <a href=\"{tweetData['qrtURL']}\">{tweetData['qrt']['user_screen_name']}</a></b><br>{tweetData['qrt']['text']}</blockquote>" qrtText = tweetData['qrt']['text']
if "translation" in tweetData['qrt'] and tweetData['qrt']["translation"] is not None:
qrtText = tweetData['qrt']["translation"]["text"]
content += f"<blockquote><b>QRT: <a href=\"{tweetData['qrtURL']}\">{tweetData['qrt']['user_screen_name']}</a></b><br>{qrtText}</blockquote>"
if tweetData['pollData'] is not None: if tweetData['pollData'] is not None:
content += f"<p>{msgs.genPollDisplay(tweetData['pollData'])}</p>" content += f"<p>{msgs.genPollDisplay(tweetData['pollData'])}</p>"
content += "</p>" content += "</p>"
@@ -35,9 +45,14 @@ def tweetDataToActivity(tweetData,embedIndex = -1):
if media is not None: if media is not None:
media = deepcopy(media) media = deepcopy(media)
if media['type'] == "gif": if media['type'] == "gif":
media['type'] = "gifv" if "/convert.avif" in media['url']:
media['type'] = "image"
else:
media['type'] = "gifv"
if 'thumbnail_url' not in media: if 'thumbnail_url' not in media:
media['thumbnail_url'] = media['url'] media['thumbnail_url'] = media['url']
if media['type'] == "image" and "?" not in media['url']:
media['url'] += "?name=orig"
attachments.append({ attachments.append({
"id": "114163769487684704", "id": "114163769487684704",
"type": media['type'], "type": media['type'],
+4 -2
View File
@@ -13,7 +13,8 @@ if ('RUNNING_SERVERLESS' in os.environ and os.environ['RUNNING_SERVERLESS'] == '
"url": os.getenv("VXTWITTER_URL","https://vxtwitter.com"), "url": os.getenv("VXTWITTER_URL","https://vxtwitter.com"),
"combination_method": os.getenv("VXTWITTER_COMBINATION_METHOD","local"), # can either be 'local' or a URL to a server handling requests in the same format "combination_method": os.getenv("VXTWITTER_COMBINATION_METHOD","local"), # can either be 'local' or a URL to a server handling requests in the same format
"gifConvertAPI":os.getenv("VXTWITTER_GIF_CONVERT_API",""), "gifConvertAPI":os.getenv("VXTWITTER_GIF_CONVERT_API",""),
"workaroundTokens":os.getenv("VXTWITTER_WORKAROUND_TOKENS",None) "workaroundTokens":os.getenv("VXTWITTER_WORKAROUND_TOKENS",None),
"deeplKey":os.getenv("DEEPL_API_KEY",None),
} }
} }
else: else:
@@ -31,7 +32,8 @@ else:
"url": "https://vxtwitter.com", "url": "https://vxtwitter.com",
"combination_method": "local", # can either be 'local' or a URL to a server handling requests in the same format "combination_method": "local", # can either be 'local' or a URL to a server handling requests in the same format
"gifConvertAPI":"", "gifConvertAPI":"",
"workaroundTokens":None "workaroundTokens":None,
"deeplKey":None
} }
} }
+68 -22
View File
@@ -1,30 +1,76 @@
FROM public.ecr.aws/lambda/python:3.8 AS builder ARG TARGETARCH
RUN yum -y install git curl FROM public.ecr.aws/lambda/python:3.12 AS builder
RUN yum -y groupinstall 'Development Tools' RUN dnf -y install git cargo && dnf clean all
RUN git clone https://github.com/kohler/gifsicle RUN git clone https://github.com/ImageOptim/gifski /gifski
WORKDIR gifsicle WORKDIR /gifski
RUN autoreconf -i RUN cargo build --release
RUN ./configure --disable-gifview --disable-gifdiff
RUN make
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
WORKDIR /var/task
RUN git clone https://github.com/ImageOptim/gifski
WORKDIR gifski
RUN /root/.cargo/bin/cargo build --release
#FROM public.ecr.aws/lambda/python:3.12 AS ffmpeg-linux-amd64
#RUN dnf -y update
#RUN dnf -y install git wget tar.x86_64 xz && dnf clean all
#WORKDIR /ffmpeg
#RUN wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
#RUN tar -xvf ffmpeg-release-amd64-static.tar.xz
FROM public.ecr.aws/lambda/python:3.8 #FROM public.ecr.aws/lambda/python:3.12 AS ffmpeg-linux-arm64
RUN yum -y update #RUN dnf -y update
RUN yum -y install git && yum -y install wget && yum -y install tar.x86_64 && yum -y install xz && yum clean all #RUN dnf -y install git wget tar xz && dnf clean all
RUN wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz #WORKDIR /ffmpeg
RUN tar -xvf ffmpeg-release-amd64-static.tar.xz #RUN wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz
RUN mv ff*/ffmpeg . && mv ff*/ffprobe . && rm *.tar.xz && rm -rf ff*/ #RUN tar -xvf ffmpeg-release-arm64-static.tar.xz
COPY --from=builder /var/task/gifsicle/src/gifsicle ./
COPY --from=builder /var/task/gifski/target/release/gifski ./ #FROM ffmpeg-linux-${TARGETARCH} AS ffmpeg
FROM public.ecr.aws/lambda/python:3.12 AS ffmpeg
# tl;dr: build ffmpeg from source with svt-av1 support and statically linked binaries
# Install dependencies
RUN dnf remove -y microdnf-dnf && microdnf install -y dnf
RUN dnf group install "Development Tools" -y
RUN mkdir /ffmpeg_sources
WORKDIR /ffmpeg_sources
RUN git clone https://git.ffmpeg.org/ffmpeg.git .
RUN git clone --branch stable --depth 1 https://code.videolan.org/videolan/x264.git
RUN git clone https://gitlab.com/AOMediaCodec/SVT-AV1.git
RUN dnf install curl nasm glibc-static libstdc++-static cmake --allowerasing -y
# Install x264
WORKDIR /ffmpeg_sources/x264
RUN PKG_CONFIG_PATH="/ffmpeg_build/lib/pkgconfig" ./configure --prefix="/ffmpeg_build" --bindir="/bin" --enable-static
RUN make -j
RUN make install
# Install SVT-AV1
WORKDIR /ffmpeg_sources
WORKDIR /ffmpeg_sources/SVT-AV1/Build
RUN PKG_CONFIG_PATH="/ffmpeg_build/lib/pkgconfig" cmake .. -G"Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DENABLE_STATIC=ON -DBUILD_SHARED_LIBS=OFF -DCMAKE_C_FLAGS_INIT="-static"
RUN make -j
RUN make install
# Install FFmpeg
WORKDIR /ffmpeg_sources
RUN PKG_CONFIG_PATH="/ffmpeg_build/lib/pkgconfig" ./configure --prefix="/ffmpeg_build" \
--pkg-config-flags="--static" --extra-ldexeflags="-static" --extra-libs="-lpthread -lm" --bindir="/bin" --disable-shared --enable-static --enable-gpl --enable-pthreads \
--enable-libx264 --enable-libsvtav1 --disable-ffplay
RUN make -j 8
FROM public.ecr.aws/lambda/python:3.12
#COPY --from=builder /gifski/target/release/gifski /usr/local/bin/gifski
#COPY --from=ffmpeg /ffmpeg/ffmpeg-*-static/ffmpeg /usr/local/bin/ffmpeg
#COPY --from=ffmpeg /ffmpeg/ffmpeg-*-static/ffprobe /usr/local/bin/ffprobe
COPY --from=ffmpeg /ffmpeg_sources/ffmpeg /usr/local/bin/ffmpeg
COPY --from=ffmpeg /ffmpeg_sources/ffprobe /usr/local/bin/ffprobe
RUN pip install requests==2.32.3
# Copy function code # Copy function code
COPY __init__.py ${LAMBDA_TASK_ROOT}/app.py COPY __init__.py ${LAMBDA_TASK_ROOT}/app.py
COPY conv.sh ${LAMBDA_TASK_ROOT}/conv.sh
# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile) # Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile)
CMD [ "app.lambda_handler" ] CMD [ "app.lambda_handler" ]
+121 -77
View File
@@ -1,100 +1,144 @@
import base64 import base64
import os import os
import subprocess import subprocess
import json
import sys
import tempfile import tempfile
import urllib.request
import re
import botocore
import boto3
import requests
useBucket=False
bucketname=os.getenv('CF_BUCKET')
s3=None
if bucketname is None:
useBucket=False
else:
useBucket=True
s3 = boto3.client('s3',endpoint_url=os.getenv('CF_ENDPOINT'),aws_access_key_id=os.getenv('CF_KEY'),aws_secret_access_key=os.getenv('CF_KEY_SECRET'))
def extractStatus(url): def extractStatus(url):
return "" return ""
def get_video_frame_rate(filename): def convert_video_to_gif(filename):
result = subprocess.run( try:
[ new_filename = tempfile.mkstemp(suffix=".gif")[1]
"./ffprobe", print("converting gif w gifski")
"-v", p_ffmpeg = subprocess.Popen(["ffmpeg", "-i", filename, "-f", "yuv4mpegpipe", "-"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
"error", p_gifski = subprocess.Popen(["gifski","--quality","70","--lossy-quality","30","-o", new_filename, "-"], stdin=p_ffmpeg.stdout, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
"-select_streams",
"v",
"-of",
"default=noprint_wrappers=1:nokey=1",
"-show_entries",
"stream=r_frame_rate",
filename,
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
result_string = result.stdout.decode('utf-8').split()[0].split('/')
fps = float(result_string[0])/float(result_string[1])
return fps
def get_video_length_seconds(filename): p_ffmpeg.stdout.close()
result = subprocess.run( ret_gifski = p_gifski.wait()
[ ret_ffmpeg = p_ffmpeg.wait()
"./ffprobe", if ret_gifski != 0:
"-v", print("err: gifski exited with code:", ret_gifski)
"error", if ret_ffmpeg != 0:
"-show_entries", print("err: ffmpeg exited with code:", ret_ffmpeg)
"format=duration", if os.path.isfile(new_filename) and os.path.getsize(new_filename) > 0:
"-of", return new_filename
"default=noprint_wrappers=1:nokey=1", else:
filename, print("gifski failed to convert gif")
], return filename
stdout=subprocess.PIPE, except Exception as e:
stderr=subprocess.STDOUT, print("error converting gif (convert_video_to_gif):")
) print(e)
result_string = result.stdout.decode('utf-8').split()[0]
return float(result_string)
def calcEdits(vlen,loopTimes):
st="r"
for i in range(loopTimes):
st+=f'e{str((vlen*i))}-9999,0'
return st
def loop_video_until_length(filename, length):
# use stream_loop to loop video until it's at least length seconds long
video_length = get_video_length_seconds(filename)
if video_length < length:
loops = int(length/video_length)
new_filename = tempfile.mkstemp(suffix=".mp4")[1]
#edits = calcEdits(video_length,loops)
out = subprocess.call(["ffmpeg","-stream_loop",str(loops),"-i",filename,"-c","copy","-y",new_filename],stdout=subprocess.DEVNULL,stderr=subprocess.STDOUT)
#subprocess.run(["./MP4Box", "-add",filename,"-edits",f'1={edits}',new_filename])
return new_filename
else:
return filename return filename
def convert_video_to_avif(filename):
try:
fd,new_filename = tempfile.mkstemp(suffix=".avif")
os.close(fd)
print("converting gif w ffmpeg to avif")
subprocess.call(["ffmpeg","-nostdin","-y", "-i", filename,"-pix_fmt","yuv420p","-an","-c:v","libsvtav1","-crf","30","-b:v","0","-threads","4", new_filename], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
if os.path.isfile(new_filename) and os.path.getsize(new_filename) > 0:
return new_filename
else:
print("ffmpeg failed to convert avif")
return filename
except Exception as e:
print("error converting avif (convert_video_to_avif):")
print(e)
return filename
def redir(url):
return {
"statusCode": 307,
"headers": {
"Location": url
}
}
def lambda_handler(event, context): def lambda_handler(event, context):
if ("queryStringParameters" not in event): if ("queryStringParameters" not in event):
return { return {
"statusCode": 400, "statusCode": 400,
"body": "Invalid request." "body": "Invalid request!"
} }
url = event["queryStringParameters"].get("url","") url = event["queryStringParameters"].get("url","")
try:
if url == "":
return {
"statusCode": 400,
"body": "Invalid request!!"
}
if not url.startswith("https://video.twimg.com/tweet_video/"):
return redir(url)
# download video if useBucket:
videoLocation = tempfile.mkstemp(suffix=".mp4")[1] id=re.search(r"https:\/\/video\.twimg\.com\/tweet_video\/(.*?)\..*",url).group(1)
subprocess.call(["wget","-O",videoLocation,url],stdout=subprocess.DEVNULL,stderr=subprocess.STDOUT) bfilename = str(id)+".avif"
furl=f"https://gifs.vxtwitter.com/{bfilename}" #f"https://{bucketname}.s3.amazonaws.com/{bfilename}"
print("get req for: "+url)
try:
s3.head_object(Bucket=bucketname, Key=bfilename)
print("found existing already: "+bfilename)
return redir(furl)
except botocore.exceptions.ClientError:
# Not found
pass
# download video
print("downloading: "+url)
videoLocation = tempfile.mkstemp(suffix=".mp4")[1]
response = requests.get(url, stream=True)
if response.status_code == 200:
with open(videoLocation, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
else:
print("error downloading video")
return redir(url)
videoLocationLooped = loop_video_until_length(videoLocation, 30) videoLocationLooped = convert_video_to_avif(videoLocation)
if videoLocationLooped != videoLocation: if videoLocationLooped != videoLocation:
os.remove(videoLocation) os.remove(videoLocation)
videoLocation = videoLocationLooped videoLocation = videoLocationLooped
else:
os.remove(videoLocation)
return redir(url)
with open(videoLocation, "rb") as image_file: if not useBucket:
encoded_string = base64.b64encode(image_file.read()).decode('ascii') with open(videoLocation, "rb") as image_file:
os.remove(videoLocation) encoded_string = base64.b64encode(image_file.read()).decode('ascii')
return { os.remove(videoLocation)
'statusCode': 200, return {
"headers": 'statusCode': 200,
{ "headers":
"Content-Type": "video/mp4" {
}, "Content-Type": "image/avif",
'body': encoded_string, "Cache-Control": "public, max-age=604800, immutable"
'isBase64Encoded': True },
} 'body': encoded_string,
'isBase64Encoded': True
}
else:
with open(videoLocation, "rb") as image_file:
s3.upload_fileobj(image_file, bucketname, bfilename)
os.remove(videoLocation)
print("converted: "+url+" -> "+furl)
return redir(furl)
except Exception as e:
print("error converting gif: ")
print(e)
return redir(url)
-64
View File
@@ -1,64 +0,0 @@
#!/bin/bash -e
usage(){
echo "Usage: $0 [options] output"
echo "Options:"
echo " --help Show this help"
echo " -u, --url URL of the video"
echo " -w, --max-width Maximum width of the output"
echo " -h, --max-height Maximum height of the output"
echo " -t, --threads Number of threads to use"
exit 1
}
URL=""
MAXW=400
MAXH=267
THREADS=1
OUTPUT="out.gif"
FPS=10
while [ $# -gt 0 ]; do
case "$1" in
--help)
usage
;;
-u|--url)
URL="$2"
shift
;;
-w|--max-width)
MAXW="$2"
shift
;;
-h|--max-height)
MAXH="$2"
shift
;;
-t|--threads)
THREADS="$2"
shift
;;
-f|--fps)
FPS="$2"
shift
;;
-*)
echo "Unknown option: $1"
usage
;;
*)
OUTPUT="$1"
;;
esac
shift
done
# make unique temp directory
TEMPDIR=$( mktemp -d )
./ffmpeg -i "$URL" -vf "scale=if(gte(iw\,ih)\,min($MAXW\,iw)\,-2):if(lt(iw\,ih)\,min($MAXH\,ih)\,-2)" -threads $THREADS "$TEMPDIR/frame%04d.png"
./gifski -o "$TEMPDIR/out.gif" --fast --fps $FPS --quality=90 $TEMPDIR/frame*.png
#./gifsicle -O3 "$TEMPDIR/out.gif" -o "$OUTPUT"
mv "$TEMPDIR/out.gif" "$OUTPUT"
rm -rf "$TEMPDIR"
+12 -3
View File
@@ -17,8 +17,11 @@ def genLikesDisplay(vnf):
return ("💖 " + numerize.numerize(vnf['likes'])) return ("💖 " + numerize.numerize(vnf['likes']))
def genQrtDisplay(qrt): def genQrtDisplay(qrt):
text = qrt['text']
if "translation" in qrt and qrt["translation"] is not None:
text = qrt["translation"]["text"]
verifiedCheck = "☑️" if ('verified' in qrt and qrt['verified']) else "" verifiedCheck = "☑️" if ('verified' in qrt and qrt['verified']) else ""
return ("\n\n【QRT of " + qrt['user_name'] + " (@" + qrt['user_screen_name'] + ")"+ verifiedCheck+":】\n\n'" + qrt['text'] + "'") return ("\n\n【QRT of " + qrt['user_name'] + " (@" + qrt['user_screen_name'] + ")"+ verifiedCheck+":】\n\n'" + text + "'")
def genPollDisplay(poll): def genPollDisplay(poll):
pctSplit=10 pctSplit=10
@@ -27,6 +30,9 @@ def genPollDisplay(poll):
output+=choice["name"]+"\n"+(""*int(choice["percent"]/pctSplit)) +" "+str(choice["percent"])+"%\n" output+=choice["name"]+"\n"+(""*int(choice["percent"]/pctSplit)) +" "+str(choice["percent"])+"%\n"
return output return output
def genTranslationDisplay(translation):
return f"【TL "+translation["source_language"]+""+translation["destination_language"]+"\n\n"+translation["text"]
# formats the top text of the embed # formats the top text of the embed
def formatProvider(base,vnf): def formatProvider(base,vnf):
finalText = base finalText = base
@@ -38,13 +44,16 @@ def formatProvider(base,vnf):
finalText = base finalText = base
return finalText return finalText
def formatEmbedDesc(type,body,qrt,pollData): def formatEmbedDesc(type,body,qrt,pollData,translation):
# Trim the embed description to 248 characters, prioritizing poll and likes # Trim the embed description to 248 characters, prioritizing poll and likes
qrtType=None qrtType=None
if qrt!=None: if qrt!=None:
qrtType="Text" qrtType="Text"
if translation is not None:
body = genTranslationDisplay(translation)
limit = videoDescLimit if type=="Video" or (qrt!=None and (qrtType=="Video")) else tweetDescLimit limit = videoDescLimit if type=="Video" or (qrt!=None and (qrtType=="Video")) else tweetDescLimit
output = "" output = ""
@@ -73,6 +82,6 @@ def formatEmbedDesc(type,body,qrt,pollData):
diff = len(output)-limit diff = len(output)-limit
# remove the characters from body, add ellipsis # remove the characters from body, add ellipsis
body = body[:-(diff+1)]+"" body = body[:-(diff+1)]+""
return formatEmbedDesc(type,body,qrt,pollData) return formatEmbedDesc(type,body,qrt,pollData,None)
else: else:
return output return output
+3 -1
View File
@@ -1,5 +1,5 @@
pymongo==4.8.0 pymongo==4.8.0
boto3==1.35.18 boto3==1.36.6
requests==2.32.3 requests==2.32.3
Pillow==10.4.0 Pillow==10.4.0
Flask==2.2.3 Flask==2.2.3
@@ -8,3 +8,5 @@ Werkzeug==2.3.7
numerize==0.12 numerize==0.12
oauthlib==3.2.2 oauthlib==3.2.2
PyRTF3==0.47.5 PyRTF3==0.47.5
XClientTransaction==1.0.2
deepl==1.3.0
+2 -1
View File
@@ -29,6 +29,7 @@ provider:
VXTWITTER_COMBINATION_METHOD: ${env:VXTWITTER_COMBINATION_METHOD, 'local'} VXTWITTER_COMBINATION_METHOD: ${env:VXTWITTER_COMBINATION_METHOD, 'local'}
VXTWITTER_GIF_CONVERT_API: ${env:VXTWITTER_GIF_CONVERT_API, ''} VXTWITTER_GIF_CONVERT_API: ${env:VXTWITTER_GIF_CONVERT_API, ''}
VXTWITTER_WORKAROUND_TOKENS: ${env:VXTWITTER_WORKAROUND_TOKENS, ''} VXTWITTER_WORKAROUND_TOKENS: ${env:VXTWITTER_WORKAROUND_TOKENS, ''}
DEEPL_API_KEY: ${env:DEEPL_API_KEY, ''}
#VXTWITTER_PROXIES: ${env:VXTWITTER_PROXIES, ''} #VXTWITTER_PROXIES: ${env:VXTWITTER_PROXIES, ''}
package: package:
@@ -47,7 +48,7 @@ functions:
handler: wsgi_handler.handler handler: wsgi_handler.handler
url: true url: true
timeout: 15 timeout: 15
memorySize: 500 memorySize: 128
layers: layers:
- Ref: PythonRequirementsLambdaLayer - Ref: PythonRequirementsLambdaLayer
+1 -1
View File
@@ -2,7 +2,7 @@
<meta content="{{ color }}" name="theme-color" /> <meta content="{{ color }}" name="theme-color" />
<meta property="og:site_name" content="{{ appname }}"> <meta property="og:site_name" content="{{ appname }}">
<meta name="twitter:title" content="{{ tweet['user_name'] }} (@{{ tweet['user_screen_name'] }})" /> <meta name="og:title" content="{{ tweet['user_name'] }} (@{{ tweet['user_screen_name'] }})" />
{% if activityLink %} {% if activityLink %}
<link type="application/activity+json" href="{{ activityLink|safe }}" /> <link type="application/activity+json" href="{{ activityLink|safe }}" />
+1 -1
View File
@@ -8,7 +8,7 @@
<meta content="{{ color }}" name="theme-color" /> <meta content="{{ color }}" name="theme-color" />
<meta property="og:site_name" content="{{ appname }}"> <meta property="og:site_name" content="{{ appname }}">
<meta name="twitter:title" content="{{ user['name'] }} (@{{ user['screen_name'] }})" /> <meta name="og:title" content="{{ user['name'] }} (@{{ user['screen_name'] }})" />
<meta name="twitter:image" content="{{ user['profile_image_url'] }}" /> <meta name="twitter:image" content="{{ user['profile_image_url'] }}" />
<meta name="twitter:creator" content="@{{ user['name'] }}" /> <meta name="twitter:creator" content="@{{ user['name'] }}" />
+46 -1
View File
@@ -3,6 +3,17 @@ import twitfix,twExtract
from flask.testing import FlaskClient from flask.testing import FlaskClient
client = FlaskClient(twitfix.app) client = FlaskClient(twitfix.app)
def test_api_get_tweet():
resp = client.get(testTextTweet.replace("https://twitter.com","https://api.vxtwitter.com")+"?include_txt=true",headers={"User-Agent":"test"})
jData = resp.get_json()
assert resp.status_code==200
assert jData['text'] == 'just setting up my twttr'
def test_api_get_invalid_tweet():
resp = client.get("https://vxtwitter.com/test/status/None",headers={"User-Agent":"test"})
jData = resp.get_json()
assert resp.status_code!=200
def test_api_include_txt(): def test_api_include_txt():
resp = client.get(testTextTweet.replace("https://twitter.com","https://api.vxtwitter.com")+"?include_txt=true",headers={"User-Agent":"test"}) resp = client.get(testTextTweet.replace("https://twitter.com","https://api.vxtwitter.com")+"?include_txt=true",headers={"User-Agent":"test"})
jData = resp.get_json() jData = resp.get_json()
@@ -37,8 +48,42 @@ def test_api_include_rtf_nomedia():
assert resp.status_code==200 assert resp.status_code==200
assert not any(".rtf" in i for i in jData["mediaURLs"]) assert not any(".rtf" in i for i in jData["mediaURLs"])
def test_api_mixedmedia():
resp = client.get(testMixedMediaTweet.replace("https://twitter.com","https://api.vxtwitter.com")+"?include_txt=true",headers={"User-Agent":"test"})
assert resp.status_code==200
def test_api_user(): def test_api_user():
resp = client.get(testUser.replace("https://twitter.com","https://api.vxtwitter.com")+"?include_rtf=true",headers={"User-Agent":"test"}) resp = client.get(testUser.replace("https://twitter.com","https://api.vxtwitter.com"),headers={"User-Agent":"test"})
jData = resp.get_json() jData = resp.get_json()
assert resp.status_code==200 assert resp.status_code==200
assert jData["screen_name"]=="jack" assert jData["screen_name"]=="jack"
def test_api_user_suspended():
resp = client.get(testUserSuspended.replace("https://twitter.com","https://api.vxtwitter.com"),headers={"User-Agent":"test"})
jData = resp.get_json()
assert 'suspended' in jData["error"]
def test_api_user_private():
resp = client.get(testUserPrivate.replace("https://twitter.com","https://api.vxtwitter.com")+"?with_tweets=true",headers={"User-Agent":"test"})
jData = resp.get_json()
assert jData['protected'] == True
assert len(jData["latest_tweets"])==0
def test_api_user_invalid():
resp = client.get(testUserInvalid.replace("https://twitter.com","https://api.vxtwitter.com")+"?with_tweets=true",headers={"User-Agent":"test"})
jData = resp.get_json()
assert resp.status_code==404
def test_api_user_feed():
resp = client.get(testUser.replace("https://twitter.com","https://api.vxtwitter.com")+"?with_tweets=true",headers={"User-Agent":"test"})
jData = resp.get_json()
assert resp.status_code==200
assert jData["screen_name"]=="jack"
assert len(jData["latest_tweets"])>0
def test_api_retweet():
resp = client.get(testRetweetTweet.replace("https://twitter.com","https://api.vxtwitter.com"),headers={"User-Agent":"test"})
jData = resp.get_json()
assert jData['retweetURL'] == 'https://twitter.com/i/status/1828569456231993456'
assert jData['retweet'] != None
assert jData['retweet']['text'].startswith("If you want to try")
+16
View File
@@ -205,3 +205,19 @@ def test_embed_action():
assert resp.status_code==200 assert resp.status_code==200
assert "application/activity+json" in str(resp.data) assert "application/activity+json" in str(resp.data)
assert "%F0%9F%92%96" not in str(resp.data) # 💖 assert "%F0%9F%92%96" not in str(resp.data) # 💖
def test_embed_translated():
resp = client.get(testTextTweet.replace("https://twitter.com","")+"/jp",headers={"User-Agent":"test"})
assert resp.status_code==200
assert "" in resp.text
resp = client.get(testTextTweet.replace("https://twitter.com","")+"/ja",headers={"User-Agent":"test"})
assert resp.status_code==200
assert "" in resp.text
def test_embed_translated_subdomain():
resp = client.get(testTextTweet.replace("https://twitter.com","https://jp.vxtwitter.com"),headers={"User-Agent":"test"})
assert resp.status_code==200
assert "" in resp.text
resp = client.get(testTextTweet.replace("https://twitter.com","https://ja.vxtwitter.com"),headers={"User-Agent":"test"})
assert resp.status_code==200
assert "" in resp.text
+19 -17
View File
@@ -3,22 +3,27 @@ import os
import twExtract import twExtract
import utils import utils
from vx_testdata import * from vx_testdata import *
import twitfix
def test_twextract_syndicationAPI(): def test_twextract_syndicationAPI():
tweet = twExtract.extractStatus_syndication(testMediaTweet,workaroundTokens=tokens) tweet = twExtract.extractStatus_syndication(testMediaTweet,workaroundTokens=tokens)
assert utils.stripEndTCO(utils.stripEndTCO(tweet["full_text"]))==testMediaTweet_compare['text'] assert utils.stripEndTCO(utils.stripEndTCO(tweet["full_text"]))==testMediaTweet_compare['text']
def test_twextract_extractStatusV2Anon(): def test_twextract_extractStatusV2Rest():
tweet = twExtract.extractStatusV2Anon(testTextTweet,None)['legacy'] tweet = twExtract.extractStatusV2Rest(testTextTweet,None)['legacy']
assert utils.stripEndTCO(tweet["full_text"])==testTextTweet_compare['text'] assert utils.stripEndTCO(tweet["full_text"])==testTextTweet_compare['text']
tweet = twExtract.extractStatusV2Anon(testVideoTweet,None)['legacy'] tweet = twExtract.extractStatusV2Rest(testVideoTweet,None)['legacy']
assert utils.stripEndTCO(tweet["full_text"])==testVideoTweet_compare['text'] assert utils.stripEndTCO(tweet["full_text"])==testVideoTweet_compare['text']
tweet = twExtract.extractStatusV2Anon(testMediaTweet,None)['legacy'] tweet = twExtract.extractStatusV2Rest(testMediaTweet,None)['legacy']
assert utils.stripEndTCO(tweet["full_text"])==testMediaTweet_compare['text'] assert utils.stripEndTCO(tweet["full_text"])==testMediaTweet_compare['text']
tweet = twExtract.extractStatusV2Anon(testMultiMediaTweet,None)['legacy'] tweet = twExtract.extractStatusV2Rest(testMultiMediaTweet,None)['legacy']
assert utils.stripEndTCO(tweet["full_text"])[:94]==testMultiMediaTweet_compare['text'][:94] assert utils.stripEndTCO(tweet["full_text"])[:94]==testMultiMediaTweet_compare['text'][:94]
def test_twextract_v2API_Anon():
tweet = twExtract.extractStatusV2Rest_Anon(testMediaTweet,workaroundTokens=tokens)['legacy']
assert utils.stripEndTCO(tweet["full_text"])==testMediaTweet_compare['text']
def test_twextract_v2API(): def test_twextract_v2API():
tweet = twExtract.extractStatusV2(testMediaTweet,workaroundTokens=tokens)['legacy'] tweet = twExtract.extractStatusV2(testMediaTweet,workaroundTokens=tokens)['legacy']
assert utils.stripEndTCO(tweet["full_text"])==testMediaTweet_compare['text'] assert utils.stripEndTCO(tweet["full_text"])==testMediaTweet_compare['text']
@@ -32,30 +37,28 @@ def test_twextract_extractStatusV2TweetDetails():
assert utils.stripEndTCO(tweet["full_text"])==testMediaTweet_compare['text'] assert utils.stripEndTCO(tweet["full_text"])==testMediaTweet_compare['text']
## Tweet retrieve tests ## ## Tweet retrieve tests ##
def test_twextract_textTweetExtract():
tweet = twExtract.extractStatus(testTextTweet,workaroundTokens=tokens)
assert utils.stripEndTCO(tweet["legacy"]["full_text"])==testTextTweet_compare['text']
assert tweet["user"]["screen_name"]=="jack"
assert 'extended_entities' not in tweet
def test_twextract_extractV2(): # remove this when v2 is default def test_twextract_extractV2():
tweet = twExtract.extractStatusV2(testTextTweet,workaroundTokens=tokens) tweet = twExtract.extractStatusV2(testTextTweet,workaroundTokens=tokens)
def test_twextract_UserExtract(): def test_twextract_UserExtract():
user = twExtract.extractUser(testUser,workaroundTokens=tokens) rawUserData = twExtract.extractUser(testUser,workaroundTokens=tokens)
user = twitfix.getApiUserResponse(rawUserData)
assert user["screen_name"]=="jack" assert user["screen_name"]=="jack"
assert user["id"]==12 assert user["id"]==12
assert user["created_at"] == "Tue Mar 21 20:50:14 +0000 2006" assert user["created_at"] == "Tue Mar 21 20:50:14 +0000 2006"
def test_twextract_UserExtractID(): def test_twextract_UserExtractID():
user = twExtract.extractUser(testUserIDUrl,workaroundTokens=tokens) rawUserData = twExtract.extractUser(testUserIDUrl,workaroundTokens=tokens)
user = twitfix.getApiUserResponse(rawUserData)
assert user["screen_name"]=="jack" assert user["screen_name"]=="jack"
assert user["id"]==12 assert user["id"]==12
assert user["created_at"] == "Tue Mar 21 20:50:14 +0000 2006" assert user["created_at"] == "Tue Mar 21 20:50:14 +0000 2006"
def test_twextract_UserExtractWeirdURLs(): def test_twextract_UserExtractWeirdURLs():
for url in testUserWeirdURLs: for url in testUserWeirdURLs:
user = twExtract.extractUser(url,workaroundTokens=tokens) rawUserData = twExtract.extractUser(url,workaroundTokens=tokens)
user = twitfix.getApiUserResponse(rawUserData)
assert user["screen_name"]=="jack" assert user["screen_name"]=="jack"
assert user["id"]==12 assert user["id"]==12
assert user["created_at"] == "Tue Mar 21 20:50:14 +0000 2006" assert user["created_at"] == "Tue Mar 21 20:50:14 +0000 2006"
@@ -101,7 +104,6 @@ def test_twextract_pollTweetExtract(): # basic check if poll data exists
def test_twextract_NSFW_TweetExtract(): def test_twextract_NSFW_TweetExtract():
tweet = twExtract.extractStatus(testNSFWTweet,workaroundTokens=tokens) # For now just test that there's no error tweet = twExtract.extractStatus(testNSFWTweet,workaroundTokens=tokens) # For now just test that there's no error
'''
def test_twextract_feed(): def test_twextract_feed():
tweet = twExtract.extractUserFeedFromId(testUserID,workaroundTokens=tokens) tweets = twExtract.extractUserFeedFromId(testUserID,workaroundTokens=tokens) # For now just test that there's no error
''' assert len(tweets)>0
+8 -1
View File
@@ -1,10 +1,17 @@
import twitfix, cache, twExtract import twitfix, cache, twExtract, utils
from vx_testdata import * from vx_testdata import *
from twExtract import twUtils from twExtract import twUtils
def test_calcSyndicationToken(): def test_calcSyndicationToken():
assert twUtils.calcSyndicationToken("1691389765483200513") == "43lnobuxzql" assert twUtils.calcSyndicationToken("1691389765483200513") == "43lnobuxzql"
def test_stripEndTCO():
assert utils.stripEndTCO("Hello World https://t.co/abc123") == "Hello World"
assert utils.stripEndTCO("Hello\nWorld https://t.co/abc123") == "Hello\nWorld"
assert utils.stripEndTCO("Hello\nWorld\nhttps://t.co/abc123") == "Hello\nWorld"
assert utils.stripEndTCO("Hello\nWorld\n https://t.co/abc123") == "Hello\nWorld"
assert utils.stripEndTCO("Hello\nWorld \nhttps://t.co/abc123") == "Hello\nWorld"
def test_addToCache(): def test_addToCache():
cache.clearCache() cache.clearCache()
twitfix.getTweetData(testTextTweet) twitfix.getTweetData(testTextTweet)
+2
View File
@@ -12,6 +12,7 @@ tests = {
"testPollTweet": "https://twitter.com/norm/status/651169346518056960", "testPollTweet": "https://twitter.com/norm/status/651169346518056960",
"testMixedMediaTweet":"https://twitter.com/bigbeerfest/status/1760638922084741177", "testMixedMediaTweet":"https://twitter.com/bigbeerfest/status/1760638922084741177",
"testVinePlayerTweet":"https://twitter.com/Roblox/status/583302104342638592", "testVinePlayerTweet":"https://twitter.com/Roblox/status/583302104342638592",
"testRetweetTweet":"https://twitter.com/pdxdylan/status/1828570470222045294",
} }
def getVNFFromLink(link): def getVNFFromLink(link):
@@ -31,5 +32,6 @@ with open('generated.txt', 'w',encoding='utf-8') as f:
del VNF['user_name'] del VNF['user_name']
del VNF['user_profile_image_url'] del VNF['user_profile_image_url']
del VNF['communityNote'] del VNF['communityNote']
del VNF['fetched_on']
# write in a format that can be copy-pasted into a python file, i.e testTextTweet={... # write in a format that can be copy-pasted into a python file, i.e testTextTweet={...
f.write(f"{test}_compare={VNF}\n") f.write(f"{test}_compare={VNF}\n")
+27
View File
@@ -0,0 +1,27 @@
import deepl
from configHandler import config
validLanguages = ['AF', 'AN', 'AR', 'AS', 'AY', 'AZ', 'BA', 'BE', 'BG', 'BN', 'BR', 'BS', 'CA', 'CS', 'CY', 'DA', 'DE', 'DE-DE', 'EL', 'EN', 'EO', 'ES', 'ES-419', 'ET', 'EU', 'FA', 'FI', 'FR', 'FR-FR', 'GA', 'GL', 'GN', 'GU', 'HA', 'HE', 'HI', 'HR', 'HT', 'HU', 'HY', 'ID', 'IG', 'IS', 'IT', 'JA', 'JV', 'KA', 'KK', 'KO', 'KY', 'LA', 'LB', 'LN', 'LT', 'LV', 'MG', 'MI', 'MK', 'ML', 'MN', 'MR', 'MS', 'MT', 'MY', 'NB', 'NE', 'NL', 'OC', 'OM', 'PA', 'PL', 'PS', 'PT-BR', 'PT-PT', 'QU', 'RO', 'RU', 'SA', 'SK', 'SL', 'SQ', 'SR', 'ST', 'SU', 'SV', 'SW', 'TA', 'TE', 'TG', 'TH', 'TK', 'TL', 'TN', 'TR', 'TS', 'TT', 'UK', 'UR', 'UZ', 'VI', 'WO', 'XH', 'YI', 'ZH', 'ZH-HANS', 'ZH-HANT', 'ZU']
languageAliases = {
#"EN":"EN-US",
"JP":"JA",
}
mergedLangs=(validLanguages+list(languageAliases.keys()))
def getDeeplTranslation(text,target):
target = target.upper()
if target == "EN":
target = "EN-US"
try:
if 'deeplKey' not in config["config"] or config["config"]["deeplKey"] == None:
return None
deepl_client = deepl.Translator(config["config"]["deeplKey"])
result = deepl_client.translate_text(text, target_lang=target)
return {
"text":result.text,
"source_language":result.detected_source_lang,
"destination_language":target
}
except Exception as e:
return None
+188 -105
View File
@@ -9,12 +9,14 @@ from oauthlib import oauth1
import sys import sys
sys.path.append(os.path.dirname(os.path.realpath(__file__))) sys.path.append(os.path.dirname(os.path.realpath(__file__)))
import twUtils import twUtils
import concurrent.futures
bearer="Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw" bearer="Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw"
v2bearer="Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" v2bearer="Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
androidBearer="Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" androidBearer="Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
tweetdeckBearer="Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
bearerTokens=[tweetdeckBearer,bearer,v2bearer,androidBearer] requestUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0"
bearerTokens=[bearer,v2bearer,androidBearer]
guestToken=None guestToken=None
guestTokenUses=0 guestTokenUses=0
@@ -25,21 +27,28 @@ userIDregex = r"\/i\/user\/(\d+)"
v2Features='{"longform_notetweets_inline_media_enabled":true,"super_follow_badge_privacy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"super_follow_user_api_enabled":true,"super_follow_tweet_api_enabled":true,"android_graphql_skip_api_media_color_palette":true,"creator_subscriptions_tweet_preview_api_enabled":true,"freedom_of_speech_not_reach_fetch_enabled":true,"creator_subscriptions_subscription_count_enabled":true,"tweetypie_unmention_optimization_enabled":true,"longform_notetweets_consumption_enabled":true,"subscriptions_verification_info_enabled":true,"blue_business_profile_image_shape_enabled":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"super_follow_exclusive_tweet_notifications_enabled":true}' v2Features='{"longform_notetweets_inline_media_enabled":true,"super_follow_badge_privacy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"super_follow_user_api_enabled":true,"super_follow_tweet_api_enabled":true,"android_graphql_skip_api_media_color_palette":true,"creator_subscriptions_tweet_preview_api_enabled":true,"freedom_of_speech_not_reach_fetch_enabled":true,"creator_subscriptions_subscription_count_enabled":true,"tweetypie_unmention_optimization_enabled":true,"longform_notetweets_consumption_enabled":true,"subscriptions_verification_info_enabled":true,"blue_business_profile_image_shape_enabled":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"super_follow_exclusive_tweet_notifications_enabled":true}'
v2graphql_api="2OOZWmw8nAtUHVnXXQhgaA" v2graphql_api="2OOZWmw8nAtUHVnXXQhgaA"
v2AnonFeatures='{"creator_subscriptions_tweet_preview_api_enabled":true,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"articles_preview_enabled":true,"tweetypie_unmention_optimization_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"tweet_with_visibility_results_prefer_gql_media_interstitial_enabled":true,"rweb_video_timestamps_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"rweb_tipjar_consumption_enabled":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_enhance_cards_enabled":false}' v2AnonFeatures='{"creator_subscriptions_tweet_preview_api_enabled":true,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"responsive_web_jetfuel_frame":true,"responsive_web_grok_share_attachment_enabled":true,"responsive_web_grok_annotations_enabled":false,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"responsive_web_grok_show_grok_translated_post":true,"responsive_web_grok_analysis_button_from_backend":true,"post_ctas_fetch_enabled":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"profile_label_improvements_pcf_label_in_post_enabled":true,"responsive_web_profile_redirect_enabled":false,"rweb_tipjar_consumption_enabled":false,"verified_phone_label_enabled":false,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_grok_imagine_annotation_enabled":true,"responsive_web_grok_community_note_auto_translation_is_enabled":false,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_enhance_cards_enabled":false}'
v2AnonGraphql_api="7xflPyRiUxGVbJd4uWmbfg" v2AnonGraphql_api="0aTrQMKgj95K791yXeNDRA"
gt_pattern = r'document\.cookie="gt=([^;]+);' gt_pattern = r'document\.cookie="gt=([^;]+);'
androidGraphqlFeatures='{"longform_notetweets_inline_media_enabled":true,"super_follow_badge_privacy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"super_follow_user_api_enabled":true,"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled":true,"super_follow_tweet_api_enabled":true,"articles_api_enabled":true,"android_graphql_skip_api_media_color_palette":true,"creator_subscriptions_tweet_preview_api_enabled":true,"freedom_of_speech_not_reach_fetch_enabled":true,"tweetypie_unmention_optimization_enabled":true,"longform_notetweets_consumption_enabled":true,"subscriptions_verification_info_enabled":true,"blue_business_profile_image_shape_enabled":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"immersive_video_status_linkable_timestamps":true,"super_follow_exclusive_tweet_notifications_enabled":true}' androidGraphqlFeatures='{"grok_translations_community_note_translation_is_enabled":false,"super_follow_badge_privacy_enabled":true,"unified_cards_destination_url_params_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"super_follow_user_api_enabled":true,"profile_label_improvements_pcf_label_in_profile_enabled":true,"premium_content_api_read_enabled":false,"grok_translations_community_note_auto_translation_is_enabled":false,"android_graphql_skip_api_media_color_palette":true,"tweetypie_unmention_optimization_enabled":true,"longform_notetweets_consumption_enabled":true,"subscriptions_verification_info_enabled":true,"blue_business_profile_image_shape_enabled":true,"super_follow_exclusive_tweet_notifications_enabled":true,"longform_notetweets_inline_media_enabled":true,"grok_android_analyze_trend_fetch_enabled":false,"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled":true,"super_follow_tweet_api_enabled":true,"articles_api_enabled":true,"android_ad_formats_media_component_render_overlay_enabled":true,"creator_subscriptions_tweet_preview_api_enabled":true,"freedom_of_speech_not_reach_fetch_enabled":true,"grok_translations_timeline_user_bio_auto_translation_is_enabled":false,"grok_translations_post_auto_translation_is_enabled":false,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"immersive_video_status_linkable_timestamps":true,"profile_label_improvements_pcf_label_in_post_enabled":true}'
androidGraphql_api="llQH5PFIRlenVrlKJU8jNA" androidGraphql_api="Wrspae-uyGj-nWPyUqdUag"
tweetDetailGraphqlFeatures='{"rweb_tipjar_consumption_enabled":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"articles_preview_enabled":true,"tweetypie_unmention_optimization_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"rweb_video_timestamps_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_enhance_cards_enabled":false}' tweetDetailGraphqlFeatures='{"rweb_video_screen_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"responsive_web_profile_redirect_enabled":false,"rweb_tipjar_consumption_enabled":false,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"responsive_web_jetfuel_frame":true,"responsive_web_grok_share_attachment_enabled":true,"responsive_web_grok_annotations_enabled":false,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"responsive_web_grok_show_grok_translated_post":true,"responsive_web_grok_analysis_button_from_backend":true,"post_ctas_fetch_enabled":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_grok_imagine_annotation_enabled":true,"responsive_web_grok_community_note_auto_translation_is_enabled":false,"responsive_web_enhance_cards_enabled":false}'
tweetDetailGraphql_api="e7RKseIxLu7HgkWNKZ6qnw" tweetDetailGraphql_api="Kzfv17rukSzjT96BerOWZA"
# this is for UserTweets endpoint # this is for UserTweets endpoint
tweetFeedGraphqlFeatures='{"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"responsive_web_jetfuel_frame":false,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"responsive_web_grok_analysis_button_from_backend":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"rweb_video_timestamps_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_enhance_cards_enabled":false}' tweetFeedGraphqlFeatures='{"rweb_video_screen_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"responsive_web_jetfuel_frame":false,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_analysis_button_from_backend":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_enhance_cards_enabled":false}'
tweetFeedGraphql_api="Y9WM4Id6UcGFE8Z-hbnixw" tweetFeedGraphql_api="OAx9yEcW3JA9bPo63pcYlA"
userByScreenNameGraphqlFeatures='{"rweb_xchat_enabled":false,"hidden_profile_subscriptions_enabled":true,"payments_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"verified_phone_label_enabled":false,"subscriptions_verification_info_is_identity_verified_enabled":true,"subscriptions_verification_info_verified_since_enabled":true,"highlights_tweets_tab_ui_enabled":true,"responsive_web_twitter_article_notes_tab_enabled":true,"subscriptions_feature_can_gift_premium":true,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true}'
userByScreenNameGraphql_api="96tVxbPqMZDoYB5pmzezKA"
userByRestIdGraphql_api="8r5oa_2vD0WkhIAOkY4TTA"
twitterUrl = "x.com" # doubt this will change but just in case twitterUrl = "x.com" # doubt this will change but just in case
simultaneousRequests = int(os.getenv("VXTWITTER_SIMULTANEOUS_REQUESTS",1))
class TwExtractError(Exception): class TwExtractError(Exception):
def __init__(self, code, message): def __init__(self, code, message):
self.code = code self.code = code
@@ -48,6 +57,37 @@ class TwExtractError(Exception):
def __str__(self): def __str__(self):
return self.msg return self.msg
def parallel_token_request(twid, tokens, request_function):
results = []
errors = []
def try_token(token):
try:
result = request_function(twid, token)
return {'success': True, 'result': result}
except Exception as e:
return {'success': False, 'error': str(e)}
with concurrent.futures.ThreadPoolExecutor(max_workers=min(simultaneousRequests, len(tokens))) as executor:
futures = {executor.submit(try_token, token): token for token in tokens}
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result['success']:
results.append(result)
else:
errors.append(result)
# Early return if success
if result['success']:
for f in futures: # Cancel remaining futures
if not f.done():
f.cancel()
return result['result']
# all tokens failed
if errors:
raise TwExtractError(400, f"All tokens failed. Last error: {errors[-1]['error']}")
return None
def cycleBearerTokenGet(url,headers): def cycleBearerTokenGet(url,headers):
global bearerTokens global bearerTokens
rateLimitRemaining = None rateLimitRemaining = None
@@ -73,33 +113,37 @@ def cycleBearerTokenGet(url,headers):
return tweet return tweet
raise TwExtractError(400, "Extract error") raise TwExtractError(400, "Extract error")
def twitterApiGet(url,btoken=None,authToken=None,guestToken=None): def twitterApiGet(url,btoken=None,authToken=None,guestToken=None,userAgent=None,language=None):
if authToken.startswith("oa|"): if authToken != None and authToken.startswith("oa|"):
url = url.replace("https://x.com/i/api/graphql/","https://api.twitter.com/graphql/") url = url.replace("https://x.com/i/api/graphql/","https://api.twitter.com/graphql/")
authToken = authToken[3:] authToken = authToken[3:]
key = authToken.split("|")[0] key = authToken.split("|")[0]
secret = authToken.split("|")[1] secret = authToken.split("|")[1]
twt = oauth1.Client(client_key='3nVuSoBZnx6U4vzUxf5w',client_secret='Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys',resource_owner_key=key,resource_owner_secret=secret) twt = oauth1.Client(client_key='3nVuSoBZnx6U4vzUxf5w',client_secret='Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys',resource_owner_key=key,resource_owner_secret=secret)
hdr = getAuthHeaders(androidBearer) hdr = getAuthHeaders(androidBearer,language=language)
del hdr["Authorization"] del hdr["Authorization"]
hdr["X-Twitter-Client"] = "TwitterAndroid" hdr["X-Twitter-Client"] = "TwitterAndroid"
if userAgent is not None:
hdr["User-Agent"] = userAgent
uri, headers, body = twt.sign(url, headers=hdr,realm="http://api.twitter.com/") uri, headers, body = twt.sign(url, headers=hdr,realm="http://api.twitter.com/")
response = requests.get(url,headers=headers) response = requests.get(url,headers=headers)
else: else:
if btoken is None: if btoken is None:
return cycleBearerTokenGet(url,getAuthHeaders(bearer,authToken=authToken,guestToken=guestToken)) btoken = v2bearer
headers = getAuthHeaders(btoken,authToken=authToken,guestToken=guestToken) #return cycleBearerTokenGet(url,getAuthHeaders(bearer,authToken=authToken,guestToken=guestToken))
headers = getAuthHeaders(btoken,authToken=authToken,guestToken=guestToken,language=language)
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
return response return response
def getAuthHeaders(btoken,authToken=None,guestToken=None): def getAuthHeaders(btoken,authToken=None,guestToken=None,language=None):
csrfToken=str(uuid.uuid4()).replace('-', '') csrfToken=str(uuid.uuid4()).replace('-', '')
headers = {"x-twitter-active-user":"yes","x-twitter-client-language":"en","x-csrf-token":csrfToken,"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.0"} headers = {"x-twitter-active-user":"yes","x-twitter-client-language":"en","x-csrf-token":csrfToken,"User-Agent":requestUserAgent}
headers['Authorization'] = btoken headers['Authorization'] = btoken
if authToken is not None: if authToken is not None:
@@ -108,19 +152,17 @@ def getAuthHeaders(btoken,authToken=None,guestToken=None):
if guestToken is not None: if guestToken is not None:
headers["x-guest-token"] = guestToken headers["x-guest-token"] = guestToken
if language is not None:
headers["x-twitter-client-language"] = language
return headers return headers
def getGuestToken(): def getGuestToken():
global guestToken global guestToken
global guestTokenUses global guestTokenUses
if guestToken is None: if guestToken is None:
r = requests.get(f"https://{twitterUrl}",headers={"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.0","Cookie":"night_mode=2"},allow_redirects=False) r = requests.post(f"https://api.{twitterUrl}/1.1/guest/activate.json", headers={"Authorization":v2bearer,"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0"})
m = re.search(gt_pattern, r.text) guestToken = json.loads(r.text)["guest_token"]
if m is None:
r = requests.post(f"https://api.{twitterUrl}/1.1/guest/activate.json", headers={"Authorization":bearer})
guestToken = json.loads(r.text)["guest_token"]
else:
guestToken = m.group(1)
guestTokenUses = 0 guestTokenUses = 0
else: else:
guestTokenUses+=1 guestTokenUses+=1
@@ -172,7 +214,7 @@ def extractStatus_guestToken(url):
raise TwExtractError(error["code"], error["message"]) raise TwExtractError(error["code"], error["message"])
return output return output
def extractStatus_syndication(url,workaroundTokens=None): def extractStatus_syndication(url,workaroundTokens=None,tlLanguage=None):
# https://github.com/mikf/gallery-dl/blob/46cae04aa3a113c7b6bbee1bb468669564b14ae8/gallery_dl/extractor/twitter.py#L1784 # https://github.com/mikf/gallery-dl/blob/46cae04aa3a113c7b6bbee1bb468669564b14ae8/gallery_dl/extractor/twitter.py#L1784
m = re.search(pathregex, url) m = re.search(pathregex, url)
if m is None: if m is None:
@@ -224,7 +266,7 @@ def extractStatus_twExtractProxy(url,workaroundTokens=None):
continue continue
return output return output
def extractStatusV2(url,workaroundTokens): def extractStatusV2(url,workaroundTokens,tlLanguage=None):
# get tweet ID # get tweet ID
m = re.search(pathregex, url) m = re.search(pathregex, url)
if m is None: if m is None:
@@ -235,53 +277,49 @@ def extractStatusV2(url,workaroundTokens):
# get tweet # get tweet
tokens = workaroundTokens tokens = workaroundTokens
random.shuffle(tokens) random.shuffle(tokens)
for authToken in tokens: def request_with_token(twid, authToken):
vars = json.loads('{"includeTweetImpression":true,"includeHasBirdwatchNotes":false,"includeEditPerspective":false,"rest_ids":["x"],"includeEditControl":true,"includeCommunityTweetRelationship":true,"includeTweetVisibilityNudge":true}')
vars['rest_ids'][0] = str(twid)
tweet = twitterApiGet(f"https://x.com/i/api/graphql/{v2graphql_api}/TweetResultsByIdsQuery?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(v2Features)}",authToken=authToken,language=tlLanguage)
try: try:
vars = json.loads('{"includeTweetImpression":true,"includeHasBirdwatchNotes":false,"includeEditPerspective":false,"rest_ids":["x"],"includeEditControl":true,"includeCommunityTweetRelationship":true,"includeTweetVisibilityNudge":true}') rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining")
vars['rest_ids'][0] = str(twid) print(f"Twitter Token Rate limit remaining: {rateLimitRemaining}")
tweet = twitterApiGet(f"https://x.com/i/api/graphql/{v2graphql_api}/TweetResultsByIdsQuery?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(v2Features)}",authToken=authToken) except: # for some reason the header is not always present
try: pass
rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining") if tweet.status_code == 429:
print(f"Twitter Token Rate limit remaining: {rateLimitRemaining}") print("Rate limit reached for token (429)")
except: # for some reason the header is not always present # try another token
pass raise TwExtractError(400, "Extract error: rate limit reached")
if tweet.status_code == 429: output = tweet.json()
print("Rate limit reached for token (429)")
# try another token
continue
output = tweet.json()
if "errors" in output: if "errors" in output:
print(f"Error in output: {json.dumps(output['errors'])}") print(f"Error in output: {json.dumps(output['errors'])}")
# try another token # try another token
raise TwExtractError(400, "Extract error: errors in output - "+json.dumps(output['errors']))
entries=output['data']['tweet_results']
tweetEntry=None
for entry in entries:
if 'result' not in entry:
print("Tweet result not found in entry")
continue continue
entries=output['data']['tweet_results'] result = entry['result']
tweetEntry=None if '__typename' in result and result['__typename'] == 'TweetWithVisibilityResults':
for entry in entries: result=result['tweet']
if 'result' not in entry: elif '__typename' in result and result['__typename'] == 'TweetUnavailable':
print("Tweet result not found in entry") if 'reason' in result:
continue return {'error':'Tweet unavailable: '+result['reason']}
result = entry['result'] return {'error':'Tweet unavailable'}
if '__typename' in result and result['__typename'] == 'TweetWithVisibilityResults': if 'rest_id' in result and result['rest_id'] == twid:
result=result['tweet'] tweetEntry=result
elif '__typename' in result and result['__typename'] == 'TweetUnavailable': break
if 'reason' in result: tweet=tweetEntry
return {'error':'Tweet unavailable: '+result['reason']} if tweet is None:
return {'error':'Tweet unavailable'} print("Tweet 404")
if 'rest_id' in result and result['rest_id'] == twid: return {'error':'Tweet not found (404); May be due to invalid tweet, changes in Twitter\'s API, or a protected account.'}
tweetEntry=result
break
tweet=tweetEntry
if tweet is None:
print("Tweet 404")
return {'error':'Tweet not found (404); May be due to invalid tweet, changes in Twitter\'s API, or a protected account.'}
except Exception as e:
print(f"Exception in extractStatusV2: {str(e)}")
continue
return tweet return tweet
raise TwExtractError(400, "Extract error") return parallel_token_request(twid, tokens, request_with_token)
def extractStatusV2Android(url,workaroundTokens): def extractStatusV2Android(url,workaroundTokens,tlLanguage=None):
# get tweet ID # get tweet ID
m = re.search(pathregex, url) m = re.search(pathregex, url)
if m is None: if m is None:
@@ -289,15 +327,13 @@ def extractStatusV2Android(url,workaroundTokens):
twid = m.group(2) twid = m.group(2)
if workaroundTokens == None: if workaroundTokens == None:
raise TwExtractError(400, "Extract error (no tokens defined)") raise TwExtractError(400, "Extract error (no tokens defined)")
# get tweet
tokens = workaroundTokens tokens = workaroundTokens
random.shuffle(tokens) random.shuffle(tokens)
for authToken in tokens: def request_with_token(twid, authToken):
try: try:
vars = json.loads('{"referrer":"home","includeTweetImpression":true,"includeHasBirdwatchNotes":false,"isReaderMode":false,"includeEditPerspective":false,"includeEditControl":true,"focalTweetId":0,"includeCommunityTweetRelationship":true,"includeTweetVisibilityNudge":true}') vars = json.loads('{"referrer":"home","includeTweetImpression":true,"includeHasBirdwatchNotes":false,"isReaderMode":false,"includeEditPerspective":false,"includeEditControl":true,"focalTweetId":0,"includeCommunityTweetRelationship":true,"includeTweetVisibilityNudge":true}')
vars['focalTweetId'] = int(twid) vars['focalTweetId'] = int(twid)
tweet = twitterApiGet(f"https://x.com/i/api/graphql/{androidGraphql_api}/ConversationTimelineV2?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(androidGraphqlFeatures)}", authToken=authToken) tweet = twitterApiGet(f"https://x.com/i/api/graphql/{androidGraphql_api}/ConversationTimelineV2?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(androidGraphqlFeatures)}", authToken=authToken,btoken=androidBearer,userAgent="TwitterAndroid/11.61.0-release.0 (311610000-r-0) G011A/9 (google;G011A;google;G011A;0;;1;2016)",language=tlLanguage)
try: try:
rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining") rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining")
print(f"Twitter Android Token Rate limit remaining: {rateLimitRemaining}") print(f"Twitter Android Token Rate limit remaining: {rateLimitRemaining}")
@@ -306,14 +342,18 @@ def extractStatusV2Android(url,workaroundTokens):
if tweet.status_code == 429: if tweet.status_code == 429:
print("Rate limit reached for android token") print("Rate limit reached for android token")
# try another token # try another token
continue raise TwExtractError(400, "Extract error: rate limit reached")
output = tweet.json() output = tweet.json()
if "errors" in output: if "errors" in output:
print(f"Error in output: {json.dumps(output['errors'])}") print(f"Error in output: {json.dumps(output['errors'])}")
# try another token # try another token
continue raise TwExtractError(400, "Extract error: errors in output - "+json.dumps(output['errors']))
entries=output['data']['timeline_response']['instructions'][0]['entries'] entries = None
for instruction in output['data']['timeline_response']['instructions']:
if instruction["__typename"] == "TimelineAddEntries":
entries = instruction['entries']
break
tweetEntry=None tweetEntry=None
for entry in entries: for entry in entries:
if 'content' not in entry: if 'content' not in entry:
@@ -332,13 +372,13 @@ def extractStatusV2Android(url,workaroundTokens):
print("Tweet 404") print("Tweet 404")
return {'error':'Tweet not found (404); May be due to invalid tweet, changes in Twitter\'s API, or a protected account.'} return {'error':'Tweet not found (404); May be due to invalid tweet, changes in Twitter\'s API, or a protected account.'}
except Exception as e: except Exception as e:
print(f"Exception in extractStatusV2: {str(e)}") print(f"Exception in extractStatusV2Android: {str(e)}")
continue raise TwExtractError(400, "Extract error")
return tweet return tweet
raise TwExtractError(400, "Extract error") return parallel_token_request(twid, tokens, request_with_token)
def extractStatusV2TweetDetail(url,workaroundTokens): def extractStatusV2TweetDetail(url,workaroundTokens,tlLanguage=None):
# get tweet ID # get tweet ID
m = re.search(pathregex, url) m = re.search(pathregex, url)
if m is None: if m is None:
@@ -349,12 +389,11 @@ def extractStatusV2TweetDetail(url,workaroundTokens):
# get tweet # get tweet
tokens = workaroundTokens tokens = workaroundTokens
random.shuffle(tokens) random.shuffle(tokens)
for authToken in tokens: def request_with_token(twid, authToken):
try: try:
vars = json.loads('{"focalTweetId":"0","with_rux_injections":false,"includePromotedContent":true,"withCommunity":true,"withQuickPromoteEligibilityTweetFields":true,"withBirdwatchNotes":true,"withVoice":true,"withV2Timeline":true}') vars = json.loads('{"focalTweetId":"0","with_rux_injections":false,"includePromotedContent":true,"withCommunity":true,"withQuickPromoteEligibilityTweetFields":true,"withBirdwatchNotes":true,"withVoice":true,"withV2Timeline":true}')
vars['focalTweetId'] = str(twid) vars['focalTweetId'] = str(twid)
tweet = twitterApiGet(f"https://x.com/i/api/graphql/{tweetDetailGraphql_api}/TweetDetail?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(tweetDetailGraphqlFeatures)}", authToken=authToken) tweet = twitterApiGet(f"https://x.com/i/api/graphql/{tweetDetailGraphql_api}/TweetDetail?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(tweetDetailGraphqlFeatures)}", authToken=authToken,btoken=v2bearer,language=tlLanguage)
try: try:
rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining") rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining")
print(f"Twitter Token Rate limit remaining: {rateLimitRemaining}") print(f"Twitter Token Rate limit remaining: {rateLimitRemaining}")
@@ -363,14 +402,18 @@ def extractStatusV2TweetDetail(url,workaroundTokens):
if tweet.status_code == 429: if tweet.status_code == 429:
print("Rate limit reached for token") print("Rate limit reached for token")
# try another token # try another token
continue raise TwExtractError(400, "Extract error: rate limit reached")
output = tweet.json() output = tweet.json()
if "errors" in output: if "errors" in output:
print(f"Error in output: {json.dumps(output['errors'])}") print(f"Error in output: {json.dumps(output['errors'])}")
# try another token # try another token
continue raise TwExtractError(400, "Extract error: errors in output - "+json.dumps(output['errors']))
entries=output['data']['threaded_conversation_with_injections_v2']['instructions'][0]['entries'] entries = None
for instruction in output['data']['threaded_conversation_with_injections_v2']['instructions']:
if instruction["type"] == "TimelineAddEntries":
entries = instruction['entries']
break
tweetEntry=None tweetEntry=None
for entry in entries: for entry in entries:
if 'content' not in entry: if 'content' not in entry:
@@ -390,12 +433,15 @@ def extractStatusV2TweetDetail(url,workaroundTokens):
return {'error':'Tweet not found (404); May be due to invalid tweet, changes in Twitter\'s API, or a protected account.'} return {'error':'Tweet not found (404); May be due to invalid tweet, changes in Twitter\'s API, or a protected account.'}
except Exception as e: except Exception as e:
print(f"Exception in extractStatusV2: {str(e)}") print(f"Exception in extractStatusV2: {str(e)}")
continue raise TwExtractError(400, "Extract error")
return tweet return tweet
raise TwExtractError(400, "Extract error") return parallel_token_request(twid, tokens, request_with_token)
def extractStatusV2Anon(url,x): def extractStatusV2Rest_Anon(url,workaroundTokens,tlLanguage=None):
return extractStatusV2Rest(url,None,tlLanguage=tlLanguage)
def extractStatusV2Rest(url,workaroundTokens,tlLanguage=None):
# get tweet ID # get tweet ID
m = re.search(pathregex, url) m = re.search(pathregex, url)
if m is None: if m is None:
@@ -406,9 +452,19 @@ def extractStatusV2Anon(url,x):
# get tweet # get tweet
try: try:
vars = json.loads('{"tweetId":"0","withCommunity":false,"includePromotedContent":false,"withVoice":false}') vars = json.loads('{"tweetId":"0","includePromotedContent":true,"withBirdwatchNotes":true,"withVoice":true,"withCommunity":true}')
vars['tweetId'] = str(twid) vars['tweetId'] = str(twid)
tweet = requests.get(f"https://x.com/i/api/graphql/{v2AnonGraphql_api}/TweetResultByRestId?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(v2AnonFeatures)}", headers=getAuthHeaders(v2bearer,guestToken=guestToken)) if workaroundTokens is not None and len(workaroundTokens) > 0:
tokens = workaroundTokens
random.shuffle(tokens)
for authToken in tokens:
try:
tweet = twitterApiGet(f"https://x.com/i/api/graphql/{v2AnonGraphql_api}/TweetResultByRestId?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(v2AnonFeatures)}", btoken=v2bearer,authToken=authToken,guestToken=guestToken,language=tlLanguage)
except Exception as e:
continue
else:
tweet = twitterApiGet(f"https://x.com/i/api/graphql/{v2AnonGraphql_api}/TweetResultByRestId?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(v2AnonFeatures)}", btoken=v2bearer,guestToken=guestToken,language=tlLanguage)
try: try:
rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining") rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining")
print(f"Twitter Anon Token Rate limit remaining: {rateLimitRemaining}") print(f"Twitter Anon Token Rate limit remaining: {rateLimitRemaining}")
@@ -454,11 +510,13 @@ def fixTweetData(tweet):
pass pass
return tweet return tweet
def extractStatus(url,workaroundTokens=None): def extractStatus(url,workaroundTokens=None,tlLanguage=None):
methods=[extractStatusV2Anon,extractStatusV2TweetDetail,extractStatusV2Android,extractStatusV2] methods=[extractStatusV2Rest_Anon,extractStatusV2,extractStatusV2Rest,extractStatusV2Android]#,extractStatusV2TweetDetail]
if tlLanguage is not None: # prioritize endpoints that return translations
methods = [extractStatusV2Rest,extractStatusV2TweetDetail,extractStatusV2Android,extractStatusV2,extractStatusV2Rest_Anon]
for method in methods: for method in methods:
try: try:
result = method(url,workaroundTokens) result = method(url,workaroundTokens,tlLanguage=tlLanguage)
if 'legacy' not in result: if 'legacy' not in result:
print(f"{method.__name__} method failed: Legacy not found for {url}") print(f"{method.__name__} method failed: Legacy not found for {url}")
# try another method # try another method
@@ -486,19 +544,28 @@ def extractUser(url,workaroundTokens):
if authToken.startswith("oa|"): # oauth token not supported atm if authToken.startswith("oa|"): # oauth token not supported atm
continue continue
try: try:
reqHeaders = getAuthHeaders(v2bearer,authToken=authToken)
reqHeaders = getAuthHeaders(bearer,authToken=authToken)
if not useId: if not useId:
user = requests.get(f"https://api.{twitterUrl}/1.1/users/show.json?screen_name={screen_name}",headers=reqHeaders) vars=json.loads('{"screen_name":"","withGrokTranslatedBio":false}')
vars['screen_name'] = screen_name
user = requests.get(f"https://x.com/i/api/graphql/{userByScreenNameGraphql_api}/UserByScreenName",{'variables':json.dumps(vars),'features':userByScreenNameGraphqlFeatures,'fieldToggles':'{"withAuxiliaryUserLabels":true}'},headers=reqHeaders)
else: else:
user = requests.get(f"https://api.{twitterUrl}/1.1/users/show.json?user_id={screen_name}",headers=reqHeaders) vars=json.loads('{"userId":"","withGrokTranslatedBio":false}')
vars['userId'] = screen_name
user = requests.get(f"https://x.com/i/api/graphql/{userByRestIdGraphql_api}/UserByRestId",{'variables':json.dumps(vars),'features':userByScreenNameGraphqlFeatures,'fieldToggles':'{"withAuxiliaryUserLabels":true}'},headers=reqHeaders)
output = user.json() output = user.json()
if "errors" in output: if "errors" in output:
# pick the first error and create a twExtractError # pick the first error and create a twExtractError
error = output["errors"][0] error = output["errors"][0]
raise TwExtractError(error["code"], error["message"]) raise TwExtractError(error["code"], error["message"])
elif 'user' not in output['data']:
raise TwExtractError(404, "User not found.")
elif output['data']['user']['result']['__typename'] == 'UserUnavailable':
raise TwExtractError(404, output['data']['user']['result']['message'])
return output return output
except Exception as e: except Exception as e:
if hasattr(e,"msg") and ('suspended' in e.msg or e.msg == 'User not found.'):
raise e
continue continue
raise TwExtractError(400, "Extract error") raise TwExtractError(400, "Extract error")
@@ -510,25 +577,41 @@ def extractUserFeedFromId(userId,workaroundTokens):
# TODO: https://api.twitter.com/graphql/x31u1gdnjcqtiVZFc1zWnQ/UserWithProfileTweetsQueryV2?variables={"cursor":"?","includeTweetImpression":true,"includeHasBirdwatchNotes":false,"includeEditPerspective":false,"includeEditControl":true,"count":40,"rest_id":"12","includeTweetVisibilityNudge":true,"autoplay_enabled":true}&features={"longform_notetweets_inline_media_enabled":true,"super_follow_badge_privacy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"super_follow_user_api_enabled":true,"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled":true,"super_follow_tweet_api_enabled":true,"articles_api_enabled":true,"android_graphql_skip_api_media_color_palette":true,"creator_subscriptions_tweet_preview_api_enabled":true,"freedom_of_speech_not_reach_fetch_enabled":true,"tweetypie_unmention_optimization_enabled":true,"longform_notetweets_consumption_enabled":true,"subscriptions_verification_info_enabled":true,"blue_business_profile_image_shape_enabled":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"immersive_video_status_linkable_timestamps":false,"super_follow_exclusive_tweet_notifications_enabled":true} # TODO: https://api.twitter.com/graphql/x31u1gdnjcqtiVZFc1zWnQ/UserWithProfileTweetsQueryV2?variables={"cursor":"?","includeTweetImpression":true,"includeHasBirdwatchNotes":false,"includeEditPerspective":false,"includeEditControl":true,"count":40,"rest_id":"12","includeTweetVisibilityNudge":true,"autoplay_enabled":true}&features={"longform_notetweets_inline_media_enabled":true,"super_follow_badge_privacy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"super_follow_user_api_enabled":true,"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled":true,"super_follow_tweet_api_enabled":true,"articles_api_enabled":true,"android_graphql_skip_api_media_color_palette":true,"creator_subscriptions_tweet_preview_api_enabled":true,"freedom_of_speech_not_reach_fetch_enabled":true,"tweetypie_unmention_optimization_enabled":true,"longform_notetweets_consumption_enabled":true,"subscriptions_verification_info_enabled":true,"blue_business_profile_image_shape_enabled":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"immersive_video_status_linkable_timestamps":false,"super_follow_exclusive_tweet_notifications_enabled":true}
continue continue
try: try:
vars = json.loads('{"userId":"x","count":20,"includePromotedContent":true,"withQuickPromoteEligibilityTweetFields":true,"withVoice":true,"withV2Timeline":true}') vars = json.loads('{"userId":"0","count":20,"includePromotedContent":true,"withCommunity":true,"withVoice":true}')
vars['userId'] = str(userId) vars['userId'] = str(userId)
vars['includePromotedContent'] = False # idk if this works vars['includePromotedContent'] = False # idk if this works
reqHeaders = getAuthHeaders(bearer,authToken=authToken) reqHeaders = getAuthHeaders(v2bearer,authToken=authToken)
reqHeaders["x-client-transaction-id"] = twUtils.generate_transaction_id("GET","/i/api/graphql/x31u1gdnjcqtiVZFc1zWnQ/UserWithProfileTweetsQueryV2") endpoint=f"/i/api/graphql/{tweetFeedGraphql_api}/UserTweetsAndReplies"
feed = requests.get(f"https://{twitterUrl}/i/api/graphql/{tweetFeedGraphql_api}/UserTweets?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(tweetFeedGraphqlFeatures)}", reqHeaders) reqHeaders["x-client-transaction-id"] = twUtils.generate_transaction_id("GET",endpoint)
feed = requests.get(f"https://{twitterUrl}{endpoint}", {'variables':json.dumps(vars),'features':tweetFeedGraphqlFeatures,'fieldToggles':'{"withArticlePlainText":false}'},headers=reqHeaders)
if feed.status_code == 403 or feed.status_code == 404:
raise TwExtractError(403, "Extract error")
output = feed.json() output = feed.json()
if "errors" in output: if "errors" in output:
# pick the first error and create a twExtractError # pick the first error and create a twExtractError
error = output["errors"][0] error = output["errors"][0]
raise TwExtractError(error["code"], error["message"]) raise TwExtractError(error["code"], error["message"])
return output timelineInstructions = output['data']['user']['result']['timeline']['timeline']['instructions']
#tweetIds=None
tweets=None
for instruction in timelineInstructions:
if 'type' in instruction and instruction['type'] == 'TimelineAddEntries':
entries = instruction['entries']
#tweetIds = []
tweets = []
for entry in entries:
if entry['entryId'].startswith("tweet-"):
# get the tweet ID from the entryId
#tweetId = entry['entryId'].split("-")[1]
#tweetIds.append(tweetId)
tweet = entry['content']['itemContent']['tweet_results']['result']
tweets.append(tweet)
return tweets
except Exception as e: except Exception as e:
print(f"Exception in extractUserFeedFromId: {str(e)}")
continue continue
raise TwExtractError(400, "Extract error") raise TwExtractError(400, "Extract error")
def extractUserFeed(username,workaroundTokens):
pass
def lambda_handler(event, context): def lambda_handler(event, context):
if ("queryStringParameters" not in event): if ("queryStringParameters" not in event):
return { return {
+30 -2
View File
@@ -2,6 +2,10 @@ import math
import hashlib import hashlib
import base64 import base64
import uuid import uuid
from x_client_transaction import ClientTransaction
from x_client_transaction.utils import get_ondemand_file_url, generate_headers
import requests
import bs4
digits = "0123456789abcdefghijklmnopqrstuvwxyz" digits = "0123456789abcdefghijklmnopqrstuvwxyz"
def baseConversion(x, base): def baseConversion(x, base):
@@ -31,5 +35,29 @@ def calcSyndicationToken(idStr):
c = '0' c = '0'
return c return c
def generate_transaction_id(method: str, path: str) -> str: def get_twitter_homepage(headers=None):
return "?" # not implemented if headers is None:
headers = {"Authority": "x.com",
"Accept-Language": "en-US,en;q=0.9",
"Cache-Control": "no-cache",
"Referer": "https://x.com",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
"X-Twitter-Active-User": "yes",
"X-Twitter-Client-Language": "en"}
if 'Authorization' in headers:
del headers['Authorization']
response = requests.get("https://x.com/home", headers=headers)
return response
def generate_transaction_id(method: str, path: str,headers=None) -> str:
session = requests.Session()
session.headers = generate_headers()
home_page = session.get(url="https://x.com/home")
home_page_response = bs4.BeautifulSoup(home_page.content, 'html.parser')
ondemand_file_url = get_ondemand_file_url(response=home_page_response)
ondemand_file = session.get(url=ondemand_file_url)
ondemand_file_response = bs4.BeautifulSoup(ondemand_file.content, 'html.parser')
ct = ClientTransaction(home_page_response=home_page_response, ondemand_file_response=ondemand_file_response)
transaction_id = ct.generate_transaction_id(method=method, path=path)
return transaction_id
+125 -46
View File
@@ -14,7 +14,7 @@ import msgs
import twExtract as twExtract import twExtract as twExtract
from cache import addVnfToLinkCache,getVnfFromLinkCache from cache import addVnfToLinkCache,getVnfFromLinkCache
import vxlogging as log import vxlogging as log
from utils import getTweetIdFromUrl, pathregex, determineMediaToEmbed, determineEmbedTweet, BytesIOWrapper, fixMedia from utils import getTweetIdFromUrl, pathregex, determineMediaToEmbed, determineEmbedTweet, BytesIOWrapper, fixMedia, indexOfAny
from vxApi import getApiResponse, getApiUserResponse from vxApi import getApiResponse, getApiUserResponse
from urllib.parse import urlparse from urllib.parse import urlparse
from PyRTF.Elements import Document from PyRTF.Elements import Document
@@ -22,12 +22,14 @@ from PyRTF.document.section import Section
from PyRTF.document.paragraph import Paragraph from PyRTF.document.paragraph import Paragraph
from copy import deepcopy from copy import deepcopy
import json import json
import datetime import translation
import activity as activitymg import activity as activitymg
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
user_agent="" user_agent=""
activityseparator="zqj"
staticFiles = { # TODO: Use flask static files instead of this staticFiles = { # TODO: Use flask static files instead of this
"favicon.ico": {"mime": "image/vnd.microsoft.icon","path": "favicon.ico"}, "favicon.ico": {"mime": "image/vnd.microsoft.icon","path": "favicon.ico"},
"apple-touch-icon.png": {"mime": "image/png","path": "apple-touch-icon.png"}, "apple-touch-icon.png": {"mime": "image/png","path": "apple-touch-icon.png"},
@@ -67,13 +69,14 @@ def isValidUserAgent(user_agent):
return False return False
def message(text): def message(text):
return render_template( rendered = render_template(
'default.html', 'default.html',
message = text, message = text,
color = config['config']['color'], color = config['config']['color'],
appname = config['config']['appname'], appname = config['config']['appname'],
repo = config['config']['repo'], repo = config['config']['repo'],
url = config['config']['url'] ) url = config['config']['url'] )
return Response(rendered, mimetype='text/html',headers={"Cache-Tag": "message", "Cache-Control": "max-age=1760, public"})
def generateActivityLink(tweetData,media=None,mediatype=None,embedIndex=-1): def generateActivityLink(tweetData,media=None,mediatype=None,embedIndex=-1):
global user_agent global user_agent
@@ -81,7 +84,11 @@ def generateActivityLink(tweetData,media=None,mediatype=None,embedIndex=-1):
return None return None
try: try:
embedIndex = embedIndex+1 embedIndex = embedIndex+1
return f"{config['config']['url']}/users/{tweetData['user_screen_name']}/statuses/{str(embedIndex)}{tweetData['tweetID']}"
extras=""
if tweetData["translation"] is not None:
extras+=activityseparator+tweetData["translation"]["destination_language"]
return f"{config['config']['url']}/users/{tweetData['user_screen_name']}/statuses/{str(embedIndex)}{activityseparator}{tweetData['tweetID']}{extras}"
except Exception as e: except Exception as e:
log.error("Error generating activity link: "+str(e)) log.error("Error generating activity link: "+str(e))
return None return None
@@ -94,7 +101,7 @@ def getAppName(tweetData,appnameSuffix=""):
def renderImageTweetEmbed(tweetData,image,appnameSuffix="",embedIndex=-1): def renderImageTweetEmbed(tweetData,image,appnameSuffix="",embedIndex=-1):
qrt = tweetData['qrt'] qrt = tweetData['qrt']
embedDesc = msgs.formatEmbedDesc("Image",tweetData['text'],qrt,tweetData['pollData']) embedDesc = msgs.formatEmbedDesc("Image",tweetData['text'],qrt,tweetData['pollData'],tweetData["translation"])
if image.startswith("https://pbs.twimg.com") and "?" not in image: if image.startswith("https://pbs.twimg.com") and "?" not in image:
image = f"{image}?name=orig" image = f"{image}?name=orig"
@@ -114,7 +121,7 @@ def renderImageTweetEmbed(tweetData,image,appnameSuffix="",embedIndex=-1):
def renderVideoTweetEmbed(tweetData,mediaInfo,appnameSuffix="",embedIndex=-1): def renderVideoTweetEmbed(tweetData,mediaInfo,appnameSuffix="",embedIndex=-1):
qrt = tweetData['qrt'] qrt = tweetData['qrt']
embedDesc = msgs.formatEmbedDesc("Video",tweetData['text'],qrt,tweetData['pollData']) embedDesc = msgs.formatEmbedDesc("Video",tweetData['text'],qrt,tweetData['pollData'],tweetData["translation"])
mediaInfo=fixMedia(mediaInfo) mediaInfo=fixMedia(mediaInfo)
@@ -137,7 +144,7 @@ def renderVideoTweetEmbed(tweetData,mediaInfo,appnameSuffix="",embedIndex=-1):
def renderTextTweetEmbed(tweetData,appnameSuffix=""): def renderTextTweetEmbed(tweetData,appnameSuffix=""):
qrt = tweetData['qrt'] qrt = tweetData['qrt']
embedDesc = msgs.formatEmbedDesc("Text",tweetData['text'],qrt,tweetData['pollData']) embedDesc = msgs.formatEmbedDesc("Text",tweetData['text'],qrt,tweetData['pollData'],tweetData["translation"])
return render_template("text.html", return render_template("text.html",
tweet=tweetData, tweet=tweetData,
@@ -153,7 +160,7 @@ def renderTextTweetEmbed(tweetData,appnameSuffix=""):
def renderArticleTweetEmbed(tweetData,appnameSuffix=""): def renderArticleTweetEmbed(tweetData,appnameSuffix=""):
articlePreview=tweetData['article']["title"]+"\n\n"+tweetData['article']["preview_text"]+"" articlePreview=tweetData['article']["title"]+"\n\n"+tweetData['article']["preview_text"]+""
embedDesc = msgs.formatEmbedDesc("Image",articlePreview,None,None) embedDesc = msgs.formatEmbedDesc("Image",articlePreview,None,None,None)
return render_template("image.html", return render_template("image.html",
tweet=tweetData, tweet=tweetData,
@@ -259,25 +266,19 @@ def userJson():
}, },
} }
def getTweetData(twitter_url,include_txt="false",include_rtf="false"): def getTweetData(twitter_url,include_txt="false",include_rtf="false",tlLanguage=None):
cachedVNF = getVnfFromLinkCache(twitter_url) cachedVNF = getVnfFromLinkCache(twitter_url)
if cachedVNF is not None and include_txt == "false" and include_rtf == "false": if cachedVNF is not None and include_txt == "false" and include_rtf == "false" and tlLanguage is None:
return cachedVNF return cachedVNF
try: try:
rawTweetData = twExtract.extractStatusV2Anon(twitter_url, None) if config['config']['workaroundTokens'] is not None:
workaroundTokens = config['config']['workaroundTokens'].split(",")
else:
workaroundTokens = None
rawTweetData = twExtract.extractStatus(twitter_url,workaroundTokens=workaroundTokens,tlLanguage=tlLanguage)
except: except:
rawTweetData = None rawTweetData = None
if rawTweetData is None:
try:
if config['config']['workaroundTokens'] is not None:
workaroundTokens = config['config']['workaroundTokens'].split(",")
else:
workaroundTokens = None
rawTweetData = twExtract.extractStatus(twitter_url,workaroundTokens=workaroundTokens)
except:
rawTweetData = None
if rawTweetData == None or 'error' in rawTweetData: if rawTweetData == None or 'error' in rawTweetData:
return None return None
@@ -286,24 +287,53 @@ def getTweetData(twitter_url,include_txt="false",include_rtf="false"):
tweetData = getApiResponse(rawTweetData,include_txt,include_rtf) tweetData = getApiResponse(rawTweetData,include_txt,include_rtf)
if tweetData is None: if tweetData is None:
return None return None
if include_txt == "false" and include_rtf == "false":
if tweetData["translation"] is None and tlLanguage is not None and tlLanguage != tweetData["lang"]:
# translation probably failed on twitter's end
tweetData["translation"] = translation.getDeeplTranslation(tweetData["text"],tlLanguage)
if tweetData["translation"] is not None and tlLanguage is None:
tweetData["translation"] = None # didn't ask for TL
if include_txt == "false" and include_rtf == "false" and tlLanguage is None:
addVnfToLinkCache(twitter_url,tweetData) addVnfToLinkCache(twitter_url,tweetData)
return tweetData return tweetData
def getUserData(twitter_url): def getUserData(twitter_url,includeFeed=False):
rawUserData = twExtract.extractUser(twitter_url,workaroundTokens=config['config']['workaroundTokens'].split(',')) if config['config']['workaroundTokens'] is not None:
workaroundTokens = config['config']['workaroundTokens'].split(",")
else:
workaroundTokens = None
rawUserData = twExtract.extractUser(twitter_url,workaroundTokens=workaroundTokens)
userData = getApiUserResponse(rawUserData) userData = getApiUserResponse(rawUserData)
if includeFeed:
if userData['protected']:
userData['latest_tweets']=[]
else:
feed = twExtract.extractUserFeedFromId(userData['id'],workaroundTokens=workaroundTokens)
apiFeed = []
for tweet in feed:
apiFeed.append(getApiResponse(tweet))
userData['latest_tweets'] = apiFeed
return userData return userData
@app.route('/<path:sub_path>') # Default endpoint used by everything @app.route('/<path:sub_path>') # Default endpoint used by everything
def twitfix(sub_path): def twitfix(sub_path):
splitPath=sub_path.split("/")
if "|" in splitPath[-1]: # discord doesn't strip this when a link is spoilered
splitPath[-1] = splitPath[-1].replace("|","")
subdomain = request.host.rsplit(".",2)[0]
global user_agent global user_agent
user_agent = request.headers.get('User-Agent', None) user_agent = request.headers.get('User-Agent', None)
if user_agent is None: if user_agent is None:
user_agent = "unknown" user_agent = "unknown"
isApiRequest=request.url.startswith("https://api.vx") or request.url.startswith("http://api.vx") isApiRequest=subdomain=="api"
if not isApiRequest and (request.url.startswith("https://l.vx") or request.url.startswith("https://old.vx")) and "Discord" in user_agent: if not isApiRequest and (subdomain=="l" or subdomain=="old") and "Discord" in user_agent:
user_agent = user_agent.replace("Discord","LegacyEmbed") # TODO: Clean up; This is a hacky fix to make the new activity embed not trigger user_agent = user_agent.replace("Discord","LegacyEmbed") # TODO: Clean up; This is a hacky fix to make the new activity embed not trigger
if sub_path in staticFiles: if sub_path in staticFiles:
if 'path' not in staticFiles[sub_path] or staticFiles[sub_path]["path"] == None: if 'path' not in staticFiles[sub_path] or staticFiles[sub_path]["path"] == None:
@@ -322,7 +352,15 @@ def twitfix(sub_path):
username=sub_path.split("/")[0] username=sub_path.split("/")[0]
extra = sub_path.split("/")[1] extra = sub_path.split("/")[1]
if extra in [None,"with_replies","media","likes","highlights","superfollows","media",''] and username != "" and username != None: if extra in [None,"with_replies","media","likes","highlights","superfollows","media",''] and username != "" and username != None:
userData = getUserData(f"https://twitter.com/{username}") try:
userData = getUserData(f"https://twitter.com/{username}","with_tweets" in request.args)
except twExtract.TwExtractError as e:
if isApiRequest:
status=500
if 'not found' in e.msg:
status=404
return Response(json.dumps({"error": e.msg}), status=status,mimetype='application/json')
return message("Error getting user data: "+str(e.msg))
if isApiRequest: if isApiRequest:
if userData is None: if userData is None:
abort(404) abort(404)
@@ -337,20 +375,40 @@ def twitfix(sub_path):
include_txt="false" include_txt="false"
include_rtf="false" include_rtf="false"
translationIndex=indexOfAny(splitPath,translation.mergedLangs,caseInsensitive=True)
translationLang = None
if translationIndex != -1:
if splitPath[translationIndex].upper() in translation.languageAliases:
translationLang = translation.languageAliases[splitPath[translationIndex].upper()]
else:
translationLang = splitPath[translationIndex].lower()
elif subdomain.upper() in translation.mergedLangs:
if subdomain.upper() in translation.languageAliases:
translationLang = translation.languageAliases[subdomain.upper()]
else:
translationLang = subdomain.lower()
if isApiRequest: if isApiRequest:
if "include_txt" in request.args: if "include_txt" in request.args:
include_txt = request.args.get("include_txt") include_txt = request.args.get("include_txt")
if "include_rtf" in request.args: if "include_rtf" in request.args:
include_rtf = request.args.get("include_rtf") include_rtf = request.args.get("include_rtf")
tweetData = getTweetData(twitter_url,include_txt,include_rtf) tweetData = getTweetData(twitter_url,include_txt,include_rtf,tlLanguage=translationLang)
if tweetData is None: if tweetData is None:
log.error("Tweet Data Get failed for "+twitter_url) log.error("Tweet Data Get failed for "+twitter_url)
return message(msgs.failedToScan) return message(msgs.failedToScan)
qrt = None qrt = None
if 'qrtURL' in tweetData and tweetData['qrtURL'] is not None: if 'qrtURL' in tweetData and tweetData['qrtURL'] is not None:
qrt = getTweetData(tweetData['qrtURL']) qrt = getTweetData(tweetData['qrtURL'],tlLanguage=translationLang)
tweetData['qrt'] = qrt tweetData['qrt'] = qrt
retweet = None
if 'retweetURL' in tweetData and tweetData['retweetURL'] is not None:
retweet = getTweetData(tweetData['retweetURL'],tlLanguage=translationLang)
tweetData['retweet'] = retweet
tweetData = deepcopy(tweetData) tweetData = deepcopy(tweetData)
log.success("Tweet Data Get success") log.success("Tweet Data Get success")
if '?' in request.url: if '?' in request.url:
@@ -359,7 +417,7 @@ def twitfix(sub_path):
requestUrlWithoutQuery = request.url requestUrlWithoutQuery = request.url
directEmbed=False directEmbed=False
if requestUrlWithoutQuery.startswith("https://d.vx") or requestUrlWithoutQuery.endswith(".mp4") or requestUrlWithoutQuery.endswith(".png"): if subdomain=="d" or requestUrlWithoutQuery.endswith(".mp4") or requestUrlWithoutQuery.endswith(".png"):
directEmbed = True directEmbed = True
# remove the .mp4 from the end of the URL # remove the .mp4 from the end of the URL
if requestUrlWithoutQuery.endswith(".mp4") or requestUrlWithoutQuery.endswith(".png"): if requestUrlWithoutQuery.endswith(".mp4") or requestUrlWithoutQuery.endswith(".png"):
@@ -379,9 +437,10 @@ def twitfix(sub_path):
return send_file(BytesIOWrapper(rtf), mimetype='application/rtf', as_attachment=True, download_name=f'{tweetData["user_screen_name"]}_{tweetData["tweetID"]}.rtf') return send_file(BytesIOWrapper(rtf), mimetype='application/rtf', as_attachment=True, download_name=f'{tweetData["user_screen_name"]}_{tweetData["tweetID"]}.rtf')
embedIndex = -1 embedIndex = -1
# if url ends with /1, /2, /3, or /4, we'll use that as the index # if url has /1, /2, /3, or /4, we'll use that as the index
if sub_path[-2:] in ["/1","/2","/3","/4"]: numIdx=indexOfAny(splitPath,["1","2","3","4"]);
embedIndex = int(sub_path[-1])-1 if numIdx != -1:
embedIndex = int(splitPath[numIdx])-1
sub_path = sub_path[:-2] sub_path = sub_path[:-2]
if isApiRequest: # Directly return the API response if the request is from the API if isApiRequest: # Directly return the API response if the request is from the API
@@ -390,33 +449,39 @@ def twitfix(sub_path):
embeddingMedia = tweetData['hasMedia'] embeddingMedia = tweetData['hasMedia']
renderMedia = None renderMedia = None
if embeddingMedia: if embeddingMedia:
renderMedia = determineMediaToEmbed(tweetData,embedIndex) renderMedia = determineMediaToEmbed(tweetData,embedIndex,convertGif=False)
# direct embeds should always prioritize the main tweet, so don't check for qrt # direct embeds should always prioritize the main tweet, so don't check for qrt
# determine what type of media we're dealing with # determine what type of media we're dealing with
if not embeddingMedia and qrt is None: if not embeddingMedia and qrt is None:
return renderTextTweetEmbed(tweetData) return renderTextTweetEmbed(tweetData)
else: else:
if renderMedia['type'] == "image": if renderMedia['type'] == "image":
return render_template("rawimage.html",media=renderMedia) return Response(render_template("rawimage.html",media=renderMedia),headers={"Cache-Tag": "embed"})
elif renderMedia['type'] == "video" or renderMedia['type'] == "gif": elif renderMedia['type'] == "video" or renderMedia['type'] == "gif":
return render_template("rawvideo.html",media=renderMedia) return Response(render_template("rawvideo.html",media=renderMedia),headers={"Cache-Tag": "embed"})
else: # full embed else: # full embed
embedTweetData = determineEmbedTweet(tweetData) embedTweetData = determineEmbedTweet(tweetData)
embeddingMedia = embedTweetData['hasMedia'] embeddingMedia = embedTweetData['hasMedia']
if "article" in embedTweetData and embedTweetData["article"] is not None: if "article" in embedTweetData and embedTweetData["article"] is not None:
return renderArticleTweetEmbed(tweetData," • See original tweet for full article") return Response(renderArticleTweetEmbed(tweetData," • See original tweet for full article"),headers={"Cache-Tag": "embed"})
elif not embeddingMedia: elif not embeddingMedia:
return renderTextTweetEmbed(tweetData) return Response(renderTextTweetEmbed(tweetData),headers={"Cache-Tag": "embed"})
else: else:
media = determineMediaToEmbed(embedTweetData,embedIndex) tryGifConversion = "Discord" in user_agent
media = determineMediaToEmbed(embedTweetData,embedIndex,convertGif=tryGifConversion)
suffix="" suffix=""
if "suffix" in media: if "suffix" in media:
suffix = media["suffix"] suffix = media["suffix"]
if media['type'] == "image": if media['type'] == "image":
return renderImageTweetEmbed(tweetData,media['url'] , appnameSuffix=suffix,embedIndex=embedIndex) return Response(renderImageTweetEmbed(tweetData,media['url'] , appnameSuffix=suffix,embedIndex=embedIndex),headers={"Cache-Tag": "embed"})
elif media['type'] == "video" or media['type'] == "gif": elif media['type'] == "video":
return renderVideoTweetEmbed(tweetData,media,appnameSuffix=suffix,embedIndex=embedIndex) return Response(renderVideoTweetEmbed(tweetData,media,appnameSuffix=suffix,embedIndex=embedIndex),headers={"Cache-Tag": "embed"})
elif media['type'] == "gif":
if "originalUrl" in media and media["url"] != media["originalUrl"] and tryGifConversion:
return Response(renderImageTweetEmbed(tweetData,media['url'] , appnameSuffix=suffix,embedIndex=embedIndex),headers={"Cache-Tag": "embed"})
else:
return Response(renderVideoTweetEmbed(tweetData,media,appnameSuffix=suffix,embedIndex=embedIndex),headers={"Cache-Tag": "embed"})
return message(msgs.failedToScan) return message(msgs.failedToScan)
@@ -454,17 +519,31 @@ def rendercombined():
@app.route("/api/v1/statuses/<string:tweet_id>") @app.route("/api/v1/statuses/<string:tweet_id>")
def api_v1_status(tweet_id): def api_v1_status(tweet_id):
embedIndex = int(tweet_id[0])-1 lang = None
tweet_id = int(tweet_id[1:]) if activityseparator not in tweet_id: #old embeds
embedIndex = int(tweet_id[0])-1
tweet_id = int(tweet_id[1:])
else:
args = tweet_id.split(activityseparator)
embedIndex = int(args[0])-1
tweet_id = int(args[1])
if len(args)>2:
lang=args[2]
twitter_url=f"https://twitter.com/i/status/{tweet_id}" twitter_url=f"https://twitter.com/i/status/{tweet_id}"
tweetData = getTweetData(twitter_url) tweetData = getTweetData(twitter_url,tlLanguage=lang)
if tweetData is None: if tweetData is None:
log.error("Tweet Data Get failed for "+twitter_url) log.error("Tweet Data Get failed for "+twitter_url)
return message(msgs.failedToScan) return message(msgs.failedToScan)
qrt = None qrt = None
if 'qrtURL' in tweetData and tweetData['qrtURL'] is not None: if 'qrtURL' in tweetData and tweetData['qrtURL'] is not None:
qrt = getTweetData(tweetData['qrtURL']) qrt = getTweetData(tweetData['qrtURL'],tlLanguage=lang)
tweetData['qrt'] = qrt tweetData['qrt'] = qrt
retweet = None
if 'retweetURL' in tweetData and tweetData['retweetURL'] is not None:
retweet = getTweetData(tweetData['retweetURL'],tlLanguage=lang)
tweetData['retweet'] = retweet
if tweetData is None: if tweetData is None:
abort(500) # this should cause Discord to fall back to the default embed abort(500) # this should cause Discord to fall back to the default embed
return activitymg.tweetDataToActivity(tweetData,embedIndex) return activitymg.tweetDataToActivity(tweetData,embedIndex)
+19 -5
View File
@@ -3,7 +3,7 @@ import io
from configHandler import config from configHandler import config
pathregex = re.compile("\\w{1,15}\\/(status|statuses)\\/(\\d{2,20})") pathregex = re.compile("\\w{1,15}\\/(status|statuses)\\/(\\d{2,20})")
endTCOregex = re.compile("(^.*?) +https:\/\/t.co\/.*?$") endTCOregex = re.compile("(^.*?)[ \n]+https:\/\/t.co\/.*?$",flags=re.DOTALL)
def getTweetIdFromUrl(url): def getTweetIdFromUrl(url):
match = pathregex.search(url) match = pathregex.search(url)
@@ -64,7 +64,7 @@ def determineEmbedTweet(tweetData):
return tweetData['qrt'] return tweetData['qrt']
return tweetData return tweetData
def determineMediaToEmbed(tweetData,embedIndex = -1): def determineMediaToEmbed(tweetData,embedIndex = -1,convertGif = True):
if tweetData['allSameType'] and tweetData['media_extended'][0]['type'] == "image" and embedIndex == -1 and tweetData['combinedMediaUrl'] != None: if tweetData['allSameType'] and tweetData['media_extended'][0]['type'] == "image" and embedIndex == -1 and tweetData['combinedMediaUrl'] != None:
return {"url":tweetData['combinedMediaUrl'],"type":"image"} return {"url":tweetData['combinedMediaUrl'],"type":"image"}
else: else:
@@ -82,10 +82,24 @@ def determineMediaToEmbed(tweetData,embedIndex = -1):
if media['type'] == "image": if media['type'] == "image":
return media return media
elif media['type'] == "video" or media['type'] == "gif": elif media['type'] == "video" or media['type'] == "gif":
if media['type'] == "gif": if media['type'] == "gif" and convertGif:
if config['config']['gifConvertAPI'] != "" and config['config']['gifConvertAPI'] != "none": if config['config']['gifConvertAPI'] != "" and config['config']['gifConvertAPI'] != "none" and config['config']['gifConvertAPI'] != "local":
gcApi = config['config']['gifConvertAPI']
#if gcApi == "local": # TODO
#gcApi = f"{config['config']['url']}/gifconvert"
vurl=media['originalUrl'] if 'originalUrl' in media else media['url'] vurl=media['originalUrl'] if 'originalUrl' in media else media['url']
media['url'] = config['config']['gifConvertAPI'] + "/convert?url=" + vurl media['url'] = gcApi + "/convert.avif?url=" + vurl
suffix += " • GIF" suffix += " • GIF"
media["suffix"] = suffix media["suffix"] = suffix
return media return media
def indexOfAny(list,items,caseInsensitive=False):
if caseInsensitive:
list = [item.upper() for item in list]
items = [item.upper() for item in items]
for item in items:
try:
return list.index(item)
except:
continue
return -1
+133 -62
View File
@@ -1,29 +1,75 @@
import html import html
from datetime import datetime from datetime import datetime
from flask import json
from configHandler import config from configHandler import config
from utils import stripEndTCO from utils import stripEndTCO
def getApiUserResponse(user): def getApiUserResponse(user):
userResult = user["data"]["user"]["result"]
return { return {
"id": user["id"], "id": int(userResult["rest_id"]),
"screen_name": user["screen_name"], "screen_name": userResult["core"]["screen_name"],
"name": user["name"], "name": userResult["core"]["name"],
"profile_image_url": user["profile_image_url_https"], "profile_image_url": userResult['avatar']["image_url"],
"description": user["description"], "description": userResult["legacy"]["description"],
"location": user["location"], "location": userResult["location"]["location"],
"followers_count": user["followers_count"], "followers_count": userResult["legacy"]["followers_count"],
"following_count": user["friends_count"], "following_count": userResult["legacy"]["friends_count"],
"tweet_count": user["statuses_count"], "tweet_count": userResult["legacy"]["statuses_count"],
"created_at": user["created_at"], "created_at": userResult["core"]["created_at"],
"protected": user["protected"], "protected": userResult["privacy"]["protected"],
"fetched_on": int(datetime.now().timestamp()),
} }
def getBestMediaUrl(mediaList):
# find the highest bitrate
best_bitrate = -1
besturl=""
for j in mediaList:
if j['content_type'] == "video/mp4" and '/hevc/' not in j["url"] and j['bitrate'] > best_bitrate:
besturl = j["url"]
best_bitrate = j['bitrate']
if "?tag=" in besturl:
besturl = besturl[:besturl.index("?tag=")]
return besturl
def getExtendedVideoOrGifInfo(mediaEntry):
videoInfo = mediaEntry["video_info"]
info = {
"url": getBestMediaUrl(videoInfo["variants"]),
"type": "gif" if mediaEntry.get("type", "") == "animated_gif" else "video",
"size": {
"width": mediaEntry['original_info']["width"],
"height": mediaEntry['original_info']["height"]
},
"duration_millis": videoInfo.get("duration_millis", 0),
"thumbnail_url": mediaEntry.get("media_url_https", None),
"altText": mediaEntry.get("ext_alt_text", None),
"id_str": mediaEntry.get("id_str", None)
}
return info
def getExtendedImageInfo(mediaEntry):
info = {
"url": mediaEntry.get("media_url_https", None),
"type": "image",
"size": {
"width": mediaEntry["original_info"]["width"],
"height": mediaEntry["original_info"]["height"]
},
"thumbnail_url": mediaEntry.get("media_url_https", None),
"altText": mediaEntry.get("ext_alt_text", None),
"id_str": mediaEntry.get("id_str", None)
}
return info
def getApiResponse(tweet,include_txt=False,include_rtf=False): def getApiResponse(tweet,include_txt=False,include_rtf=False):
tweetL = tweet["legacy"] tweetL = tweet["legacy"]
if "user_result" in tweet["core"]: if "user_result" in tweet["core"]:
userL = tweet["core"]["user_result"]["result"]["legacy"] user = tweet["core"]["user_result"]["result"]
elif "user_results" in tweet["core"]: elif "user_results" in tweet["core"]:
userL = tweet["core"]["user_results"]["result"]["legacy"] user = tweet["core"]["user_results"]["result"]
userL = user["legacy"]
media=[] media=[]
media_extended=[] media_extended=[]
hashtags=[] hashtags=[]
@@ -31,6 +77,23 @@ def getApiResponse(tweet,include_txt=False,include_rtf=False):
oldTweetVersion = False oldTweetVersion = False
tweetArticle=None tweetArticle=None
lang=None lang=None
translation=None
if "screen_name" not in userL:
userL["screen_name"] = user["core"]["screen_name"]
if "name" not in userL:
userL["name"] = user["core"]["name"]
if "profile_image_url_https" not in userL:
userL["profile_image_url_https"] = user["avatar"]["image_url"]
if "grok_translated_post_with_availability" in tweet and tweet["grok_translated_post_with_availability"]["is_available"] and "data" in tweet["grok_translated_post_with_availability"]:
tlData = tweet["grok_translated_post_with_availability"]["data"]
translation = {
"source_language":tlData["source_language"],
"destination_language":tlData["destination_language"],
"text":tlData["translation"]
}
#editedTweet=False #editedTweet=False
try: try:
if "birdwatch_pivot" in tweet: if "birdwatch_pivot" in tweet:
@@ -58,61 +121,55 @@ def getApiResponse(tweet,include_txt=False,include_rtf=False):
for i in tmedia: for i in tmedia:
extendedInfo={} extendedInfo={}
if "video_info" in i: if "video_info" in i:
# find the highest bitrate extendedInfo = getExtendedVideoOrGifInfo(i)
best_bitrate = -1 media.append(extendedInfo["url"])
besturl=""
for j in i["video_info"]["variants"]:
if j['content_type'] == "video/mp4" and '/hevc/' not in j["url"] and j['bitrate'] > best_bitrate:
besturl = j['url']
best_bitrate = j['bitrate']
if "?tag=" in besturl:
besturl = besturl[:besturl.index("?tag=")]
media.append(besturl)
extendedInfo["url"] = besturl
extendedInfo["type"] = "video"
if (i["type"] == "animated_gif"):
extendedInfo["type"] = "gif"
altText = None
extendedInfo["size"] = {"width":i["original_info"]["width"],"height":i["original_info"]["height"]}
if "ext_alt_text" in i:
altText=i["ext_alt_text"]
if "duration_millis" in i["video_info"]:
extendedInfo["duration_millis"] = i["video_info"]["duration_millis"]
else:
extendedInfo["duration_millis"] = 0
extendedInfo["thumbnail_url"] = i["media_url_https"]
extendedInfo["altText"] = altText
media_extended.append(extendedInfo) media_extended.append(extendedInfo)
else: else:
media.append(i["media_url_https"]) extendedInfo = getExtendedImageInfo(i)
extendedInfo["url"] = i["media_url_https"]
altText=None
if "ext_alt_text" in i:
altText=i["ext_alt_text"]
extendedInfo["altText"] = altText
extendedInfo["type"] = "image"
extendedInfo["size"] = {"width":i["original_info"]["width"],"height":i["original_info"]["height"]}
extendedInfo["thumbnail_url"] = i["media_url_https"]
media_extended.append(extendedInfo) media_extended.append(extendedInfo)
media.append(extendedInfo["url"])
if "hashtags" in tweetL["entities"]: if "hashtags" in tweetL["entities"]:
for i in tweetL["entities"]["hashtags"]: for i in tweetL["entities"]["hashtags"]:
hashtags.append(i["text"]) hashtags.append(i["text"])
elif "card" in tweet and tweet['card']['name'] == "player": elif "card" in tweet or "tweet_card" in tweet:
width = None cardData = tweet["card" if "card" in tweet else "tweet_card"]
height = None bindingValues = None
vidUrl = None if 'binding_values' in cardData:
for i in tweet['card']['binding_values']: bindingValues = cardData['binding_values']
if i['key'] == 'player_stream_url': elif 'legacy' in cardData and 'binding_values' in cardData['legacy']:
vidUrl = i['value']['string_value'] bindingValues = cardData['legacy']['binding_values']
elif i['key'] == 'player_width': if bindingValues != None:
width = int(i['value']['string_value']) if 'name' in cardData and cardData['name'] == "player":
elif i['key'] == 'player_height': width = None
height = int(i['value']['string_value']) height = None
if vidUrl != None and width != None and height != None: vidUrl = None
media.append(vidUrl) for i in bindingValues:
media_extended.append({"url":vidUrl,"type":"video","size":{"width":width,"height":height}}) if i['key'] == 'player_stream_url':
vidUrl = i['value']['string_value']
elif i['key'] == 'player_width':
width = int(i['value']['string_value'])
elif i['key'] == 'player_height':
height = int(i['value']['string_value'])
if vidUrl != None and width != None and height != None:
media.append(vidUrl)
media_extended.append({"url":vidUrl,"type":"video","size":{"width":width,"height":height}})
else:
for i in bindingValues:
if i['key'] == 'unified_card' and 'value' in i and 'string_value' in i['value']:
cardData = json.loads(i['value']['string_value'])
media_key = cardData['component_objects']['media_1']['data']['id']
media_entry = cardData['media_entities'][media_key]
extendedInfo = getExtendedVideoOrGifInfo(media_entry)
media.append(extendedInfo['url'])
media_extended.append(extendedInfo)
break
elif i['key'] == 'photo_image_full_size_large' and 'value' in i and 'image_value' in i['value']:
imgData = i['value']['image_value']
imgurl = imgData['url']
media.append(imgurl)
media_extended.append({"url":imgurl,"type":"image","size":{"width":imgData['width'],"height":imgData['height']}})
break
if "article" in tweet: if "article" in tweet:
try: try:
result = tweet["article"]["article_results"]["result"] result = tweet["article"]["article_results"]["result"]
@@ -143,6 +200,10 @@ def getApiResponse(tweet,include_txt=False,include_rtf=False):
if 'quoted_status_id_str' in tweetL: if 'quoted_status_id_str' in tweetL:
qrtURL = "https://twitter.com/i/status/" + tweetL['quoted_status_id_str'] qrtURL = "https://twitter.com/i/status/" + tweetL['quoted_status_id_str']
retweetURL = None
if 'retweeted_status_result' in tweetL:
retweetURL = "https://twitter.com/i/status/" + tweetL['retweeted_status_result']['result']['rest_id']
if 'possibly_sensitive' not in tweetL: if 'possibly_sensitive' not in tweetL:
tweetL['possibly_sensitive'] = False tweetL['possibly_sensitive'] = False
@@ -155,6 +216,8 @@ def getApiResponse(tweet,include_txt=False,include_rtf=False):
if 'entities' in tweetL and 'urls' in tweetL['entities']: if 'entities' in tweetL and 'urls' in tweetL['entities']:
for eurl in tweetL['entities']['urls']: for eurl in tweetL['entities']['urls']:
if 'expanded_url' not in eurl:
continue
if "/status/" in eurl["expanded_url"] and eurl["expanded_url"].startswith("https://twitter.com/"): if "/status/" in eurl["expanded_url"] and eurl["expanded_url"].startswith("https://twitter.com/"):
twText = twText.replace(eurl["url"], "") twText = twText.replace(eurl["url"], "")
else: else:
@@ -210,7 +273,7 @@ def getApiResponse(tweet,include_txt=False,include_rtf=False):
totalVotes += option["votes"] totalVotes += option["votes"]
pollData["options"].append(option) pollData["options"].append(option)
for i in pollData["options"]: for i in pollData["options"]:
i["percent"] = round((i["votes"]/totalVotes)*100,2) i["percent"] = round((i["votes"]/totalVotes)*100,2) if totalVotes > 0 else 0
if 'lang' in tweetL: if 'lang' in tweetL:
lang = tweetL['lang'] lang = tweetL['lang']
@@ -219,6 +282,10 @@ def getApiResponse(tweet,include_txt=False,include_rtf=False):
if 'in_reply_to_screen_name' in tweetL and tweetL['in_reply_to_screen_name'] != None: if 'in_reply_to_screen_name' in tweetL and tweetL['in_reply_to_screen_name'] != None:
replyingTo = tweetL['in_reply_to_screen_name'] replyingTo = tweetL['in_reply_to_screen_name']
replyingToID = None
if 'in_reply_to_status_id_str' in tweetL and tweetL['in_reply_to_status_id_str'] != None:
replyingToID = tweetL['in_reply_to_status_id_str']
apiObject = { apiObject = {
"text": twText, "text": twText,
"likes": tweetL["favorite_count"], "likes": tweetL["favorite_count"],
@@ -244,6 +311,10 @@ def getApiResponse(tweet,include_txt=False,include_rtf=False):
"article": tweetArticle, "article": tweetArticle,
"lang": lang, "lang": lang,
"replyingTo": replyingTo, "replyingTo": replyingTo,
"replyingToID": replyingToID,
"fetched_on": int(datetime.now().timestamp()),
"retweetURL":retweetURL,
"translation":translation
} }
try: try:
apiObject["date_epoch"] = int(datetime.strptime(tweetL["created_at"], "%a %b %d %H:%M:%S %z %Y").timestamp()) apiObject["date_epoch"] = int(datetime.strptime(tweetL["created_at"], "%a %b %d %H:%M:%S %z %Y").timestamp())
+16 -12
View File
@@ -6,26 +6,30 @@ testVideoTweet="https://twitter.com/pdxdylan/status/1540398733669666818"
testMediaTweet="https://twitter.com/pdxdylan/status/1534672932106035200" testMediaTweet="https://twitter.com/pdxdylan/status/1534672932106035200"
testMultiMediaTweet="https://twitter.com/pdxdylan/status/1532006436703715331" testMultiMediaTweet="https://twitter.com/pdxdylan/status/1532006436703715331"
testQRTTweet="https://twitter.com/pdxdylan/status/1611477137319514129" testQRTTweet="https://twitter.com/pdxdylan/status/1611477137319514129"
testQrtCeptionTweet="https://twitter.com/CatherineShu/status/585253766271672320" # TODO: tweet is deleted testQrtCeptionTweet="https://twitter.com/CatherineShu/status/585253766271672320"
testQrtVideoTweet="https://twitter.com/pdxdylan/status/1674561759422578690" testQrtVideoTweet="https://twitter.com/pdxdylan/status/1674561759422578690"
testNSFWTweet="https://twitter.com/kuyacoy/status/1581185279376838657" testNSFWTweet="https://twitter.com/kuyacoy/status/1581185279376838657"
testPollTweet="https://twitter.com/norm/status/651169346518056960" testPollTweet="https://twitter.com/norm/status/651169346518056960"
testMixedMediaTweet="https://twitter.com/bigbeerfest/status/1760638922084741177" testMixedMediaTweet="https://twitter.com/bigbeerfest/status/1760638922084741177"
testVinePlayerTweet="https://twitter.com/Roblox/status/583302104342638592" testVinePlayerTweet="https://twitter.com/Roblox/status/583302104342638592"
testRetweetTweet="https://twitter.com/pdxdylan/status/1828570470222045294"
testTextTweet_compare={'text': 'just setting up my twttr', 'date': 'Tue Mar 21 20:50:14 +0000 2006', 'tweetURL': 'https://twitter.com/jack/status/20', 'tweetID': '20', 'conversationID': '20', 'mediaURLs': [], 'media_extended': [], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': False, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'date_epoch': 1142974214} testTextTweet_compare={'text': 'just setting up my twttr', 'date': 'Tue Mar 21 20:50:14 +0000 2006', 'tweetURL': 'https://twitter.com/jack/status/20', 'tweetID': '20', 'conversationID': '20', 'mediaURLs': [], 'media_extended': [], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': False, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'lang': 'en', 'replyingTo': None, 'replyingToID': None, 'retweetURL': None, 'date_epoch': 1142974214}
testVideoTweet_compare={'text': 'TikTok embeds on Discord/Telegram bait you with a fake play button, but to see the actual video you have to go to their website.\nAs a request from a friend, I made it so that if you add "vx" before "tiktok" on any link, it fixes that. https://t.co/QYpiVXUIrW', 'date': 'Fri Jun 24 18:17:31 +0000 2022', 'tweetURL': 'https://twitter.com/pdxdylan/status/1540398733669666818', 'tweetID': '1540398733669666818', 'conversationID': '1540398733669666818', 'mediaURLs': ['https://video.twimg.com/ext_tw_video/1540396699037929472/pu/vid/762x528/YxbXbT3X7vq4LWfC.mp4'], 'media_extended': [{'url': 'https://video.twimg.com/ext_tw_video/1540396699037929472/pu/vid/762x528/YxbXbT3X7vq4LWfC.mp4', 'type': 'video', 'size': {'width': 762, 'height': 528}, 'duration_millis': 13650, 'thumbnail_url': 'https://pbs.twimg.com/ext_tw_video_thumb/1540396699037929472/pu/img/l187Z6B9AHHxUKPV.jpg', 'altText': None}], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': True, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'date_epoch': 1656094651} testVideoTweet_compare={'text': 'TikTok embeds on Discord/Telegram bait you with a fake play button, but to see the actual video you have to go to their website.\nAs a request from a friend, I made it so that if you add "vx" before "tiktok" on any link, it fixes that.', 'date': 'Fri Jun 24 18:17:31 +0000 2022', 'tweetURL': 'https://twitter.com/pdxdylan/status/1540398733669666818', 'tweetID': '1540398733669666818', 'conversationID': '1540398733669666818', 'mediaURLs': ['https://video.twimg.com/ext_tw_video/1540396699037929472/pu/vid/762x528/YxbXbT3X7vq4LWfC.mp4'], 'media_extended': [{'url': 'https://video.twimg.com/ext_tw_video/1540396699037929472/pu/vid/762x528/YxbXbT3X7vq4LWfC.mp4', 'type': 'video', 'size': {'width': 762, 'height': 528}, 'duration_millis': 13650, 'thumbnail_url': 'https://pbs.twimg.com/ext_tw_video_thumb/1540396699037929472/pu/img/l187Z6B9AHHxUKPV.jpg', 'altText': None, 'id_str': '1540396699037929472'}], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': True, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'lang': 'en', 'replyingTo': None, 'replyingToID': None, 'retweetURL': None, 'date_epoch': 1656094651}
testMediaTweet_compare={'text': 'oh.', 'date': 'Wed Jun 08 23:05:14 +0000 2022', 'tweetURL': 'https://twitter.com/pdxdylan/status/1534672932106035200', 'tweetID': '1534672932106035200', 'conversationID': '1534672673422381057', 'mediaURLs': ['https://pbs.twimg.com/media/FUxAt5LWUAMol0N.png'], 'media_extended': [{'url': 'https://pbs.twimg.com/media/FUxAt5LWUAMol0N.png', 'altText': None, 'type': 'image', 'size': {'width': 927, 'height': 534}, 'thumbnail_url': 'https://pbs.twimg.com/media/FUxAt5LWUAMol0N.png'}], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': True, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'date_epoch': 1654729514} testMediaTweet_compare={'text': 'oh.', 'date': 'Wed Jun 08 23:05:14 +0000 2022', 'tweetURL': 'https://twitter.com/pdxdylan/status/1534672932106035200', 'tweetID': '1534672932106035200', 'conversationID': '1534672673422381057', 'mediaURLs': ['https://pbs.twimg.com/media/FUxAt5LWUAMol0N.png'], 'media_extended': [{'url': 'https://pbs.twimg.com/media/FUxAt5LWUAMol0N.png', 'altText': None, 'type': 'image', 'size': {'width': 927, 'height': 534}, 'thumbnail_url': 'https://pbs.twimg.com/media/FUxAt5LWUAMol0N.png', 'id_str': '1534672730213208067'}], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': True, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'lang': 'und', 'replyingTo': 'pdxdylan', 'replyingToID': '1534672673422381057', 'retweetURL': None, 'date_epoch': 1654729514}
testMultiMediaTweet_compare={'text': 'Released #Retro64 1.0.9. Besides a lot of internal bug-fixes, this adds quicksand blocks, fixes the rendering for the castle stairs block, and adds a new model, Sonic! \nhttps://github.com/Retro64Mod/Retro64Mod/releases/tag/1.18.2-1.0.9 https://t.co/CWZaw4hzyg', 'date': 'Wed Jun 01 14:29:32 +0000 2022', 'tweetURL': 'https://twitter.com/pdxdylan/status/1532006436703715331', 'tweetID': '1532006436703715331', 'conversationID': '1532006436703715331', 'mediaURLs': ['https://pbs.twimg.com/media/FULF9oxXwAMDI-C.png', 'https://pbs.twimg.com/media/FULGaHkWYAIBV5U.png', 'https://pbs.twimg.com/media/FULGiZnWQAMBRWl.png'], 'media_extended': [{'url': 'https://pbs.twimg.com/media/FULF9oxXwAMDI-C.png', 'altText': None, 'type': 'image', 'size': {'width': 507, 'height': 507}, 'thumbnail_url': 'https://pbs.twimg.com/media/FULF9oxXwAMDI-C.png'}, {'url': 'https://pbs.twimg.com/media/FULGaHkWYAIBV5U.png', 'altText': None, 'type': 'image', 'size': {'width': 396, 'height': 431}, 'thumbnail_url': 'https://pbs.twimg.com/media/FULGaHkWYAIBV5U.png'}, {'url': 'https://pbs.twimg.com/media/FULGiZnWQAMBRWl.png', 'altText': None, 'type': 'image', 'size': {'width': 399, 'height': 341}, 'thumbnail_url': 'https://pbs.twimg.com/media/FULGiZnWQAMBRWl.png'}], 'possibly_sensitive': False, 'hashtags': ['Retro64'], 'qrtURL': None, 'allSameType': True, 'hasMedia': True, 'combinedMediaUrl': 'https://vxtwitter.com/rendercombined.jpg?imgs=https://pbs.twimg.com/media/FULF9oxXwAMDI-C.png,https://pbs.twimg.com/media/FULGaHkWYAIBV5U.png,https://pbs.twimg.com/media/FULGiZnWQAMBRWl.png', 'pollData': None, 'article': None, 'date_epoch': 1654093772} testMultiMediaTweet_compare={'text': 'Released #Retro64 1.0.9. Besides a lot of internal bug-fixes, this adds quicksand blocks, fixes the rendering for the castle stairs block, and adds a new model, Sonic! \nhttps://github.com/Retro64Mod/Retro64Mod/releases/tag/1.18.2-1.0.9', 'date': 'Wed Jun 01 14:29:32 +0000 2022', 'tweetURL': 'https://twitter.com/pdxdylan/status/1532006436703715331', 'tweetID': '1532006436703715331', 'conversationID': '1532006436703715331', 'mediaURLs': ['https://pbs.twimg.com/media/FULF9oxXwAMDI-C.png', 'https://pbs.twimg.com/media/FULGaHkWYAIBV5U.png', 'https://pbs.twimg.com/media/FULGiZnWQAMBRWl.png'], 'media_extended': [{'url': 'https://pbs.twimg.com/media/FULF9oxXwAMDI-C.png', 'altText': None, 'type': 'image', 'size': {'width': 507, 'height': 507}, 'thumbnail_url': 'https://pbs.twimg.com/media/FULF9oxXwAMDI-C.png', 'id_str': '1532004485966577667'}, {'url': 'https://pbs.twimg.com/media/FULGaHkWYAIBV5U.png', 'altText': None, 'type': 'image', 'size': {'width': 396, 'height': 431}, 'thumbnail_url': 'https://pbs.twimg.com/media/FULGaHkWYAIBV5U.png', 'id_str': '1532004975269797890'}, {'url': 'https://pbs.twimg.com/media/FULGiZnWQAMBRWl.png', 'altText': None, 'type': 'image', 'size': {'width': 399, 'height': 341}, 'thumbnail_url': 'https://pbs.twimg.com/media/FULGiZnWQAMBRWl.png', 'id_str': '1532005117553164291'}], 'possibly_sensitive': False, 'hashtags': ['Retro64'], 'qrtURL': None, 'allSameType': True, 'hasMedia': True, 'combinedMediaUrl': 'https://vxtwitter.com/rendercombined.jpg?imgs=https://pbs.twimg.com/media/FULF9oxXwAMDI-C.png,https://pbs.twimg.com/media/FULGaHkWYAIBV5U.png,https://pbs.twimg.com/media/FULGiZnWQAMBRWl.png', 'pollData': None, 'article': None, 'lang': 'en', 'replyingTo': None, 'replyingToID': None, 'retweetURL': None, 'date_epoch': 1654093772}
testQRTTweet_compare={'text': "vxTwitter has gotten a *ton* of usage recently, so I'd appreciate a donation to keep things running!\n", 'date': 'Fri Jan 06 21:37:43 +0000 2023', 'tweetURL': 'https://twitter.com/pdxdylan/status/1611477137319514129', 'tweetID': '1611477137319514129', 'conversationID': '1611476665821003776', 'mediaURLs': [], 'media_extended': [], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': 'https://twitter.com/i/status/1518309187515781125', 'allSameType': True, 'hasMedia': False, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'date_epoch': 1673041063} testQRTTweet_compare={'text': "vxTwitter has gotten a *ton* of usage recently, so I'd appreciate a donation to keep things running!\nhttps://x.com/pdxdylan/status/1518309187515781125", 'date': 'Fri Jan 06 21:37:43 +0000 2023', 'tweetURL': 'https://twitter.com/pdxdylan/status/1611477137319514129', 'tweetID': '1611477137319514129', 'conversationID': '1611476665821003776', 'mediaURLs': [], 'media_extended': [], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': 'https://twitter.com/i/status/1518309187515781125', 'allSameType': True, 'hasMedia': False, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'lang': 'en', 'replyingTo': 'pdxdylan', 'replyingToID': '1611476665821003776', 'retweetURL': None, 'date_epoch': 1673041063}
testQrtCeptionTweet_compare={'text': 'Testing retweetception ', 'date': 'Tue Apr 07 01:32:26 +0000 2015', 'tweetURL': 'https://twitter.com/CatherineShu/status/585253766271672320', 'tweetID': '585253766271672320', 'conversationID': '585253766271672320', 'mediaURLs': [], 'media_extended': [], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': 'https://twitter.com/i/status/585253161260216320', 'allSameType': True, 'hasMedia': False, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'date_epoch': 1428370346} testQrtVideoTweet_compare={'text': 'good', 'date': 'Thu Jun 29 23:33:29 +0000 2023', 'tweetURL': 'https://twitter.com/pdxdylan/status/1674561759422578690', 'tweetID': '1674561759422578690', 'conversationID': '1674561759422578690', 'mediaURLs': [], 'media_extended': [], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': 'https://twitter.com/i/status/1674197531301904388', 'allSameType': True, 'hasMedia': False, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'lang': 'en', 'replyingTo': None, 'replyingToID': None, 'retweetURL': None, 'date_epoch': 1688081609}
testQrtVideoTweet_compare={'text': 'good', 'date': 'Thu Jun 29 23:33:29 +0000 2023', 'tweetURL': 'https://twitter.com/pdxdylan/status/1674561759422578690', 'tweetID': '1674561759422578690', 'conversationID': '1674561759422578690', 'mediaURLs': [], 'media_extended': [], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': 'https://twitter.com/i/status/1674197531301904388', 'allSameType': True, 'hasMedia': False, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'date_epoch': 1688081609} testNSFWTweet_compare={'text': "ngl, I'm scared on finding out the cute Sprigatito's final evolution..\n\nso i had a bot generate it for me.... and I'm forever scarred", 'date': 'Sat Oct 15 07:28:42 +0000 2022', 'tweetURL': 'https://twitter.com/kuyacoy/status/1581185279376838657', 'tweetID': '1581185279376838657', 'conversationID': '1581185279376838657', 'mediaURLs': ['https://pbs.twimg.com/media/FfF_gKwXgAIpnpD.jpg'], 'media_extended': [{'url': 'https://pbs.twimg.com/media/FfF_gKwXgAIpnpD.jpg', 'altText': None, 'type': 'image', 'size': {'width': 760, 'height': 926}, 'thumbnail_url': 'https://pbs.twimg.com/media/FfF_gKwXgAIpnpD.jpg', 'id_str': '1581185134803517442'}], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': True, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'lang': 'en', 'replyingTo': None, 'replyingToID': None, 'retweetURL': None, 'date_epoch': 1665818922}
testNSFWTweet_compare={'text': "ngl, I'm scared on finding out the cute Sprigatito's final evolution..\n\nso i had a bot generate it for me.... and I'm forever scarred https://t.co/itMay87vcS", 'date': 'Sat Oct 15 07:28:42 +0000 2022', 'tweetURL': 'https://twitter.com/kuyacoy/status/1581185279376838657', 'tweetID': '1581185279376838657', 'conversationID': '1581185279376838657', 'mediaURLs': ['https://pbs.twimg.com/media/FfF_gKwXgAIpnpD.jpg'], 'media_extended': [{'url': 'https://pbs.twimg.com/media/FfF_gKwXgAIpnpD.jpg', 'altText': None, 'type': 'image', 'size': {'width': 760, 'height': 926}, 'thumbnail_url': 'https://pbs.twimg.com/media/FfF_gKwXgAIpnpD.jpg'}], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': True, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'date_epoch': 1665818922} testPollTweet_compare={'text': 'I know when that hotline bling, that can only:', 'date': 'Mon Oct 05 22:57:25 +0000 2015', 'tweetURL': 'https://twitter.com/norm/status/651169346518056960', 'tweetID': '651169346518056960', 'conversationID': '651169346518056960', 'mediaURLs': [], 'media_extended': [], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': False, 'combinedMediaUrl': None, 'pollData': {'options': [{'name': 'Mean one thing', 'votes': 124875, 'percent': 78.82}, {'name': 'Mean multiple things', 'votes': 33554, 'percent': 21.18}]}, 'article': None, 'lang': 'en', 'replyingTo': None, 'replyingToID': None, 'retweetURL': None, 'date_epoch': 1444085845}
testPollTweet_compare={'text': 'I know when that hotline bling, that can only:', 'date': 'Mon Oct 05 22:57:25 +0000 2015', 'tweetURL': 'https://twitter.com/norm/status/651169346518056960', 'tweetID': '651169346518056960', 'conversationID': '651169346518056960', 'mediaURLs': [], 'media_extended': [], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': False, 'combinedMediaUrl': None, 'pollData': {'options': [{'name': 'Mean one thing', 'votes': 124875, 'percent': 78.82}, {'name': 'Mean multiple things', 'votes': 33554, 'percent': 21.18}]}, 'article': None, 'date_epoch': 1444085845} testMixedMediaTweet_compare={'text': 'Some of us here are definitely big nerds about beer, and could talk your ear off about it for days on end, but some of us are just "beer is nice"', 'date': 'Thu Feb 22 12:13:24 +0000 2024', 'tweetURL': 'https://twitter.com/salebeerfest/status/1760638922084741177', 'tweetID': '1760638922084741177', 'conversationID': '1760638922084741177', 'mediaURLs': ['https://pbs.twimg.com/media/GG8LwfuWoAANKhs.jpg', 'https://video.twimg.com/tweet_video/GG8LwqWX0AAZch0.mp4'], 'media_extended': [{'url': 'https://pbs.twimg.com/media/GG8LwfuWoAANKhs.jpg', 'altText': None, 'type': 'image', 'size': {'width': 858, 'height': 960}, 'thumbnail_url': 'https://pbs.twimg.com/media/GG8LwfuWoAANKhs.jpg', 'id_str': '1760638907102699520'}, {'url': 'https://video.twimg.com/tweet_video/GG8LwqWX0AAZch0.mp4', 'type': 'gif', 'size': {'width': 500, 'height': 500}, 'duration_millis': 0, 'thumbnail_url': 'https://pbs.twimg.com/tweet_video_thumb/GG8LwqWX0AAZch0.jpg', 'altText': None, 'id_str': '1760638909954904064'}], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': False, 'hasMedia': True, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'lang': 'en', 'replyingTo': None, 'replyingToID': None, 'retweetURL': None, 'date_epoch': 1708604004}
testMixedMediaTweet_compare={'text': 'Some of us here are definitely big nerds about beer, and could talk your ear off about it for days on end, but some of us are just "beer is nice"', 'date': 'Thu Feb 22 12:13:24 +0000 2024', 'tweetURL': 'https://twitter.com/salebeerfest/status/1760638922084741177', 'tweetID': '1760638922084741177', 'conversationID': '1760638922084741177', 'mediaURLs': ['https://pbs.twimg.com/media/GG8LwfuWoAANKhs.jpg', 'https://video.twimg.com/tweet_video/GG8LwqWX0AAZch0.mp4'], 'media_extended': [{'url': 'https://pbs.twimg.com/media/GG8LwfuWoAANKhs.jpg', 'altText': None, 'type': 'image', 'size': {'width': 858, 'height': 960}, 'thumbnail_url': 'https://pbs.twimg.com/media/GG8LwfuWoAANKhs.jpg'}, {'url': 'https://video.twimg.com/tweet_video/GG8LwqWX0AAZch0.mp4', 'type': 'gif', 'size': {'width': 500, 'height': 500}, 'duration_millis': 0, 'thumbnail_url': 'https://pbs.twimg.com/tweet_video_thumb/GG8LwqWX0AAZch0.jpg', 'altText': None}], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': False, 'hasMedia': True, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'date_epoch': 1708604004} testVinePlayerTweet_compare={'text': 'You wanted old ROBLOX back, you got it. Check out our sweet "new" look! #BringBackOldROBLOX https://vine.co/v/OL9VqvM6wJh', 'date': 'Wed Apr 01 16:17:13 +0000 2015', 'tweetURL': 'https://twitter.com/Roblox/status/583302104342638592', 'tweetID': '583302104342638592', 'conversationID': '583302104342638592', 'mediaURLs': ['https://v.cdn.vine.co/r/videos/20A1BE53011195086166081318912_3fe3b526b1a.1.5.3156516531034157495.mp4?versionId=DI1mMu7EI6zcLbvgucyp3GHebdz8.9cQ'], 'media_extended': [{'url': 'https://v.cdn.vine.co/r/videos/20A1BE53011195086166081318912_3fe3b526b1a.1.5.3156516531034157495.mp4?versionId=DI1mMu7EI6zcLbvgucyp3GHebdz8.9cQ', 'type': 'video', 'size': {'width': 435, 'height': 435}}], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': True, 'combinedMediaUrl': None, 'pollData': {'options': []}, 'article': None, 'lang': 'en', 'replyingTo': None, 'replyingToID': None, 'retweetURL': None, 'date_epoch': 1427905033}
testVinePlayerTweet_compare={'text': 'You wanted old ROBLOX back, you got it. Check out our sweet "new" look! #BringBackOldROBLOX https://vine.co/v/OL9VqvM6wJh', 'date': 'Wed Apr 01 16:17:13 +0000 2015', 'tweetURL': 'https://twitter.com/Roblox/status/583302104342638592', 'tweetID': '583302104342638592', 'conversationID': '583302104342638592', 'mediaURLs': ['https://v.cdn.vine.co/r/videos/20A1BE53011195086166081318912_3fe3b526b1a.1.5.3156516531034157495.mp4?versionId=DI1mMu7EI6zcLbvgucyp3GHebdz8.9cQ'], 'media_extended': [{'url': 'https://v.cdn.vine.co/r/videos/20A1BE53011195086166081318912_3fe3b526b1a.1.5.3156516531034157495.mp4?versionId=DI1mMu7EI6zcLbvgucyp3GHebdz8.9cQ', 'type': 'video', 'size': {'width': 435, 'height': 435}}], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': True, 'combinedMediaUrl': None, 'pollData': {'options': []}, 'article': None, 'date_epoch': 1427905033} testRetweetTweet_compare={'text': 'RT @pdxdylan: If you want to try this out, on your mobile device, head over to https://vxtwitter.com/preferences and enable "Open links in app". Hope…', 'date': 'Tue Aug 27 23:09:07 +0000 2024', 'tweetURL': 'https://twitter.com/pdxdylan/status/1828570470222045294', 'tweetID': '1828570470222045294', 'conversationID': '1828570470222045294', 'mediaURLs': [], 'media_extended': [], 'possibly_sensitive': False, 'hashtags': [], 'qrtURL': None, 'allSameType': True, 'hasMedia': False, 'combinedMediaUrl': None, 'pollData': None, 'article': None, 'lang': 'en', 'replyingTo': None, 'replyingToID': None, 'retweetURL': 'https://twitter.com/i/status/1828569456231993456', 'date_epoch': 1724800147}
testUser="https://twitter.com/jack" testUser="https://twitter.com/jack"
testUserSuspended="https://twitter.com/twitter"
testUserPrivate="https://twitter.com/PrestigeIsKey"
testUserInvalid="https://twitter.com/.a"
testUserID=12 # could also be 170824883 testUserID=12 # could also be 170824883
testUserIDUrl = "https://twitter.com/i/user/"+str(testUserID) testUserIDUrl = "https://twitter.com/i/user/"+str(testUserID)
testUserWeirdURLs=["https://twitter.com/jack?lang=en","https://twitter.com/jack/with_replies","https://twitter.com/jack/media","https://twitter.com/jack/likes","https://twitter.com/jack/with_replies?lang=en","https://twitter.com/jack/media?lang=en","https://twitter.com/jack/likes?lang=en","https://twitter.com/jack/"] testUserWeirdURLs=["https://twitter.com/jack?lang=en","https://twitter.com/jack/with_replies","https://twitter.com/jack/media","https://twitter.com/jack/likes","https://twitter.com/jack/with_replies?lang=en","https://twitter.com/jack/media?lang=en","https://twitter.com/jack/likes?lang=en","https://twitter.com/jack/"]