Compare commits

...

65 Commits

Author SHA1 Message Date
80a12a4b63 Merge pull request 'November update' (#2) from github/origin into alterware
Some checks failed
Run Tests / build (push) Failing after 1m13s
Reviewed-on: #2
2025-11-10 07:43:07 +00:00
23d0564129 Merge branch 'main' of https://github.com/dylanpdx/BetterTwitFix into github/origin
Some checks failed
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
f7dddc42c1 Merge pull request 'chore: update my branch' (#1) from update/alterware into alterware
Some checks failed
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
Some checks failed
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
656bcd40b9 build: test CI here
Some checks failed
Run Tests / build (push) Failing after 1m28s
2025-05-20 22:05:26 +02:00
alterware
061e7fb96e alterware: our changes
Some checks failed
Run Tests / build (3.10) (push) Failing after 1m8s
Run Tests / build (3.11) (push) Failing after 9s
Run Tests / build (3.12) (push) Failing after 8s
2025-05-20 19:37:44 +00: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
22828a1fc8 fix DB cache 2025-04-22 22:04:17 +02:00
Dylan
84a19e9baa Add old.vxtwitter as a valid subdomain for legacy embeds 2025-04-16 15:10:43 +01:00
Dylan
68717be147 Add temp fix for legacy embeds 2025-04-16 14:34:58 +01:00
Dylan
740a300d1e Fix several small bugs & dead code 2025-03-31 21:33:49 +01:00
Dylan
dde6f889cb Fix qrt issue 2025-03-31 21:14:21 +01:00
Dylan
24812acd32 Refactor more; fix Activity url for videos 2025-03-31 20:29:43 +01:00
Dylan
9452e7be8c Update tests 2025-03-31 01:48:12 +01:00
Dylan
a851fe587b Misc refactoring; Icons based on media type 2025-03-31 01:36:23 +01:00
Dylan
6aad6aee7f Use new embed format for easier readability 2025-03-31 01:11:03 +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] <support@github.com>
2025-01-27 01:24:25 +00:00
24 changed files with 801 additions and 300 deletions

View File

@@ -6,23 +6,30 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: test environment: test
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps: steps:
- uses: actions/checkout@v3 - name: Check out files
- name: Set up Python ${{ matrix.python-version }} uses: actions/checkout@v4
uses: actions/setup-python@v4
with: - name: Install Python
python-version: ${{ matrix.python-version }} run: |
apt-get update
apt-get install -y python3 python3-pip python3-venv
- name: Set up virtual environment
run: |
python3 -m venv venv
. venv/bin/activate
python -m pip install --upgrade pip
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip . venv/bin/activate
pip install pytest pytest-cov pytest-env pip install pytest pytest-cov pytest-env
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Test with pytest - name: Test with pytest
env: env:
VXTWITTER_WORKAROUND_TOKENS: ${{ secrets.VXTWITTER_WORKAROUND_TOKENS }} VXTWITTER_WORKAROUND_TOKENS: ${{ secrets.VXTWITTER_WORKAROUND_TOKENS }}
run: | run: |
. venv/bin/activate
pytest --cov-config=.coveragerc --cov=. pytest --cov-config=.coveragerc --cov=.

79
activity.py Normal file
View File

@@ -0,0 +1,79 @@
import datetime
import msgs
from utils import determineEmbedTweet, determineMediaToEmbed
from copy import deepcopy
def tweetDataToActivity(tweetData,embedIndex = -1):
content=""
if tweetData['replyingTo'] is not None:
content += f"<blockquote>↪️ <i>Replying to @{tweetData['replyingTo']}</i></blockquote>"
content+=f"<p>{tweetData['text']}</p>"
attachments=[]
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>"
if tweetData['pollData'] is not None:
content += f"<p>{msgs.genPollDisplay(tweetData['pollData'])}</p>"
content += "</p>"
content = content.replace("\n","<br>")
#if media is not None:
# attachments.append({"type":mediatype,"url":media})
likes = tweetData['likes']
retweets = tweetData['retweets']
# convert date epoch to iso format
date = tweetData['date_epoch']
date = datetime.datetime.fromtimestamp(date).isoformat() + "Z"
embedTweetData = determineEmbedTweet(tweetData)
embeddingMedia = embedTweetData['hasMedia']
media = None
if embeddingMedia:
media = determineMediaToEmbed(embedTweetData,embedIndex)
if media is not None:
media = deepcopy(media)
if media['type'] == "gif":
media['type'] = "gifv"
if 'thumbnail_url' not in media:
media['thumbnail_url'] = media['url']
if media['type'] == "image" and "?" not in media['url']:
media['url'] += "?name=orig"
attachments.append({
"id": "114163769487684704",
"type": media['type'],
"url": media['url'],
"preview_url": media['thumbnail_url'],
})
# https://docs.joinmastodon.org/methods/statuses/
return {
"id": tweetData['tweetID'],
"url": f"https://x.com/{tweetData['user_screen_name']}/status/{tweetData['tweetID']}",
"uri": f"https://x.com/{tweetData['user_screen_name']}/status/{tweetData['tweetID']}",
"created_at": date,
"edited_at": None,
"reblog": None,
"in_reply_to_account_id": None,
"language": "en",
"content": content,
"spoiler_text": "",
"visibility": "public",
"application": {
"website": None
},
"media_attachments": attachments,
"account": {
"display_name": tweetData['user_name'],
"username": tweetData['user_screen_name'],
"acct": tweetData['user_screen_name'],
"url": f"https://x.com/{tweetData['user_screen_name']}/status/{tweetData['tweetID']}",
"uri": f"https://x.com/{tweetData['user_screen_name']}/status/{tweetData['tweetID']}",
"locked": False,
"avatar": tweetData['user_profile_image_url'],
"avatar_static": tweetData['user_profile_image_url'],
"hide_collections": False,
"noindex": False,
},
}

View File

@@ -49,7 +49,9 @@ def addVnfToTweetIdCache(tweet_id, vnf):
global link_cache global link_cache
try: try:
if link_cache_system == "db": if link_cache_system == "db":
out = db.linkCache.update_one(vnf) filter_query = {'tweet': tweet_id}
update_operation = {'$set': vnf}
out = db.linkCache.update_one(filter_query, update_operation, upsert=True)
log.debug("Link added to DB cache ") log.debug("Link added to DB cache ")
return True return True
elif link_cache_system == "json": elif link_cache_system == "json":
@@ -86,7 +88,7 @@ def getVnfFromTweetIdCache(tweet_id):
collection = db.linkCache collection = db.linkCache
vnf = collection.find_one({'tweet': tweet_id}) vnf = collection.find_one({'tweet': tweet_id})
if vnf != None: if vnf != None:
hits = ( vnf['hits'] + 1 ) hits = ( vnf.get('hits', 0) + 1 )
log.debug("Link located in DB cache.") log.debug("Link located in DB cache.")
query = { 'tweet': tweet_id } query = { 'tweet': tweet_id }
change = { "$set" : { "hits" : hits } } change = { "$set" : { "hits" : hits } }

View File

@@ -2,14 +2,14 @@
"config": { "config": {
"appname": "vxTwitter", "appname": "vxTwitter",
"color": "#1DA1F2", "color": "#1DA1F2",
"database": "[url to mongo database goes here]", "database": "mongodb://localhost:27017/bettervxtwitter",
"link_cache": "ram", "link_cache": "db",
"method": "hybrid", "method": "hybrid",
"repo": "https://github.com/dylanpdx/BetterTwitFix", "repo": "https://git.alterware.dev/alterware/BetterTwitFix",
"table": "[database table here]", "table": "links",
"url": "https://vxtwitter.com", "url": "https://girlcock.alterware.dev",
"combination_method": "local", "combination_method": "local",
"gifConvertAPI": "local", "gifConvertAPI": "local",
"workaroundTokens":null "workaroundTokens": null
} }
} }

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,4 @@ 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==0.0.2

