diff --git a/activity.py b/activity.py index bd6264f..6c52caa 100644 --- a/activity.py +++ b/activity.py @@ -8,11 +8,20 @@ def tweetDataToActivity(tweetData,embedIndex = -1): if tweetData['replyingTo'] is not None: content += f"
↪️ Replying to @{tweetData['replyingTo']}" - content+=f"
{tweetData['text']}
" + + mainText = tweetData['text'] + if tweetData["translation"] is not None: + content += f"🌐 {tweetData['translation']['source_language'].upper()}→{tweetData['translation']['destination_language'].upper()}" + mainText=tweetData['translation']["text"] + + content+=f"
{mainText}
" attachments=[] if tweetData['qrt'] is not None: - content += f"QRT: {tweetData['qrt']['user_screen_name']}" + qrtText = tweetData['qrt']['text'] + if "translation" in tweetData['qrt'] and tweetData['qrt']["translation"] is not None: + qrtText = tweetData['qrt']["translation"]["text"] + content += f"
{tweetData['qrt']['text']}
QRT: {tweetData['qrt']['user_screen_name']}" if tweetData['pollData'] is not None: content += f"
{qrtText}
{msgs.genPollDisplay(tweetData['pollData'])}
" content += "" diff --git a/msgs.py b/msgs.py index 8bcd17c..07d3d02 100644 --- a/msgs.py +++ b/msgs.py @@ -17,8 +17,11 @@ def genLikesDisplay(vnf): return ("💖 " + numerize.numerize(vnf['likes'])) def genQrtDisplay(qrt): + text = qrt['text'] + if "translation" in qrt: + text = qrt["translation"]["text"] verifiedCheck = "☑️" if ('verified' in qrt and qrt['verified']) else "" - return ("\n\n【QRT of " + qrt['user_name'] + " (@" + qrt['user_screen_name'] + ")"+ verifiedCheck+":】\n\n'" + qrt['text'] + "'") + return ("\n\n【QRT of " + qrt['user_name'] + " (@" + qrt['user_screen_name'] + ")"+ verifiedCheck+":】\n\n'" + text + "'") def genPollDisplay(poll): pctSplit=10 @@ -27,6 +30,9 @@ def genPollDisplay(poll): output+=choice["name"]+"\n"+("█"*int(choice["percent"]/pctSplit)) +" "+str(choice["percent"])+"%\n" return output +def genTranslationDisplay(translation): + return f"【TL "+translation["source_language"]+"→"+translation["destination_language"]+"】\n\n"+translation["text"] + # formats the top text of the embed def formatProvider(base,vnf): finalText = base @@ -38,13 +44,16 @@ def formatProvider(base,vnf): finalText = base return finalText -def formatEmbedDesc(type,body,qrt,pollData): +def formatEmbedDesc(type,body,qrt,pollData,translation): # Trim the embed description to 248 characters, prioritizing poll and likes qrtType=None if qrt!=None: qrtType="Text" + if translation is not None: + body = genTranslationDisplay(translation) + limit = videoDescLimit if type=="Video" or (qrt!=None and (qrtType=="Video")) else tweetDescLimit output = "" @@ -73,6 +82,6 @@ def formatEmbedDesc(type,body,qrt,pollData): diff = len(output)-limit # remove the characters from body, add ellipsis body = body[:-(diff+1)]+"…" - return formatEmbedDesc(type,body,qrt,pollData) + return formatEmbedDesc(type,body,qrt,pollData,None) else: return output diff --git a/requirements.txt b/requirements.txt index ef9f74d..209347e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,4 @@ Werkzeug==2.3.7 numerize==0.12 oauthlib==3.2.2 PyRTF3==0.47.5 -XClientTransaction==0.0.2 \ No newline at end of file +XClientTransaction==1.0.2 \ No newline at end of file diff --git a/translation.py b/translation.py new file mode 100644 index 0000000..7e74ef4 --- /dev/null +++ b/translation.py @@ -0,0 +1,7 @@ +validLanguages = ['AF', 'AN', 'AR', 'AS', 'AY', 'AZ', 'BA', 'BE', 'BG', 'BN', 'BR', 'BS', 'CA', 'CS', 'CY', 'DA', 'DE', 'DE-DE', 'EL', 'EN', 'EO', 'ES', 'ES-419', 'ET', 'EU', 'FA', 'FI', 'FR', 'FR-FR', 'GA', 'GL', 'GN', 'GU', 'HA', 'HE', 'HI', 'HR', 'HT', 'HU', 'HY', 'ID', 'IG', 'IS', 'IT', 'JA', 'JV', 'KA', 'KK', 'KO', 'KY', 'LA', 'LB', 'LN', 'LT', 'LV', 'MG', 'MI', 'MK', 'ML', 'MN', 'MR', 'MS', 'MT', 'MY', 'NB', 'NE', 'NL', 'OC', 'OM', 'PA', 'PL', 'PS', 'PT-BR', 'PT-PT', 'QU', 'RO', 'RU', 'SA', 'SK', 'SL', 'SQ', 'SR', 'ST', 'SU', 'SV', 'SW', 'TA', 'TE', 'TG', 'TH', 'TK', 'TL', 'TN', 'TR', 'TS', 'TT', 'UK', 'UR', 'UZ', 'VI', 'WO', 'XH', 'YI', 'ZH', 'ZH-HANS', 'ZH-HANT', 'ZU'] +languageAliases = { + #"EN":"EN-US", + "JP":"JA", +} + +mergedLangs=(validLanguages+list(languageAliases.keys())) \ No newline at end of file diff --git a/twExtract/__init__.py b/twExtract/__init__.py index 364f56e..793c9af 100644 --- a/twExtract/__init__.py +++ b/twExtract/__init__.py @@ -113,7 +113,7 @@ def cycleBearerTokenGet(url,headers): return tweet raise TwExtractError(400, "Extract error") -def twitterApiGet(url,btoken=None,authToken=None,guestToken=None,userAgent=None): +def twitterApiGet(url,btoken=None,authToken=None,guestToken=None,userAgent=None,language=None): if authToken != None and authToken.startswith("oa|"): url = url.replace("https://x.com/i/api/graphql/","https://api.twitter.com/graphql/") @@ -122,7 +122,7 @@ def twitterApiGet(url,btoken=None,authToken=None,guestToken=None,userAgent=None) secret = authToken.split("|")[1] twt = oauth1.Client(client_key='3nVuSoBZnx6U4vzUxf5w',client_secret='Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys',resource_owner_key=key,resource_owner_secret=secret) - hdr = getAuthHeaders(androidBearer) + hdr = getAuthHeaders(androidBearer,language=language) del hdr["Authorization"] hdr["X-Twitter-Client"] = "TwitterAndroid" if userAgent is not None: @@ -135,12 +135,12 @@ def twitterApiGet(url,btoken=None,authToken=None,guestToken=None,userAgent=None) if btoken is None: 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,language=language) response = requests.get(url, headers=headers) return response -def getAuthHeaders(btoken,authToken=None,guestToken=None): +def getAuthHeaders(btoken,authToken=None,guestToken=None,language=None): csrfToken=str(uuid.uuid4()).replace('-', '') headers = {"x-twitter-active-user":"yes","x-twitter-client-language":"en","x-csrf-token":csrfToken,"User-Agent":requestUserAgent} @@ -152,6 +152,9 @@ def getAuthHeaders(btoken,authToken=None,guestToken=None): if guestToken is not None: headers["x-guest-token"] = guestToken + if language is not None: + headers["x-twitter-client-language"] = language + return headers def getGuestToken(): @@ -268,7 +271,7 @@ def extractStatus_twExtractProxy(url,workaroundTokens=None): continue return output -def extractStatusV2(url,workaroundTokens): +def extractStatusV2(url,workaroundTokens,tlLanguage=None): # get tweet ID m = re.search(pathregex, url) if m is None: @@ -282,7 +285,7 @@ def extractStatusV2(url,workaroundTokens): def request_with_token(twid, authToken): vars = json.loads('{"includeTweetImpression":true,"includeHasBirdwatchNotes":false,"includeEditPerspective":false,"rest_ids":["x"],"includeEditControl":true,"includeCommunityTweetRelationship":true,"includeTweetVisibilityNudge":true}') vars['rest_ids'][0] = str(twid) - tweet = twitterApiGet(f"https://x.com/i/api/graphql/{v2graphql_api}/TweetResultsByIdsQuery?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(v2Features)}",authToken=authToken) + tweet = twitterApiGet(f"https://x.com/i/api/graphql/{v2graphql_api}/TweetResultsByIdsQuery?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(v2Features)}",authToken=authToken,language=tlLanguage) try: rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining") print(f"Twitter Token Rate limit remaining: {rateLimitRemaining}") @@ -321,7 +324,7 @@ def extractStatusV2(url,workaroundTokens): return tweet return parallel_token_request(twid, tokens, request_with_token) -def extractStatusV2Android(url,workaroundTokens): +def extractStatusV2Android(url,workaroundTokens,tlLanguage=None): # get tweet ID m = re.search(pathregex, url) if m is None: @@ -335,7 +338,7 @@ def extractStatusV2Android(url,workaroundTokens): try: 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) - tweet = twitterApiGet(f"https://x.com/i/api/graphql/{androidGraphql_api}/ConversationTimelineV2?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(androidGraphqlFeatures)}", authToken=authToken,btoken=androidBearer,userAgent="TwitterAndroid/11.61.0-release.0 (311610000-r-0) G011A/9 (google;G011A;google;G011A;0;;1;2016)") + tweet = twitterApiGet(f"https://x.com/i/api/graphql/{androidGraphql_api}/ConversationTimelineV2?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(androidGraphqlFeatures)}", authToken=authToken,btoken=androidBearer,userAgent="TwitterAndroid/11.61.0-release.0 (311610000-r-0) G011A/9 (google;G011A;google;G011A;0;;1;2016)",language=tlLanguage) try: rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining") print(f"Twitter Android Token Rate limit remaining: {rateLimitRemaining}") @@ -380,7 +383,7 @@ def extractStatusV2Android(url,workaroundTokens): return tweet return parallel_token_request(twid, tokens, request_with_token) -def extractStatusV2TweetDetail(url,workaroundTokens): +def extractStatusV2TweetDetail(url,workaroundTokens,tlLanguage=None): # get tweet ID m = re.search(pathregex, url) if m is None: @@ -395,7 +398,7 @@ def extractStatusV2TweetDetail(url,workaroundTokens): try: 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) - 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) + tweet = twitterApiGet(f"https://x.com/i/api/graphql/{tweetDetailGraphql_api}/TweetDetail?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(tweetDetailGraphqlFeatures)}", authToken=authToken,btoken=v2bearer,language=tlLanguage) try: rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining") print(f"Twitter Token Rate limit remaining: {rateLimitRemaining}") @@ -440,10 +443,10 @@ def extractStatusV2TweetDetail(url,workaroundTokens): return tweet return parallel_token_request(twid, tokens, request_with_token) -def extractStatusV2Rest_Anon(url,workaroundTokens): - return extractStatusV2Rest(url,None) +def extractStatusV2Rest_Anon(url,workaroundTokens,tlLanguage=None): + return extractStatusV2Rest(url,None,tlLanguage=tlLanguage) -def extractStatusV2Rest(url,workaroundTokens): +def extractStatusV2Rest(url,workaroundTokens,tlLanguage=None): # get tweet ID m = re.search(pathregex, url) if m is None: @@ -461,11 +464,11 @@ def extractStatusV2Rest(url,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) + tweet = twitterApiGet(f"https://x.com/i/api/graphql/{v2AnonGraphql_api}/TweetResultByRestId?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(v2AnonFeatures)}", btoken=v2bearer,authToken=authToken,guestToken=guestToken,language=tlLanguage) except Exception as e: continue else: - tweet = twitterApiGet(f"https://x.com/i/api/graphql/{v2AnonGraphql_api}/TweetResultByRestId?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(v2AnonFeatures)}", btoken=v2bearer,guestToken=guestToken) + tweet = twitterApiGet(f"https://x.com/i/api/graphql/{v2AnonGraphql_api}/TweetResultByRestId?variables={urllib.parse.quote(json.dumps(vars))}&features={urllib.parse.quote(v2AnonFeatures)}", btoken=v2bearer,guestToken=guestToken,language=tlLanguage) try: rateLimitRemaining = tweet.headers.get("x-rate-limit-remaining") @@ -512,12 +515,13 @@ def fixTweetData(tweet): pass return tweet -def extractStatus(url,workaroundTokens=None): - # TODO: commented out methods are too slow/unreliable at the moment +def extractStatus(url,workaroundTokens=None,tlLanguage=None): methods=[extractStatusV2Rest_Anon,extractStatusV2,extractStatusV2Rest,extractStatusV2Android]#,extractStatusV2TweetDetail] + if tlLanguage is not None: # prioritize endpoints that return translations + methods = [extractStatusV2Rest,extractStatusV2TweetDetail,extractStatusV2Android,extractStatusV2,extractStatusV2Rest_Anon] for method in methods: try: - result = method(url,workaroundTokens) + result = method(url,workaroundTokens,tlLanguage=tlLanguage) if 'legacy' not in result: print(f"{method.__name__} method failed: Legacy not found for {url}") # try another method diff --git a/twitfix.py b/twitfix.py index f6860c9..13f7a9a 100644 --- a/twitfix.py +++ b/twitfix.py @@ -14,7 +14,7 @@ import msgs import twExtract as twExtract from cache import addVnfToLinkCache,getVnfFromLinkCache import vxlogging as log -from utils import getTweetIdFromUrl, pathregex, determineMediaToEmbed, determineEmbedTweet, BytesIOWrapper, fixMedia +from utils import getTweetIdFromUrl, pathregex, determineMediaToEmbed, determineEmbedTweet, BytesIOWrapper, fixMedia, indexOfAny from vxApi import getApiResponse, getApiUserResponse from urllib.parse import urlparse from PyRTF.Elements import Document @@ -22,12 +22,14 @@ from PyRTF.document.section import Section from PyRTF.document.paragraph import Paragraph from copy import deepcopy import json -import datetime +import translation import activity as activitymg app = Flask(__name__) CORS(app) user_agent="" +activityseparator="zqj" + staticFiles = { # TODO: Use flask static files instead of this "favicon.ico": {"mime": "image/vnd.microsoft.icon","path": "favicon.ico"}, "apple-touch-icon.png": {"mime": "image/png","path": "apple-touch-icon.png"}, @@ -82,7 +84,11 @@ def generateActivityLink(tweetData,media=None,mediatype=None,embedIndex=-1): return None try: embedIndex = embedIndex+1 - return f"{config['config']['url']}/users/{tweetData['user_screen_name']}/statuses/{str(embedIndex)}{tweetData['tweetID']}" + + extras="" + if tweetData["translation"] is not None: + extras+=activityseparator+tweetData["translation"]["destination_language"] + return f"{config['config']['url']}/users/{tweetData['user_screen_name']}/statuses/{str(embedIndex)}{activityseparator}{tweetData['tweetID']}{extras}" except Exception as e: log.error("Error generating activity link: "+str(e)) return None @@ -95,7 +101,7 @@ def getAppName(tweetData,appnameSuffix=""): def renderImageTweetEmbed(tweetData,image,appnameSuffix="",embedIndex=-1): qrt = tweetData['qrt'] - embedDesc = msgs.formatEmbedDesc("Image",tweetData['text'],qrt,tweetData['pollData']) + embedDesc = msgs.formatEmbedDesc("Image",tweetData['text'],qrt,tweetData['pollData'],tweetData["translation"]) if image.startswith("https://pbs.twimg.com") and "?" not in image: image = f"{image}?name=orig" @@ -115,7 +121,7 @@ def renderImageTweetEmbed(tweetData,image,appnameSuffix="",embedIndex=-1): def renderVideoTweetEmbed(tweetData,mediaInfo,appnameSuffix="",embedIndex=-1): qrt = tweetData['qrt'] - embedDesc = msgs.formatEmbedDesc("Video",tweetData['text'],qrt,tweetData['pollData']) + embedDesc = msgs.formatEmbedDesc("Video",tweetData['text'],qrt,tweetData['pollData'],tweetData["translation"]) mediaInfo=fixMedia(mediaInfo) @@ -138,7 +144,7 @@ def renderVideoTweetEmbed(tweetData,mediaInfo,appnameSuffix="",embedIndex=-1): def renderTextTweetEmbed(tweetData,appnameSuffix=""): qrt = tweetData['qrt'] - embedDesc = msgs.formatEmbedDesc("Text",tweetData['text'],qrt,tweetData['pollData']) + embedDesc = msgs.formatEmbedDesc("Text",tweetData['text'],qrt,tweetData['pollData'],tweetData["translation"]) return render_template("text.html", tweet=tweetData, @@ -154,7 +160,7 @@ def renderTextTweetEmbed(tweetData,appnameSuffix=""): def renderArticleTweetEmbed(tweetData,appnameSuffix=""): articlePreview=tweetData['article']["title"]+"\n\n"+tweetData['article']["preview_text"]+"…" - embedDesc = msgs.formatEmbedDesc("Image",articlePreview,None,None) + embedDesc = msgs.formatEmbedDesc("Image",articlePreview,None,None,None) return render_template("image.html", tweet=tweetData, @@ -260,9 +266,9 @@ def userJson(): }, } -def getTweetData(twitter_url,include_txt="false",include_rtf="false"): +def getTweetData(twitter_url,include_txt="false",include_rtf="false",tlLanguage=None): cachedVNF = getVnfFromLinkCache(twitter_url) - if cachedVNF is not None and include_txt == "false" and include_rtf == "false": + if cachedVNF is not None and include_txt == "false" and include_rtf == "false" and tlLanguage is None: return cachedVNF try: @@ -270,7 +276,7 @@ def getTweetData(twitter_url,include_txt="false",include_rtf="false"): workaroundTokens = config['config']['workaroundTokens'].split(",") else: workaroundTokens = None - rawTweetData = twExtract.extractStatus(twitter_url,workaroundTokens=workaroundTokens) + rawTweetData = twExtract.extractStatus(twitter_url,workaroundTokens=workaroundTokens,tlLanguage=tlLanguage) except: rawTweetData = None if rawTweetData == None or 'error' in rawTweetData: @@ -281,7 +287,15 @@ def getTweetData(twitter_url,include_txt="false",include_rtf="false"): tweetData = getApiResponse(rawTweetData,include_txt,include_rtf) if tweetData is None: return None - if include_txt == "false" and include_rtf == "false": + + if tweetData["translation"] is None and tlLanguage is not None and tlLanguage != tweetData["lang"]: + # translation probably failed on twitter's end + pass # TODO: implement DeepL api + + if tweetData["translation"] is not None and tlLanguage is None: + tweetData["translation"] = None # didn't ask for TL + + if include_txt == "false" and include_rtf == "false" and tlLanguage is None: addVnfToLinkCache(twitter_url,tweetData) return tweetData @@ -307,6 +321,7 @@ def getUserData(twitter_url,includeFeed=False): @app.route('/