View File

@@ -47,7 +47,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

BIN
static/richEmbed/gif.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
static/richEmbed/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
static/richEmbed/text.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
static/richEmbed/video.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

View File

@@ -2,7 +2,21 @@
<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 %}
<link type="application/activity+json" href="{{ activityLink|safe }}" />
{% endif %}
{% if sicon %}
<!-- Video icon created by Rizki Ahmad Fauzi - Flaticon -->
<!-- Picture and Text icon created by Freepik - Flaticon -->
<!-- Gif file icons created by Grand Iconic - Flaticon -->
<link rel="apple-touch-icon" sizes="128x128" href="/{{ sicon }}.png">
{% else %}
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
{% endif %}
<script src="/openInApp.js"></script> <script src="/openInApp.js"></script>
<script>document.addEventListener('DOMContentLoaded',function(){openTweet('{{ tweet["tweetID"] }}')})</script> <script>document.addEventListener('DOMContentLoaded',function(){openTweet('{{ tweet["tweetID"] }}')})</script>
<style> <style>

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'] }}" />

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

View File

@@ -194,3 +194,14 @@ def test_embed_rtf():
resp = client.get(testTextTweet.replace("https://twitter.com","")+".rtf",headers={"User-Agent":"test"}) resp = client.get(testTextTweet.replace("https://twitter.com","")+".rtf",headers={"User-Agent":"test"})
assert resp.status_code==200 assert resp.status_code==200
assert testTextTweet_compare["text"] in str(resp.data) assert testTextTweet_compare["text"] in str(resp.data)
def test_embed_action():
cache.clearCache()
resp = client.get(testTextTweet.replace("https://twitter.com",""),headers={"User-Agent":"test"})
assert resp.status_code==200
assert "application/activity+json" in str(resp.data)
assert "%F0%9F%92%96" in str(resp.data) # 💖
resp = client.get(testTextTweet.replace("https://twitter.com",""),headers={"User-Agent":"Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"})
assert resp.status_code==200
assert "application/activity+json" in str(resp.data)
assert "%F0%9F%92%96" not in str(resp.data) # 💖

View File

@@ -3,19 +3,20 @@ 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]
@@ -32,30 +33,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 +100,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

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)

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

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:138.0) Gecko/20100101 Firefox/138.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":false,"responsive_web_jetfuel_frame":true,"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,"payments_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"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="wqi5M7wZ7tW-X9S2t-Mqcg"
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,"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,"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="k3rtLsS9kG5hI-Jr0dTMCg"
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_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}'
tweetDetailGraphql_api="e7RKseIxLu7HgkWNKZ6qnw" tweetDetailGraphql_api="YVyS4SfwYW7Uw5qwy0mQCA"
# 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
@@ -75,7 +115,7 @@ def cycleBearerTokenGet(url,headers):
def twitterApiGet(url,btoken=None,authToken=None,guestToken=None): def twitterApiGet(url,btoken=None,authToken=None,guestToken=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]
@@ -91,7 +131,8 @@ def twitterApiGet(url,btoken=None,authToken=None,guestToken=None):
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
#return cycleBearerTokenGet(url,getAuthHeaders(bearer,authToken=authToken,guestToken=guestToken))
headers = getAuthHeaders(btoken,authToken=authToken,guestToken=guestToken) headers = getAuthHeaders(btoken,authToken=authToken,guestToken=guestToken)
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
@@ -99,7 +140,7 @@ def twitterApiGet(url,btoken=None,authToken=None,guestToken=None):
def getAuthHeaders(btoken,authToken=None,guestToken=None): def getAuthHeaders(btoken,authToken=None,guestToken=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:
@@ -114,7 +155,7 @@ 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.get(f"https://{twitterUrl}",headers={"User-Agent":requestUserAgent,"Cookie":"night_mode=2"},allow_redirects=False)
m = re.search(gt_pattern, r.text) m = re.search(gt_pattern, r.text)
if m is None: if m is None:
r = requests.post(f"https://api.{twitterUrl}/1.1/guest/activate.json", headers={"Authorization":bearer}) r = requests.post(f"https://api.{twitterUrl}/1.1/guest/activate.json", headers={"Authorization":bearer})
@@ -235,8 +276,7 @@ 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):
try:
vars = json.loads('{"includeTweetImpression":true,"includeHasBirdwatchNotes":false,"includeEditPerspective":false,"rest_ids":["x"],"includeEditControl":true,"includeCommunityTweetRelationship":true,"includeTweetVisibilityNudge":true}') vars = json.loads('{"includeTweetImpression":true,"includeHasBirdwatchNotes":false,"includeEditPerspective":false,"rest_ids":["x"],"includeEditControl":true,"includeCommunityTweetRelationship":true,"includeTweetVisibilityNudge":true}')
vars['rest_ids'][0] = str(twid) 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) 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)
@@ -248,13 +288,13 @@ def extractStatusV2(url,workaroundTokens):
if tweet.status_code == 429: if tweet.status_code == 429:
print("Rate limit reached for token (429)") print("Rate limit reached for token (429)")
# 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']['tweet_results'] entries=output['data']['tweet_results']
tweetEntry=None tweetEntry=None
for entry in entries: for entry in entries:
@@ -275,11 +315,8 @@ def extractStatusV2(url,workaroundTokens):
if tweet is None: if tweet is None:
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:
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):
# get tweet ID # get tweet ID
@@ -289,15 +326,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)
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 +341,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,11 +371,11 @@ 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):
# get tweet ID # get tweet ID
@@ -349,12 +388,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)
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 +401,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 +432,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
return tweet
raise TwExtractError(400, "Extract error") raise TwExtractError(400, "Extract error")
def extractStatusV2Anon(url,x): return tweet
return parallel_token_request(twid, tokens, request_with_token)
def extractStatusV2Rest_Anon(url,workaroundTokens):
return extractStatusV2Rest(url,None)
def extractStatusV2Rest(url,workaroundTokens):
# get tweet ID # get tweet ID
m = re.search(pathregex, url) m = re.search(pathregex, url)
if m is None: if m is None:
@@ -408,7 +453,17 @@ def extractStatusV2Anon(url,x):
try: try:
vars = json.loads('{"tweetId":"0","withCommunity":false,"includePromotedContent":false,"withVoice":false}') vars = json.loads('{"tweetId":"0","withCommunity":false,"includePromotedContent":false,"withVoice":false}')
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)
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)
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}")
@@ -455,7 +510,8 @@ def fixTweetData(tweet):
return tweet return tweet
def extractStatus(url,workaroundTokens=None): def extractStatus(url,workaroundTokens=None):
methods=[extractStatusV2Anon,extractStatusV2TweetDetail,extractStatusV2Android,extractStatusV2] # TODO: commented out methods are too slow/unreliable at the moment
methods=[extractStatusV2Rest_Anon,extractStatusV2,extractStatusV2Rest,extractStatusV2Android]#,extractStatusV2TweetDetail]
for method in methods: for method in methods:
try: try:
result = method(url,workaroundTokens) result = method(url,workaroundTokens)
@@ -486,19 +542,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 +575,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 {

View File

@@ -2,6 +2,9 @@ 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 handle_x_migration
import requests
digits = "0123456789abcdefghijklmnopqrstuvwxyz" digits = "0123456789abcdefghijklmnopqrstuvwxyz"
def baseConversion(x, base): def baseConversion(x, base):
@@ -31,5 +34,21 @@ 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:
ct = ClientTransaction(get_twitter_homepage(headers=headers))
transaction_id = ct.generate_transaction_id(method=method, path=path)
return transaction_id

View File

@@ -1,5 +1,4 @@
from weakref import finalize from flask import Flask, render_template, request, redirect, abort, Response, send_from_directory, send_file
from flask import Flask, render_template, request, redirect, abort, Response, send_from_directory, url_for, send_file, make_response, jsonify
from configHandler import config from configHandler import config
remoteCombine='combination_method' in config['config'] and config['config']['combination_method'] != "local" remoteCombine='combination_method' in config['config'] and config['config']['combination_method'] != "local"
@@ -15,14 +14,16 @@ 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 from utils import getTweetIdFromUrl, pathregex, determineMediaToEmbed, determineEmbedTweet, BytesIOWrapper, fixMedia
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
from PyRTF.document.section import Section from PyRTF.document.section import Section
from PyRTF.document.paragraph import Paragraph from PyRTF.document.paragraph import Paragraph
from utils import BytesIOWrapper
from copy import deepcopy from copy import deepcopy
import json
import datetime
import activity as activitymg
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
user_agent="" user_agent=""
@@ -34,6 +35,10 @@ staticFiles = { # TODO: Use flask static files instead of this
"preferences": {"mime": "text/html","path": "preferences.html"}, "preferences": {"mime": "text/html","path": "preferences.html"},
"style.css": {"mime": "text/css","path": "style.css"}, "style.css": {"mime": "text/css","path": "style.css"},
"Roboto-Regular.ttf": {"mime": "font/ttf","path": "Roboto-Regular.ttf"}, "Roboto-Regular.ttf": {"mime": "font/ttf","path": "Roboto-Regular.ttf"},
"gif.png": {"mime": "image/png","path": "richEmbed/gif.png"},
"video.png": {"mime": "image/png","path": "richEmbed/video.png"},
"image.png": {"mime": "image/png","path": "richEmbed/image.png"},
"text.png": {"mime": "image/png","path": "richEmbed/text.png"},
} }
generate_embed_user_agents = [ generate_embed_user_agents = [
@@ -61,23 +66,34 @@ def isValidUserAgent(user_agent):
return True return True
return False return False
def fixMedia(mediaInfo):
# This is for the iOS Discord app, which has issues when serving URLs ending in .mp4 (https://github.com/dylanpdx/BetterTwitFix/issues/210)
if 'video.twimg.com' not in mediaInfo['url'] or 'convert?url=' in mediaInfo['url']:
return mediaInfo
mediaInfo['url'] = mediaInfo['url'].replace("https://video.twimg.com",f"{config['config']['url']}/tvid").replace(".mp4","")
return mediaInfo
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 renderImageTweetEmbed(tweetData,image,appnameSuffix=""): def generateActivityLink(tweetData,media=None,mediatype=None,embedIndex=-1):
global user_agent
if 'LegacyEmbed' in user_agent: # TODO: Clean up; This is a hacky fix to make the new activity embed not trigger
return None
try:
embedIndex = embedIndex+1
return f"{config['config']['url']}/users/{tweetData['user_screen_name']}/statuses/{str(embedIndex)}{tweetData['tweetID']}"
except Exception as e:
log.error("Error generating activity link: "+str(e))
return None
def getAppName(tweetData,appnameSuffix=""):
appName = config['config']['appname']+appnameSuffix
if 'Discord' not in user_agent:
appName = msgs.formatProvider(config['config']['appname']+appnameSuffix,tweetData)
return appName
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'])
@@ -91,15 +107,22 @@ def renderImageTweetEmbed(tweetData,image,appnameSuffix=""):
desc=embedDesc, desc=embedDesc,
urlEncodedDesc=urllib.parse.quote(embedDesc), urlEncodedDesc=urllib.parse.quote(embedDesc),
tweetLink=f'https://twitter.com/{tweetData["user_screen_name"]}/status/{tweetData["tweetID"]}', tweetLink=f'https://twitter.com/{tweetData["user_screen_name"]}/status/{tweetData["tweetID"]}',
appname=msgs.formatProvider(config['config']['appname']+appnameSuffix,tweetData), appname=getAppName(tweetData,appnameSuffix),
color=config['config']['color'] color=config['config']['color'],
sicon="image",
activityLink=generateActivityLink(tweetData,image,"image/png",embedIndex)
) )
def renderVideoTweetEmbed(tweetData,mediaInfo,appnameSuffix=""): 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'])
mediaInfo=fixMedia(mediaInfo) mediaInfo=fixMedia(mediaInfo)
appName = config['config']['appname']+appnameSuffix
if 'Discord' not in user_agent:
appName = msgs.formatProvider(config['config']['appname']+appnameSuffix,tweetData)
return render_template("video.html", return render_template("video.html",
tweet=tweetData, tweet=tweetData,
media=mediaInfo, media=mediaInfo,
@@ -107,26 +130,32 @@ def renderVideoTweetEmbed(tweetData,mediaInfo,appnameSuffix=""):
desc=embedDesc, desc=embedDesc,
urlEncodedDesc=urllib.parse.quote(embedDesc), urlEncodedDesc=urllib.parse.quote(embedDesc),
tweetLink=f'https://twitter.com/{tweetData["user_screen_name"]}/status/{tweetData["tweetID"]}', tweetLink=f'https://twitter.com/{tweetData["user_screen_name"]}/status/{tweetData["tweetID"]}',
appname=msgs.formatProvider(config['config']['appname']+appnameSuffix,tweetData), appname=appName,
color=config['config']['color'] color=config['config']['color'],
sicon="video",
activityLink=generateActivityLink(tweetData,mediaInfo['url'],"video/mp4",embedIndex)
) )
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'])
return render_template("text.html", return render_template("text.html",
tweet=tweetData, tweet=tweetData,
host=config['config']['url'], host=config['config']['url'],
desc=embedDesc, desc=embedDesc,
urlEncodedDesc=urllib.parse.quote(embedDesc), urlEncodedDesc=urllib.parse.quote(embedDesc),
tweetLink=f'https://twitter.com/{tweetData["user_screen_name"]}/status/{tweetData["tweetID"]}', tweetLink=f'https://twitter.com/{tweetData["user_screen_name"]}/status/{tweetData["tweetID"]}',
appname=msgs.formatProvider(config['config']['appname']+appnameSuffix,tweetData), appname=getAppName(tweetData,appnameSuffix),
color=config['config']['color'] color=config['config']['color'],
activityLink=generateActivityLink(tweetData),
sicon="text"
) )
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)
return render_template("image.html", return render_template("image.html",
tweet=tweetData, tweet=tweetData,
pic=[tweetData['article']["image"]], pic=[tweetData['article']["image"]],
@@ -134,8 +163,9 @@ def renderArticleTweetEmbed(tweetData,appnameSuffix=""):
desc=embedDesc, desc=embedDesc,
urlEncodedDesc=urllib.parse.quote(embedDesc), urlEncodedDesc=urllib.parse.quote(embedDesc),
tweetLink=f'https://twitter.com/{tweetData["user_screen_name"]}/status/{tweetData["tweetID"]}', tweetLink=f'https://twitter.com/{tweetData["user_screen_name"]}/status/{tweetData["tweetID"]}',
appname=msgs.formatProvider(config['config']['appname']+appnameSuffix,tweetData), appname=getAppName(tweetData,appnameSuffix),
color=config['config']['color'] color=config['config']['color'],
sicon="image"
) )
def renderUserEmbed(userData,appnameSuffix=""): def renderUserEmbed(userData,appnameSuffix=""):
@@ -166,22 +196,80 @@ def oembedend():
provName = request.args.get("provider",None) provName = request.args.get("provider",None)
return oEmbedGen(desc, user, link, ttype,providerName=provName) return oEmbedGen(desc, user, link, ttype,providerName=provName)
@app.route('/activity.json')
def activity():
tweetId = request.args.get("id", None)
publishedDate = request.args.get("published", None)
likes = request.args.get("likes", None)
retweets = request.args.get("retweets", None)
userAttrTo = request.args.get("user", None)
content = request.args.get("content", None)
attachments = json.loads(request.args.get("attachments", "[]"))
##
attachmentsRaw = []
for attachment in attachments:
attachmentsRaw.append({
"type": "Document",
"mediaType": attachment["type"],
"url": attachment["url"],
"preview_url": "https://pbs.twimg.com/ext_tw_video_thumb/1906073839441735680/pu/img/2xqg6tlK9mK0mSOR.jpg",
})
return {
"id": "https://x.com/i/status/"+tweetId,
"type": "Note",
"summary": None,
"inReplyTo": None,
"published": publishedDate,
"url": "https://x.com/i/status/"+tweetId,
"attributedTo": userAttrTo,
"content": content,
"attachment": attachmentsRaw,
"likes": {
"type": "Collection",
"totalItems": int(likes)
},
"shares": {
"type": "Collection",
"totalItems": int(retweets)
},
}
@app.route('/user.json')
def userJson():
screen_name = request.args.get("screen_name", None)
name = request.args.get("name", None)
pfp = request.args.get("pfp", None)
return {
"id": screen_name,
"type": "Person",
"preferredUsername": screen_name,
"name": name,
"summary": "",
"url": "https://x.com/"+screen_name,
"tag": [],
"attachment": [],
"icon": {
"type": "Image",
"mediaType": "image/jpeg",
"url": pfp
},
}
def getTweetData(twitter_url,include_txt="false",include_rtf="false"): def getTweetData(twitter_url,include_txt="false",include_rtf="false"):
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":
return cachedVNF return cachedVNF
try:
rawTweetData = twExtract.extractStatusV2Anon(twitter_url, None)
except:
rawTweetData = None
if rawTweetData is None:
try: try:
if config['config']['workaroundTokens'] is not None: if config['config']['workaroundTokens'] is not None:
workaroundTokens = config['config']['workaroundTokens'].split(",") workaroundTokens = config['config']['workaroundTokens'].split(",")
else: else:
workaroundTokens = None workaroundTokens = None
rawTweetData = twExtract.extractStatus(twitter_url,workaroundTokens=workaroundTokens) rawTweetData = twExtract.extractStatus(twitter_url,workaroundTokens=workaroundTokens)
except: except:
rawTweetData = None rawTweetData = None
@@ -197,27 +285,36 @@ def getTweetData(twitter_url,include_txt="false",include_rtf="false"):
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)
return userData
def determineEmbedTweet(tweetData): if includeFeed:
# Determine which tweet, i.e main or QRT, to embed the media from. if userData['protected']:
# if there is no QRT, return the main tweet => default behavior userData['latest_tweets']=[]
# if both don't have media, return the main tweet => embedding qrt text will be handled in the embed description else:
# if both have media, return the main tweet => priority is given to the main tweet's media feed = twExtract.extractUserFeedFromId(userData['id'],workaroundTokens=workaroundTokens)
# if only the QRT has media, return the QRT => show the QRT's media, not the main tweet's apiFeed = []
# if only the main tweet has media, return the main tweet => show the main tweet's media, embedding QRT text will be handled in the embed description for tweet in feed:
if tweetData['qrt'] is None: apiFeed.append(getApiResponse(tweet))
return tweetData userData['latest_tweets'] = apiFeed
if tweetData['qrt']['hasMedia'] and not tweetData['hasMedia']:
return tweetData['qrt'] return userData
return tweetData
@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):
global user_agent
user_agent = request.headers.get('User-Agent', None)
if user_agent is None:
user_agent = "unknown"
isApiRequest=request.url.startswith("https://api.vx") or request.url.startswith("http://api.vx") isApiRequest=request.url.startswith("https://api.vx") or request.url.startswith("http://api.vx")
if not isApiRequest and (request.url.startswith("https://l.vx") or request.url.startswith("https://old.vx")) 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
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:
staticFiles[sub_path]["path"] = sub_path staticFiles[sub_path]["path"] = sub_path
@@ -235,7 +332,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)
@@ -264,6 +369,12 @@ def twitfix(sub_path):
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'])
tweetData['qrt'] = qrt tweetData['qrt'] = qrt
retweet = None
if 'retweetURL' in tweetData and tweetData['retweetURL'] is not None:
retweet = getTweetData(tweetData['retweetURL'])
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:
@@ -300,48 +411,36 @@ def twitfix(sub_path):
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
return tweetData return tweetData
elif directEmbed: # direct embed elif directEmbed: # direct embed
embeddingMedia = tweetData['hasMedia']
renderMedia = None
if embeddingMedia:
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 tweetData['hasMedia'] and qrt is None: if not embeddingMedia and qrt is None:
return renderTextTweetEmbed(tweetData) return renderTextTweetEmbed(tweetData)
elif tweetData['allSameType'] and tweetData['media_extended'][0]['type'] == "image" and embedIndex == -1 and tweetData['combinedMediaUrl'] != None:
return render_template("rawimage.html",media={"url":tweetData['combinedMediaUrl']})
else: else:
# this means we have mixed media or video, and we're only going to embed one if renderMedia['type'] == "image":
if embedIndex == -1: # if the user didn't specify an index, we'll just use the first one return Response(render_template("rawimage.html",media=renderMedia),headers={"Cache-Tag": "embed"})
embedIndex = 0 elif renderMedia['type'] == "video" or renderMedia['type'] == "gif":
media = tweetData['media_extended'][embedIndex] return Response(render_template("rawvideo.html",media=renderMedia),headers={"Cache-Tag": "embed"})
media=fixMedia(media)
if media['type'] == "image":
return render_template("rawimage.html",media=media)
elif media['type'] == "video" or media['type'] == "gif":
return render_template("rawvideo.html",media=media)
else: # full embed else: # full embed
embedTweetData = determineEmbedTweet(tweetData) embedTweetData = determineEmbedTweet(tweetData)
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 embedTweetData['hasMedia']: elif not embeddingMedia:
return renderTextTweetEmbed(tweetData) return Response(renderTextTweetEmbed(tweetData),headers={"Cache-Tag": "embed"})
elif embedTweetData['allSameType'] and embedTweetData['media_extended'][0]['type'] == "image" and embedIndex == -1 and embedTweetData['combinedMediaUrl'] != None:
return renderImageTweetEmbed(tweetData,embedTweetData['combinedMediaUrl'],appnameSuffix=" - See original tweet for full quality")
else: else:
# this means we have mixed media or video, and we're only going to embed one media = determineMediaToEmbed(embedTweetData,embedIndex)
if embedIndex == -1: # if the user didn't specify an index, we'll just use the first one suffix=""
embedIndex = 0 if "suffix" in media:
media = embedTweetData['media_extended'][embedIndex] suffix = media["suffix"]
if len(embedTweetData["media_extended"]) > 1:
suffix = f' - Media {embedIndex+1}/{len(embedTweetData["media_extended"])}'
else:
suffix = ''
if media['type'] == "image": if media['type'] == "image":
return renderImageTweetEmbed(tweetData,media['url'] , appnameSuffix=suffix) 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" or media['type'] == "gif":
if media['type'] == "gif": return Response(renderVideoTweetEmbed(tweetData,media,appnameSuffix=suffix,embedIndex=embedIndex),headers={"Cache-Tag": "embed"})
if config['config']['gifConvertAPI'] != "" and config['config']['gifConvertAPI'] != "none":
vurl=media['originalUrl'] if 'originalUrl' in media else media['url']
media['url'] = config['config']['gifConvertAPI'] + "/convert?url=" + vurl
suffix += " - GIF"
return renderVideoTweetEmbed(tweetData,media,appnameSuffix=suffix)
return message(msgs.failedToScan) return message(msgs.failedToScan)
@@ -377,6 +476,29 @@ def rendercombined():
imgIo.seek(0) imgIo.seek(0)
return send_file(imgIo, mimetype='image/jpeg',max_age=86400) return send_file(imgIo, mimetype='image/jpeg',max_age=86400)
@app.route("/api/v1/statuses/<string:tweet_id>")
def api_v1_status(tweet_id):
embedIndex = int(tweet_id[0])-1
tweet_id = int(tweet_id[1:])
twitter_url=f"https://twitter.com/i/status/{tweet_id}"
tweetData = getTweetData(twitter_url)
if tweetData is None:
log.error("Tweet Data Get failed for "+twitter_url)
return message(msgs.failedToScan)
qrt = None
if 'qrtURL' in tweetData and tweetData['qrtURL'] is not None:
qrt = getTweetData(tweetData['qrtURL'])
tweetData['qrt'] = qrt
retweet = None
if 'retweetURL' in tweetData and tweetData['retweetURL'] is not None:
retweet = getTweetData(tweetData['retweetURL'])
tweetData['retweet'] = retweet
if tweetData is None:
abort(500) # this should cause Discord to fall back to the default embed
return activitymg.tweetDataToActivity(tweetData,embedIndex)
def oEmbedGen(description, user, video_link, ttype,providerName=None): def oEmbedGen(description, user, video_link, ttype,providerName=None):
if providerName == None: if providerName == None:
providerName = config['config']['appname'] providerName = config['config']['appname']
@@ -394,4 +516,4 @@ def oEmbedGen(description, user, video_link, ttype,providerName=None):
if __name__ == "__main__": if __name__ == "__main__":
app.config['SERVER_NAME']='localhost:80' app.config['SERVER_NAME']='localhost:80'
app.run(host='0.0.0.0') app.run(host='0.0.0.0', port=8080)

View File

@@ -3,11 +3,11 @@ Description=Init file for twitfix uwsgi instance
After=network.target After=network.target
[Service] [Service]
User=dylan User=carbonara
Group=dylan Group=carbonara
WorkingDirectory=/home/dylan/BetterTwitFix WorkingDirectory=/home/carbonara/twitter/BetterTwitFix
Environment="PATH=/home/dylan/BetterTwitFix/venv/bin" Environment="PATH=/home/carbonara/twitter/BetterTwitFix/venv/bin"
ExecStart=/home/dylan/BetterTwitFix/venv/bin/uwsgi --ini twitfix.ini ExecStart=/home/carbonara/twitter/BetterTwitFix/venv/bin/uwsgi --ini twitfix.ini
Restart=always Restart=always
RestartSec=3 RestartSec=3

View File

@@ -1,8 +1,9 @@
import re import re
import io import io
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)
@@ -41,3 +42,50 @@ class BytesIOWrapper(io.BufferedReader):
def peek(self, size=-1): def peek(self, size=-1):
return self._encoding_call('peek', size) return self._encoding_call('peek', size)
def fixMedia(mediaInfo):
# This is for the iOS Discord app, which has issues when serving URLs ending in .mp4 (https://github.com/dylanpdx/BetterTwitFix/issues/210)
if 'video.twimg.com' not in mediaInfo['url'] or 'convert?url=' in mediaInfo['url'] or 'originalUrl' in mediaInfo:
return mediaInfo
mediaInfo["originalUrl"] = mediaInfo['url']
mediaInfo['url'] = mediaInfo['url'].replace("https://video.twimg.com",f"{config['config']['url']}/tvid").replace(".mp4","")
return mediaInfo
def determineEmbedTweet(tweetData):
# Determine which tweet, i.e main or QRT, to embed the media from.
# if there is no QRT, return the main tweet => default behavior
# if both don't have media, return the main tweet => embedding qrt text will be handled in the embed description
# if both have media, return the main tweet => priority is given to the main tweet's media
# if only the QRT has media, return the QRT => show the QRT's media, not the main tweet's
# if only the main tweet has media, return the main tweet => show the main tweet's media, embedding QRT text will be handled in the embed description
if tweetData['qrt'] is None:
return tweetData
if tweetData['qrt']['hasMedia'] and not tweetData['hasMedia']:
return tweetData['qrt']
return tweetData
def determineMediaToEmbed(tweetData,embedIndex = -1,convertGif = True):
if tweetData['allSameType'] and tweetData['media_extended'][0]['type'] == "image" and embedIndex == -1 and tweetData['combinedMediaUrl'] != None:
return {"url":tweetData['combinedMediaUrl'],"type":"image"}
else:
# this means we have mixed media or video, and we're only going to embed one
if embedIndex == -1: # if the user didn't specify an index, we'll just use the first one
embedIndex = 0
media = tweetData['media_extended'][embedIndex]
media=fixMedia(media)
suffix=""
if len(tweetData["media_extended"]) > 1:
suffix = f' • Media {embedIndex+1}/{len(tweetData["media_extended"])}'
else:
suffix = ''
media["suffix"] = suffix
if media['type'] == "image":
return media
elif media['type'] == "video" or media['type'] == "gif":
if media['type'] == "gif" and convertGif:
if config['config']['gifConvertAPI'] != "" and config['config']['gifConvertAPI'] != "none":
vurl=media['originalUrl'] if 'originalUrl' in media else media['url']
media['url'] = config['config']['gifConvertAPI'] + "/convert?url=" + vurl
suffix += " • GIF"
media["suffix"] = suffix
return media

161
vxApi.py
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,14 @@ def getApiResponse(tweet,include_txt=False,include_rtf=False):
oldTweetVersion = False oldTweetVersion = False
tweetArticle=None tweetArticle=None
lang=None lang=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"]
#editedTweet=False #editedTweet=False
try: try:
if "birdwatch_pivot" in tweet: if "birdwatch_pivot" in tweet:
@@ -58,51 +112,30 @@ 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:
cardData = tweet["card" if "card" in tweet else "tweet_card"]
bindingValues = None
if 'binding_values' in cardData:
bindingValues = cardData['binding_values']
elif 'legacy' in cardData and 'binding_values' in cardData['legacy']:
bindingValues = cardData['legacy']['binding_values']
if bindingValues != None:
if 'name' in cardData and cardData['name'] == "player":
width = None width = None
height = None height = None
vidUrl = None vidUrl = None
for i in tweet['card']['binding_values']: for i in bindingValues:
if i['key'] == 'player_stream_url': if i['key'] == 'player_stream_url':
vidUrl = i['value']['string_value'] vidUrl = i['value']['string_value']
elif i['key'] == 'player_width': elif i['key'] == 'player_width':
@@ -112,7 +145,22 @@ def getApiResponse(tweet,include_txt=False,include_rtf=False):
if vidUrl != None and width != None and height != None: if vidUrl != None and width != None and height != None:
media.append(vidUrl) media.append(vidUrl)
media_extended.append({"url":vidUrl,"type":"video","size":{"width":width,"height":height}}) 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 +191,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 +207,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 +264,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 +273,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 +302,9 @@ 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,
} }
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())

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