twitter_v2_facade.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545
  1. from dataclasses import dataclass
  2. from typing import List, Optional
  3. from dataclass_type_validator import dataclass_type_validator, dataclass_validate, TypeValidationError
  4. from configparser import ConfigParser
  5. import base64
  6. import sqlite3
  7. import os
  8. import json
  9. import json_stream
  10. from zipfile import ZipFile
  11. import itertools
  12. import time
  13. from io import BufferedReader
  14. import re
  15. import datetime
  16. import dateutil
  17. import dateutil.parser
  18. import dateutil.tz
  19. import requests
  20. from werkzeug.utils import secure_filename
  21. from flask import json, Response, render_template, request, send_from_directory, Blueprint, session, redirect, g, current_app, jsonify
  22. from flask_cors import CORS
  23. from tweet_source import ApiV2TweetSource, TwitterApiV2SocialGraph
  24. import oauth2_login
  25. DATA_DIR='.data'
  26. twitter_app = Blueprint('twitter_v2_facade', 'twitter_v2_facade',
  27. static_folder='static',
  28. static_url_path='',
  29. url_prefix='/')
  30. twitter_app.register_blueprint(oauth2_login.oauth2_login, url_prefix="/")
  31. twitter_app.context_processor(oauth2_login.inject_me)
  32. twitter_app.before_request(oauth2_login.add_me)
  33. url_for = oauth2_login.url_for_with_me
  34. def run_script(script_name, script_vars):
  35. script_path = './{}.py'.format(script_name)
  36. if (os.path.exists(script_path)):
  37. script_file = open(script_path, 'r')
  38. script = script_file.read()
  39. script_file.close()
  40. try:
  41. return exec(script, script_vars)
  42. except:
  43. print('error running script: {}'.format(script_name))
  44. return False
  45. False
  46. class ActivityData:
  47. def __init__ (self,db_path):
  48. self.db_path = db_path
  49. db_exists = os.path.exists(db_path)
  50. self.db = sqlite3.connect(db_path)
  51. if not db_exists:
  52. self.init_db()
  53. return
  54. def init_db (self):
  55. self.db.execute('create table seen_user (ts, user_id)')
  56. self.db.execute('create table seen_tweet (ts, tweet_id)')
  57. return
  58. def seen_tweet (self, tweet_id):
  59. return
  60. def seen_user (self, user_id):
  61. return
  62. def add_tweet_counts (self, user_id, start, end, tweet_count):
  63. return [current_ts, user_id, start, end, tweet_count]
  64. def add_tweet_public_metrics (self, tweet_id, like_count, reply_count, retweet_count, quote_count):
  65. return
  66. def add_tweet_non_public_metrics (self, tweet_id, impression_count, click_count, link_click_count, profile_click_count):
  67. return
  68. def add_user_public_metrics (self, user_id, followers_count, following_count, tweet_count, listed_count):
  69. return
  70. class DataSet:
  71. def __init__ (self):
  72. self.items = {}
  73. return
  74. def update_items (self, items):
  75. """
  76. merges objects by ID. Asssigns an ID if none exists. Mutates OG object.
  77. """
  78. ids = []
  79. for item in items:
  80. if not 'id' in item:
  81. #item = dict(item)
  82. item['id'] = uuid.uuid4().hex
  83. else:
  84. existing_item = self.items.get( item['id'] )
  85. if existing_item:
  86. existing_item.update(item)
  87. item = existing_item
  88. self.items[ item['id'] ] = item
  89. ids.append( item['id'] )
  90. return ids
  91. def get_items (self):
  92. return self.items.values()
  93. class TwitterMetadata:
  94. def __init__ (self, data_dir):
  95. self.data_dir = data_dir
  96. os.mkdir(data_dir, exist_ok=True)
  97. def get_tweet (self, tweet_id):
  98. path = f'{self.data_dir}/tweet_{tweet_id}.json'
  99. if not os.path.exists(path):
  100. return None
  101. with open(path, 'rt') as f:
  102. return json.loads(f.read())
  103. def update_tweet (self, tweet_id, fields):
  104. tweet = self.get_tweet(tweet_id)
  105. if not tweet:
  106. tweet = {'id': tweet_id}
  107. tweet.update(fields)
  108. with open(f'{self.data_dir}/tweet_{tweet_id}.json', 'wt') as f:
  109. f.write(json.dumps(tweet))
  110. return tweet
  111. def collection_from_card_source (url):
  112. """
  113. temp1 = await fetch('http://localhost:5000/notes/cards/search?q=twitter.com/&limit=10').then(r => r.json())
  114. re = /(http[^\s]+twitter\.com\/[^\/]+\/status\/[\d]+)/ig
  115. tweetLinks = temp1.cards.map(c => c.card.content).map(c => c.match(re))
  116. tweetLinks2 = tweetLinks.flat().filter(l => l)
  117. tweetLinksS = Array.from(new Set(tweetLinks2))
  118. statusUrls = tweetLinksS.map(s => new URL(s))
  119. //users = Array.from(new Set(statusUrls.map(s => s.pathname.split('/')[1])))
  120. ids = Array.from(new Set(statusUrls.map(s => parseInt(s.pathname.split('/')[3]))))
  121. """
  122. """
  123. temp1 = JSON.parse(document.body.innerText)
  124. // get swipe note + created_at + tweet user + tweet ID
  125. tweetCards = temp1.cards.map(c => c.card).filter(c => c.content.match(re))
  126. tweets = tweetCards.map(c => ({created_at: c.created_at, content: c.content, tweets: c.content.match(re).map(m => new URL(m))}))
  127. tweets.filter(t => t.tweets.filter(t2 => t2.user.toLowerCase() == 'stephenmpinto').length)
  128. // HN
  129. re = /(http[^\s]+news.ycombinator\.com\/[^\s]+\=[\d]+)/ig
  130. linkCards = temp1.cards.map(c => c.card).filter(c => c.content.match(re))
  131. links = linkCards.map(c => ({created_at: c.created_at, content: c.content, links: c.content.match(re).map(m => new URL(m))}))
  132. // YT (I thnk I've already done this one)
  133. """
  134. # more in 2022 twitter report
  135. return None
  136. def get_tweet_collection (collection_id):
  137. with open(f'{DATA_DIR}/collection/{collection_id}.json', 'rt', encoding='utf-8') as f:
  138. collection = json.loads(f.read())
  139. return collection
  140. # pagination token is the next tweet_ID
  141. @twitter_app.get('/collection/<collection_id>.html')
  142. def get_collection_html (collection_id):
  143. max_results = int(request.args.get('max_results', 10))
  144. pagination_token = request.args.get('pagination_token')
  145. collection = get_tweet_collection(collection_id)
  146. if 'authorized_users' in collection and g.twitter_user['id'] not in collection['authorized_users']:
  147. return 'access denied.', 403
  148. items = []
  149. for item in collection['items']:
  150. tweet_id = item['id']
  151. if pagination_token and tweet_id != pagination_token:
  152. continue
  153. elif tweet_id == pagination_token:
  154. pagination_token = None
  155. elif len(items) == max_results:
  156. pagination_token = tweet_id
  157. break
  158. items.append(item)
  159. if not len(items):
  160. return 'no tweets', 404
  161. token = g.twitter_user['access_token']
  162. tweet_source = ApiV2TweetSource(token)
  163. tweet_ids = list(map(lambda item: item['id'], items))
  164. response_json = tweet_source.get_tweets( tweet_ids )
  165. #print(response_json)
  166. if 'errors' in response_json:
  167. # types:
  168. # https://api.twitter.com/2/problems/not-authorized-for-resource (blocked or suspended)
  169. # https://api.twitter.com/2/problems/resource-not-found (deleted)
  170. #print(response_json.get('errors'))
  171. for err in response_json.get('errors'):
  172. if not 'type' in err:
  173. print('unknown error type: ' + str(err))
  174. elif err['type'] == 'https://api.twitter.com/2/problems/not-authorized-for-resource':
  175. print('blocked or suspended tweet: ' + err['value'])
  176. elif err['type'] == 'https://api.twitter.com/2/problems/resource-not-found':
  177. print('deleted tweet: ' + err['value'])
  178. else:
  179. print('unknown error: ' + str(err))
  180. includes = response_json.get('includes')
  181. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  182. for item in items:
  183. t = list(filter(lambda t: item['id'] == t['id'], tweets))
  184. if not len(t):
  185. print("no tweet for item: " + item['id'])
  186. t = {
  187. "id": item['id'],
  188. "text": "(Deleted, suspended or blocked)",
  189. "created_at": "",
  190. "handle": "error",
  191. "display_name": "Error"
  192. }
  193. # FIXME 1) put this in relative order to the collection
  194. # FIXME 2) we can use the tweet link to get the user ID...
  195. tweets.append(t)
  196. else:
  197. t = t[0]
  198. t['note'] = item['note']
  199. if request.args.get('format') == 'json':
  200. return jsonify({'ids': tweet_ids,
  201. 'data': response_json,
  202. 'tweets': tweets,
  203. 'items': items,
  204. 'pagination_token': pagination_token})
  205. else:
  206. query = {}
  207. if pagination_token:
  208. query['next_data_url'] = url_for('.get_collection_html', collection_id=collection_id, pagination_token=pagination_token)
  209. if 'HX-Request' in request.headers:
  210. return render_template('partial/tweets-timeline.html', tweets = tweets, user = {}, query = query)
  211. else:
  212. if pagination_token:
  213. query['next_page_url'] = url_for('.get_collection_html', collection_id=collection_id, pagination_token=pagination_token)
  214. return render_template('tweet-collection.html', tweets = tweets, user = {}, query = query)
  215. @twitter_app.post('/data/collection/create/from-cards')
  216. def post_data_collection_create_from_cards ():
  217. """
  218. // create collection from search, supporting multiple Tweets per card and Tweets in multiple Cards.
  219. re = /(https?[a-z0-9\.\/\:]+twitter\.com\/[0-9a-z\_]+\/status\/[\d]+)/ig
  220. temp1 = await fetch('http://localhost:5000/notes/cards/search?q=twitter.com/').then(r => r.json())
  221. cardMatches = temp1.cards
  222. .map(cm => Object.assign({}, cm, {tweetLinks: Array.from(new Set(cm.card.content.match(re)))}))
  223. .filter(cm => cm.tweetLinks && cm.tweetLinks.length)
  224. .map(cm => Object.assign({}, cm, {tweetUrls: cm.tweetLinks.map(l => new URL(l))}))
  225. .map(cm => Object.assign({}, cm, {tweetInfos: cm.tweetUrls.map(u => ({user: u.pathname.split('/')[1], tweetId: u.pathname.split('/')[3]}))}));
  226. collectionCards = {}
  227. cardMatches.forEach(function (cm) {
  228. if (!cm.tweetLinks.length) { return; }
  229. cm.tweetInfos.forEach(function (ti) {
  230. if (!collectionCards[ti.tweetId]) {
  231. collectionCards[ti.tweetId] = [];
  232. }
  233. collectionCards[ti.tweetId].push(cm.card);
  234. })
  235. })
  236. var collectionItems = [];
  237. Object.entries(collectionCards).forEach(function (e) {
  238. var tweetId = e[0], cards = e[1];
  239. var note = cards.map(function (card) {
  240. return card.created_at + "\n\n" + card.content;
  241. }).join("\n\n-\n\n");
  242. collectionItems.push({id: tweetId, note: note, tweet_infos: cm.tweetInfos, card_infos: cards.map(c => 'card#' + c.id)});
  243. })
  244. """
  245. collection = {
  246. 'items': [], # described in JS function above
  247. 'authorized_users': [g.twitter_user['id']]
  248. }
  249. return jsonify(collection)
  250. #twitter_meta = TwitterMetadata('./data/meta')
  251. @twitter_app.route('/tweets', methods=['POST'])
  252. def post_tweets_create ():
  253. user_id = g.twitter_user['id']
  254. token = g.twitter_user['access_token']
  255. text = request.form.get('text')
  256. reply_to_tweet_id = request.form.get('reply_to_tweet_id')
  257. quote_tweet_id = request.form.get('quote_tweet_id')
  258. tweet_source = ApiV2TweetSource(token)
  259. result = tweet_source.create_tweet(text, reply_to_tweet_id=reply_to_tweet_id, quote_tweet_id=quote_tweet_id)
  260. print(result)
  261. run_script('on_tweeted', {'twitter_user': g.twitter_user, 'tweet': result})
  262. if 'HX-Request' in request.headers:
  263. return render_template('partial/compose-form.html', new_tweet_id=result['data']['id'])
  264. else:
  265. response_body = json.dumps({
  266. 'result': result
  267. })
  268. return jsonify(response_body)
  269. @twitter_app.route('/tweet/<tweet_id>/retweet', methods=['POST'])
  270. def post_tweet_retweet (tweet_id):
  271. user_id = g.twitter_user['id']
  272. token = g.twitter_user['access_token']
  273. tweet_source = ApiV2TweetSource(token)
  274. result = tweet_source.retweet(tweet_id, user_id=user_id)
  275. print(result)
  276. run_script('on_tweeted', {'twitter_user': g.twitter_user, 'retweet': result})
  277. if 'HX-Request' in request.headers:
  278. return """retweeted <script>Toast.fire({
  279. icon: 'success',
  280. title: 'Retweet was sent; <a style="text-align: right" href="{}">View</a>.'
  281. });</script>""".replace('{}', url_for('.get_tweet_html', tweet_id=tweet_id))
  282. else:
  283. response_body = json.dumps({
  284. 'result': result
  285. })
  286. return jsonify(response_body)
  287. @twitter_app.route('/tweet/<tweet_id>/bookmark', methods=['POST'])
  288. def post_tweet_bookmark (tweet_id):
  289. user_id = g.twitter_user['id']
  290. token = g.twitter_user['access_token']
  291. tweet_source = ApiV2TweetSource(token)
  292. result = tweet_source.bookmark(tweet_id, user_id=user_id)
  293. print(result)
  294. if 'HX-Request' in request.headers:
  295. return """bookmarked <script>Toast.fire({
  296. icon: 'success',
  297. title: 'Tweet was bookmarked; <a style="text-align: right" href="{}">View</a>.'
  298. });</script>""".replace('{}', url_for('.get_tweet_html', tweet_id=tweet_id))
  299. else:
  300. response_body = json.dumps({
  301. 'result': result
  302. })
  303. return jsonify(response_body)
  304. @twitter_app.route('/tweet/<tweet_id>/bookmark', methods=['DELETE'])
  305. def delete_tweet_bookmark (tweet_id):
  306. user_id = g.twitter_user['id']
  307. token = g.twitter_user['access_token']
  308. tweet_source = ApiV2TweetSource(token)
  309. result = tweet_source.delete_bookmark(tweet_id, user_id=user_id)
  310. response_body = json.dumps({
  311. 'result': result
  312. })
  313. return jsonify(response_body)
  314. @twitter_app.route('/tweet/<tweet_id>.html', methods=['GET'])
  315. def get_tweet_html (tweet_id):
  316. user_id = g.twitter_user['id']
  317. token = g.twitter_user['access_token']
  318. pagination_token = request.args.get('pagination_token')
  319. view = request.args.get('view', 'replies')
  320. token = g.twitter_user['access_token']
  321. tweet_source = ApiV2TweetSource(token)
  322. only_replies = view == 'replies'
  323. tweets = []
  324. if not pagination_token:
  325. response_json = tweet_source.get_tweet(tweet_id)
  326. print("parent tweet=")
  327. print(response_json)
  328. includes = response_json.get('includes')
  329. tweet = response_json.get('data')[0]
  330. tweets.append(tweet_model(includes, tweet, g.me))
  331. response_json = None
  332. data_route = None
  333. if view == 'replies':
  334. data_route = '.get_tweet_html'
  335. response_json = tweet_source.get_thread(tweet_id,
  336. only_replies=True,
  337. pagination_token = pagination_token)
  338. elif view == 'thread':
  339. data_route = '.get_tweet_html'
  340. response_json = tweet_source.get_thread(tweet_id,
  341. only_replies=False,
  342. author_id=tweets[0]['author_id'],
  343. pagination_token = pagination_token)
  344. elif view == 'conversation':
  345. data_route = '.get_tweet_html'
  346. response_json = tweet_source.get_thread(tweet_id,
  347. only_replies=False,
  348. pagination_token = pagination_token)
  349. elif view == 'tweet':
  350. None
  351. next_token = None
  352. #print("conversation meta:")
  353. #print(json.dumps(response_json.get('meta'), indent=2))
  354. if response_json and response_json.get('meta').get('result_count'):
  355. includes = response_json.get('includes')
  356. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data'])) + tweets
  357. next_token = response_json.get('meta').get('next_token')
  358. # this method is OK except it doesn't work if there are no replies.
  359. #tweets.append(tweet_model(includes, list(filter(lambda t: t['id'] == tweet_id, includes.get('tweets')))[0], me))
  360. #related_tweets = [] # derived from includes
  361. tweets.reverse()
  362. query = {}
  363. if next_token:
  364. query = {
  365. **query,
  366. # FIXME only_replies
  367. 'next_data_url': url_for(data_route, tweet_id=tweet_id, pagination_token=next_token, only_replies = '1' if only_replies else '0', author_id = tweets[0]['author_id']),
  368. 'next_page_url': url_for('.get_tweet_html', tweet_id=tweet_id, view=view, pagination_token=next_token)
  369. }
  370. user = {
  371. 'id': user_id
  372. }
  373. if 'HX-Request' in request.headers:
  374. # console.log(res.tweets.map(t => t.text).join("\n\n-\n\n"))
  375. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  376. else:
  377. return render_template('tweet-collection.html', user = user, tweets = tweets, query = query, show_parent_tweet_controls=True)
  378. @twitter_app.route('/followers/<user_id>.html', methods=['GET'])
  379. def get_data_user_followers (user_id):
  380. token = g.twitter_user['access_token']
  381. social_source = TwitterApiV2SocialGraph(token)
  382. response_json = social_source.get_followers(user_id)
  383. ts = int(time.time() * 1000)
  384. with open(f'{DATA_DIR}/cache/followers_{user_id}_{ts}.json', 'wt') as f:
  385. f.write(json.dumps(response_json))
  386. print(response_json)
  387. #run_script('on_user_seen', {'twitter_user': g.twitter_user, 'users': response_json})
  388. followers = list(map(lambda f: f['id'], response_json.get('data')))
  389. return render_template('following.html', following=followers)
  390. @twitter_app.route('/following/<user_id>.html', methods=['GET'])
  391. def get_data_user_following (user_id):
  392. token = g.twitter_user['access_token']
  393. social_source = TwitterApiV2SocialGraph(token)
  394. response_json = social_source.get_following(user_id)
  395. ts = int(time.time() * 1000)
  396. with open(f'{DATA_DIR}/cache/following_{user_id}_{ts}.json', 'wt') as f:
  397. f.write(json.dumps(response_json))
  398. print(response_json)
  399. #run_script('on_user_seen', {'twitter_user': g.twitter_user, 'users': response_json})
  400. following = list(map(lambda f: f['id'], response_json.get('data')))
  401. return render_template('following.html', following=following)
  402. @twitter_app.route('/data/timeline/user/<user_id>/counts')
  403. def get_data_timeline_user_counts (user_id):
  404. query = f'from:{user_id}'
  405. # is:reply is:quote is:retweet has:links has:mentions has:media has:images has:videos has:geo
  406. if not oauth2_login.app_access_token:
  407. return 'refresh app token first.', 400
  408. tweet_source = ApiV2TweetSource(oauth2_login.app_access_token)
  409. response_json = tweet_source.count_tweets(query)
  410. ts = int(time.time() * 1000)
  411. with open(f'{DATA_DIR}/cache/tl_counts_{user_id}_{ts}.json', 'wt') as f:
  412. f.write(json.dumps(response_json))
  413. data = list(filter(lambda d: d.get('tweet_count') > 0, response_json.get('data')))
  414. result = {
  415. 'total_count': response_json.get('meta').get('total_tweet_count'),
  416. 'data': data
  417. }
  418. return jsonify(result)
  419. # ---------------------------------------------------------------------------------------------------------
  420. # ---------------------------------------------------------------------------------------------------------
  421. # HTMx partials
  422. # ---------------------------------------------------------------------------------------------------------
  423. # ---------------------------------------------------------------------------------------------------------
  424. @dataclass_validate
  425. @dataclass
  426. class PublicMetrics:
  427. reply_count: int = None
  428. quote_count: int = None
  429. retweet_count: int = None
  430. like_count: int = None
  431. @dataclass_validate
  432. @dataclass
  433. class NonPublicMetrics:
  434. impression_count: int = None
  435. user_profile_clicks: int = None
  436. url_link_clicks: int = None
  437. @dataclass_validate
  438. @dataclass
  439. class MediaItem:
  440. height: int
  441. width: int
  442. url: str
  443. media_key: str
  444. type: str
  445. preview_image_url: str
  446. image_url: str
  447. @dataclass_validate
  448. @dataclass
  449. class Card:
  450. display_url: Optional[str] = None
  451. source_url: Optional[str] = None
  452. content: Optional[str] = None
  453. title: Optional[str] = None
  454. @dataclass_validate
  455. @dataclass
  456. class TweetModel:
  457. id: str
  458. text: str
  459. created_at: str
  460. display_name: str
  461. handle: str
  462. author_is_verified: Optional[bool] = None
  463. url: Optional[str] = None
  464. conversation_id: Optional[str] = None
  465. avi_icon_url: Optional[str] = None
  466. author_url: Optional[str] = None
  467. author_id: Optional[str] = None
  468. source_url: Optional[str] = None
  469. source_author_url: Optional[str] = None
  470. reply_depth: Optional[int] = 0
  471. is_marked: Optional[bool] = None
  472. card: Optional[Card] = None
  473. public_metrics: Optional[PublicMetrics] = None
  474. non_public_metrics: Optional[NonPublicMetrics] = None
  475. retweeted_tweet_id: Optional[str] = None
  476. source_retweeted_by_url: Optional[str] = None
  477. retweeted_by: Optional[str] = None
  478. retweeted_by_url: Optional[str] = None
  479. videos: Optional[List[MediaItem]] = None
  480. photos: Optional[List[MediaItem]] = None
  481. quoted_tweet_id: Optional[str] = None
  482. quoted_tweet: Optional['TweetModel'] = None
  483. replied_tweet_id: Optional[str] = None
  484. replied_tweet: Optional['TweetModel'] = None
  485. def update(self, new):
  486. for key, value in new.items():
  487. if hasattr(self, key):
  488. setattr(self, key, value)
  489. dataclass_type_validator(self)
  490. # tm = TweetModel(id="1", text="aa", created_at="fs", display_name="fda", handle="fdsafas")
  491. def tweet_model (includes, tweet_data, me, my_url_for=url_for, reply_depth=0):
  492. # retweeted_by, avi_icon_url, display_name, handle, created_at, text
  493. user = list(filter(lambda u: u.get('id') == tweet_data['author_id'], includes.get('users')))[0]
  494. url = my_url_for('.get_tweet_html', tweet_id=tweet_data['id'])
  495. source_url = 'https://twitter.com/{}/status/{}'.format(user['username'], tweet_data['id'])
  496. avi_icon_url = user['profile_image_url']
  497. retweet_of = None
  498. quoted = None
  499. replied_to = None
  500. if 'referenced_tweets' in tweet_data:
  501. retweet_of = list(filter(lambda r: r['type'] == 'retweeted', tweet_data['referenced_tweets']))
  502. quoted = list(filter(lambda r: r['type'] == 'quoted', tweet_data['referenced_tweets']))
  503. replied_to = list(filter(lambda r: r['type'] == 'replied_to', tweet_data['referenced_tweets']))
  504. t = {
  505. 'id': tweet_data['id'],
  506. 'text': tweet_data['text'],
  507. 'created_at': tweet_data['created_at'],
  508. 'author_is_verified': user['verified'],
  509. 'url': url,
  510. 'conversation_id': tweet_data['conversation_id'],
  511. 'avi_icon_url': avi_icon_url,
  512. 'display_name': user['name'],
  513. 'handle': user['username'],
  514. 'author_url': my_url_for('.get_profile_html', user_id=user['id']),
  515. 'author_id': user['id'],
  516. 'source_url': source_url,
  517. 'source_author_url': 'https://twitter.com/{}'.format(user['username']),
  518. #'is_edited': len(tweet_data['edit_history_tweet_ids']) > 1
  519. }
  520. if reply_depth:
  521. t['reply_depth'] = reply_depth
  522. # HACK we should not refer to the request directly...
  523. if request and request.args.get('marked_reply') == str(t['id']):
  524. t['is_marked'] = True
  525. # This is where we should put "is_bookmark", "is_liked", "is_in_collection", etc...
  526. if 'entities' in tweet_data:
  527. if 'urls' in tweet_data['entities']:
  528. urls = list(filter(lambda u: 'title' in u and 'description' in u, tweet_data['entities']['urls']))
  529. if len(urls):
  530. url = urls[0]
  531. t['card'] = {
  532. 'display_url': url['display_url'].split('/')[0],
  533. 'source_url': url['unwound_url'],
  534. 'content': url['description'],
  535. 'title': url['title']
  536. }
  537. if 'public_metrics' in tweet_data:
  538. t['public_metrics'] = {
  539. 'reply_count': tweet_data['public_metrics']['reply_count'],
  540. 'quote_count': tweet_data['public_metrics']['quote_count'],
  541. 'retweet_count': tweet_data['public_metrics']['retweet_count'],
  542. 'like_count': tweet_data['public_metrics']['like_count']
  543. }
  544. try:
  545. pm = PublicMetrics(**t['public_metrics'])
  546. except:
  547. print('error populating public_metrics')
  548. if 'non_public_metrics' in tweet_data:
  549. t['non_public_metrics'] = {
  550. 'impression_count': tweet_data['non_public_metrics']['impression_count'],
  551. 'user_profile_clicks': tweet_data['non_public_metrics']['user_profile_clicks']
  552. }
  553. if 'url_link_clicks' in tweet_data['non_public_metrics']:
  554. t['non_public_metrics']['url_link_clicks'] = tweet_data['non_public_metrics']['url_link_clicks']
  555. if retweet_of and len(retweet_of):
  556. t['retweeted_tweet_id'] = retweet_of[0]['id']
  557. retweeted_tweet = list(filter(lambda t: t.get('id') == retweet_of[0]['id'], includes.get('tweets')))[0]
  558. t.update({
  559. 'source_retweeted_by_url': 'https://twitter.com/{}'.format(user['username']),
  560. 'retweeted_by': user['name'],
  561. 'retweeted_by_url': url_for('.get_profile_html', user_id=user['id'])
  562. })
  563. rt = tweet_model(includes, retweeted_tweet, me)
  564. t.update(rt)
  565. try:
  566. if 'attachments' in tweet_data and 'media_keys' in tweet_data['attachments']:
  567. media_keys = tweet_data['attachments']['media_keys']
  568. def first_media (mk):
  569. medias = list(filter(lambda m: m['media_key'] == mk, includes['media']))
  570. if len(medias):
  571. return medias[0]
  572. return None
  573. media = filter(lambda m: m != None, map(first_media, media_keys))
  574. #print('found media=')
  575. #print(media)
  576. photos = filter(lambda m: m['type'] == 'photo', media)
  577. videos = filter(lambda m: m['type'] == 'video', media)
  578. photos = map(lambda p: {**p, 'preview_image_url': p['url'] + '?name=tiny&format=webp'}, photos)
  579. videos = map(lambda p: {**p, 'image_url': p['preview_image_url'], 'preview_image_url': p['preview_image_url'] + '?name=tiny&format=webp'}, videos)
  580. t['photos'] = list(photos)
  581. t['videos'] = list(videos)
  582. except:
  583. # it seems like this comes when we have a retweeted tweet with media on it.
  584. print('exception adding attachments to tweet:')
  585. print(tweet_data)
  586. print('view tweet:')
  587. print(t)
  588. print('included media:')
  589. print(includes.get('media'))
  590. try:
  591. if quoted and len(quoted):
  592. t['quoted_tweet_id'] = quoted[0]['id']
  593. quoted_tweets = list(filter(lambda t: t.get('id') == quoted[0]['id'], includes.get('tweets')))
  594. if len(quoted_tweets):
  595. t['quoted_tweet'] = tweet_model(includes, quoted_tweets[0], me)
  596. except:
  597. print('error adding quoted tweet')
  598. try:
  599. if replied_to and len(replied_to):
  600. t['replied_tweet_id'] = replied_to[0]['id']
  601. if reply_depth < 1:
  602. replied_tweets = list(filter(lambda t: t.get('id') == replied_to[0]['id'], includes.get('tweets')))
  603. if len(replied_tweets):
  604. print("Found replied Tweet (t={}, rep={})".format(t['id'], t['replied_tweet_id']))
  605. t['replied_tweet'] = tweet_model(includes, replied_tweets[0], me, reply_depth=reply_depth + 1)
  606. else:
  607. print("No replied tweet found (t={}, rep={})".format(t['id'], t['replied_tweet_id']))
  608. except:
  609. print('error adding replied_to tweet')
  610. try:
  611. tm = TweetModel(**t)
  612. except:
  613. print('error populating TweetModel')
  614. return t
  615. def tweet_paginated_timeline ():
  616. return
  617. # This is a hybrid of get_tweet_html and get_collection_html, where we feed in the IDs.
  618. @twitter_app.route('/data/tweets', methods=['GET'])
  619. def get_twitter_tweets ():
  620. user_id = g.twitter_user['id']
  621. token = g.twitter_user['access_token']
  622. ids = request.args.get('ids')
  623. max_id=''
  624. if ids:
  625. ids = ids.split(',')
  626. tweet_source = ApiV2TweetSource(token)
  627. response_json = tweet_source.get_tweets(ids)
  628. user = {
  629. 'id': user_id
  630. }
  631. query = {}
  632. if 'HX-Request' in request.headers:
  633. includes = response_json.get('includes')
  634. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  635. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  636. else:
  637. return jsonify(response_json)
  638. @twitter_app.route('/data/mentions/<user_id>', methods=['GET'])
  639. def get_data_mentions (user_id):
  640. token = g.twitter_user['access_token']
  641. pagination_token = request.args.get('pagination_token')
  642. tweet_source = ApiV2TweetSource(token)
  643. response_json = tweet_source.get_mentions_timeline(user_id,
  644. pagination_token = pagination_token)
  645. # the OG tweet is in the include.tweets collection.
  646. # All thread tweets are as well, clearly. Does it cost a fetch?
  647. #print(response_json)
  648. includes = response_json.get('includes')
  649. tweets = list(map(lambda t: tweet_model(includes, t, token), response_json['data']))
  650. related_tweets = [] # derived from includes
  651. tweets.reverse()
  652. next_token = response_json.get('meta').get('next_token')
  653. query = {}
  654. if next_token:
  655. query = {
  656. **query,
  657. 'next_data_url': url_for('.get_data_metnions', pagination_token=next_token)
  658. }
  659. if 'HX-Request' in request.headers:
  660. user = {
  661. 'id': user_id
  662. }
  663. # console.log(res.tweets.map(t => t.text).join("\n\n-\n\n"))
  664. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  665. else:
  666. response_body = json.dumps({
  667. 'tweets': tweets,
  668. 'pagination_token': pagination_token,
  669. 'next_token': next_token
  670. })
  671. return jsonify(response_body)
  672. @twitter_app.route('/data/between/<user_id>/<user2_id>', methods=['GET'])
  673. def get_data_between (user_id, user2_id):
  674. token = g.twitter_user['access_token']
  675. pagination_token = request.args.get('pagination_token')
  676. if user_id == 'me':
  677. user_id = g.twitter_user['id']
  678. if user2_id == 'me':
  679. user2_id = g.twitter_user['id']
  680. search_query = "(from:{} to:{}) OR (to:{} from:{})".format(user_id, user2_id, user_id, user2_id)
  681. tweet_source = ApiV2TweetSource(token)
  682. response_json = tweet_source.search_tweets(search_query,
  683. pagination_token = pagination_token)
  684. # the OG tweet is in the include.tweets collection.
  685. # All thread tweets are as well, clearly. Does it cost a fetch?
  686. #print(response_json)
  687. # augment with archive if one of the users is me
  688. # /twitter-archive/tweets/search?in_reply_to_user_id=__
  689. # /twitter-archive/tweets/search?q=@__
  690. tweets = []
  691. next_token = None
  692. if response_json.get('meta').get('result_count'):
  693. includes = response_json.get('includes')
  694. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  695. related_tweets = [] # derived from includes
  696. next_token = response_json.get('meta').get('next_token')
  697. tweets.reverse()
  698. query = {}
  699. if next_token:
  700. query = {
  701. **query,
  702. 'next_data_url': url_for('.get_data_between', pagination_token=next_token)
  703. }
  704. if 'HX-Request' in request.headers:
  705. user = {
  706. 'id': twitter['id']
  707. }
  708. # console.log(res.tweets.map(t => t.text).join("\n\n-\n\n"))
  709. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query, me = me)
  710. else:
  711. response_body = json.dumps({
  712. 'tweets': tweets,
  713. 'pagination_token': pagination_token,
  714. 'next_token': next_token
  715. })
  716. return jsonify(response_body)
  717. @twitter_app.route('/data/thread/<tweet_id>', methods=['GET'])
  718. def get_data_thread (tweet_id):
  719. user_id = g.twitter_user['id']
  720. token = g.twitter_user['access_token']
  721. pagination_token = request.args.get('pagination_token')
  722. tweet_source = ApiV2TweetSource(token)
  723. response_json = tweet_source.get_thread(tweet_id,
  724. author_id=user_id,
  725. pagination_token = pagination_token)
  726. # the OG tweet is in the include.tweets collection.
  727. # All thread tweets are as well, clearly. Does it cost a fetch?
  728. #print(response_json)
  729. tweets = []
  730. next_token = None
  731. if response_json.get('meta').get('result_count'):
  732. includes = response_json.get('includes')
  733. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  734. # FIXME this method is OK except it doesn't work if there are no replies.
  735. #tweets.append(tweet_model(includes, list(filter(lambda t: t['id'] == tweet_id, includes.get('tweets')))[0], me))
  736. #related_tweets = [] # derived from includes
  737. next_token = response_json.get('meta').get('next_token')
  738. if not pagination_token:
  739. response_json = tweet_source.get_tweet(tweet_id)
  740. print("parent tweet=")
  741. #print(response_json)
  742. includes = response_json.get('includes')
  743. tweet = response_json.get('data')[0]
  744. tweets.append(tweet_model(includes, tweet, g.me))
  745. tweets.reverse()
  746. query = {}
  747. if next_token:
  748. query = {
  749. **query,
  750. 'next_data_url': url_for('.get_data_thread', tweet_id=tweet_id, pagination_token=next_token)
  751. }
  752. if 'HX-Request' in request.headers:
  753. user = {
  754. 'id': user_id
  755. }
  756. # console.log(res.tweets.map(t => t.text).join("\n\n-\n\n"))
  757. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  758. else:
  759. response_body = json.dumps({
  760. 'tweets': tweets,
  761. 'pagination_token': pagination_token,
  762. 'next_token': next_token
  763. })
  764. return jsonify(response_body)
  765. @twitter_app.route('/data/conversation/<tweet_id>', methods=['GET'])
  766. def get_data_conversation (tweet_id):
  767. user_id = g.twitter_user['id']
  768. token = g.twitter_user['access_token']
  769. pagination_token = request.args.get('pagination_token')
  770. only_replies = request.args.get('only_replies')
  771. tweet_source = ApiV2TweetSource(token)
  772. # seems to get l
  773. response_json = tweet_source.get_thread(tweet_id,
  774. only_replies = only_replies == '1',
  775. pagination_token = pagination_token)
  776. # the OG tweet is in the include.tweets collection.
  777. # All thread tweets are as well, clearly. Does it cost a fetch?
  778. #print(response_json)
  779. tweets = []
  780. next_token = None
  781. print("conversation meta:")
  782. print(json.dumps(response_json.get('meta'), indent=2))
  783. if response_json.get('meta').get('result_count'):
  784. includes = response_json.get('includes')
  785. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  786. next_token = response_json.get('meta').get('next_token')
  787. # this method is OK except it doesn't work if there are no replies.
  788. #tweets.append(tweet_model(includes, list(filter(lambda t: t['id'] == tweet_id, includes.get('tweets')))[0], me))
  789. if not pagination_token:
  790. response_json = tweet_source.get_tweet(tweet_id)
  791. print("parent tweet=")
  792. print(response_json)
  793. includes = response_json.get('includes')
  794. tweet = response_json.get('data')[0]
  795. tweets.append(tweet_model(includes, tweet, g.me))
  796. #related_tweets = [] # derived from includes
  797. tweets.reverse()
  798. query = {}
  799. if next_token:
  800. query = {
  801. **query,
  802. 'next_data_url': url_for('.get_data_conversation', tweet_id=tweet_id, pagination_token=next_token)
  803. }
  804. if 'HX-Request' in request.headers:
  805. user = {
  806. 'id': user_id
  807. }
  808. # console.log(res.tweets.map(t => t.text).join("\n\n-\n\n"))
  809. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  810. else:
  811. response_body = {
  812. 'tweets': tweets,
  813. 'pagination_token': pagination_token,
  814. 'next_token': next_token
  815. }
  816. return jsonify(response_body)
  817. @twitter_app.route('/data/likes/<user_id>', methods=['GET'])
  818. def get_data_likes (user_id):
  819. token = g.twitter_user['access_token']
  820. pagination_token = request.args.get('pagination_token')
  821. tweet_source = ApiV2TweetSource(token)
  822. response_json = tweet_source.get_likes(user_id,
  823. pagination_token = pagination_token)
  824. ts = int(time.time() * 1000)
  825. with open(f'{DATA_DIR}/cache/likes_{user_id}_{ts}_{pagination_token}.json', 'wt') as f:
  826. f.write(json.dumps(response_json))
  827. includes = response_json.get('includes')
  828. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  829. next_token = response_json.get('meta').get('next_token')
  830. query = {}
  831. if next_token:
  832. query = {
  833. **query,
  834. 'next_data_url': url_for('.get_data_likes', user_id=user_id, pagination_token=next_token)
  835. }
  836. if 'HX-Request' in request.headers:
  837. user = {
  838. 'id': user_id
  839. }
  840. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  841. else:
  842. response_body = json.dumps({
  843. 'tweets': tweets,
  844. 'query': query
  845. })
  846. return jsonify(response_body)
  847. @twitter_app.route('/data/tweets/user/<user_id>/media', methods=['GET'])
  848. def get_data_tweets_media (user_id):
  849. token = g.twitter_user['access_token']
  850. pagination_token = request.args.get('pagination_token')
  851. tweet_source = ApiV2TweetSource(token)
  852. response_json = tweet_source.get_media_tweets(author_id=user_id,
  853. has_images=True,
  854. is_reply=False,
  855. is_retweet=False,
  856. pagination_token = pagination_token)
  857. includes = response_json.get('includes')
  858. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  859. next_token = response_json.get('meta').get('next_token')
  860. query = {}
  861. if next_token:
  862. query = {
  863. **query,
  864. 'next_data_url': url_for('.get_data_tweets_media', user_id=user_id, pagination_token=next_token)
  865. }
  866. if 'HX-Request' in request.headers:
  867. user = {
  868. 'id': user_id
  869. }
  870. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  871. else:
  872. response_body = json.dumps({
  873. 'tweets': tweets,
  874. 'query': query
  875. })
  876. return jsonify(response_body)
  877. # ---------------------------------------------------------------------------------------------------------
  878. # ---------------------------------------------------------------------------------------------------------
  879. # HTMx views
  880. # ---------------------------------------------------------------------------------------------------------
  881. # ---------------------------------------------------------------------------------------------------------
  882. @twitter_app.route('/latest.html', methods=['GET'])
  883. def get_timeline_home_html (variant = "reverse_chronological", pagination_token=None):
  884. user_id = g.twitter_user['id']
  885. token = g.twitter_user['access_token']
  886. if not pagination_token:
  887. pagination_token = request.args.get('pagination_token')
  888. tweet_source = ApiV2TweetSource(token)
  889. response_json = tweet_source.get_home_timeline(user_id,
  890. pagination_token = pagination_token)
  891. #print(json.dumps(response_json, indent=2))
  892. includes = response_json.get('includes')
  893. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  894. next_token = response_json.get('meta').get('next_token')
  895. query = {}
  896. if next_token:
  897. query = {
  898. **query,
  899. #'next_data_url': url_for('.get_data_timeline_home', variant=variant, pagination_token=next_token),
  900. 'next_data_url': url_for('.get_timeline_home_html', pagination_token=next_token),
  901. 'next_page_url': url_for('.get_timeline_home_html', pagination_token=next_token)
  902. }
  903. user = {
  904. 'id': user_id
  905. }
  906. if 'HX-Request' in request.headers:
  907. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  908. else:
  909. return render_template('tweet-collection.html', user = user, tweets = tweets, query = query)
  910. @twitter_app.route('/bookmarks.html', methods=['GET'])
  911. def get_bookmarks_html ():
  912. user_id = g.twitter_user['id']
  913. token = g.twitter_user['access_token']
  914. pagination_token = request.args.get('pagination_token')
  915. tweet_source = ApiV2TweetSource(token)
  916. response_json = tweet_source.get_bookmarks(user_id,
  917. pagination_token = pagination_token)
  918. #print(response_json)
  919. includes = response_json.get('includes')
  920. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  921. next_token = response_json.get('meta').get('next_token')
  922. query = {}
  923. if next_token:
  924. query = {
  925. **query,
  926. 'next_data_url': url_for('.get_bookmarks_html', user_id=user_id, pagination_token=next_token),
  927. 'next_page_url': url_for('.get_bookmarks_html', user_id=user_id, pagination_token=next_token)
  928. }
  929. user = {
  930. 'id': user_id
  931. }
  932. if 'HX-Request' in request.headers:
  933. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  934. else:
  935. return render_template('tweet-collection.html', user = user, tweets = tweets, query = query)
  936. @twitter_app.route('/conversations.html', methods=['GET'])
  937. def get_conversations_html ():
  938. user_id = g.twitter_user['id']
  939. token = g.twitter_user['access_token']
  940. pagination_token = request.args.get('pagination_token')
  941. max_results = int(request.args.get('max_results', 10))
  942. # https://developer.twitter.com/en/docs/twitter-api/direct-messages/lookup/api-reference/get-dm_events
  943. url = "https://api.twitter.com/2/dm_events"
  944. params = {
  945. "dm_event.fields": "id,event_type,text,created_at,dm_conversation_id,sender_id,participant_ids,referenced_tweets,attachments",
  946. "expansions": ",".join(["sender_id", "participant_ids"]),
  947. "max_results": max_results,
  948. "user.fields": ",".join(["id", "created_at", "name", "username", "location", "profile_image_url", "url", "verified"])
  949. }
  950. if pagination_token:
  951. params['pagination_token'] = pagination_token
  952. headers = {"Authorization": "Bearer {}".format(token)}
  953. response = requests.get(url, params=params, headers=headers)
  954. response_json = json.loads(response.text)
  955. print(response.text)
  956. dm_events = response_json.get('data')
  957. next_token = response_json.get('meta').get('next_token')
  958. query = {
  959. 'pagination_token': pagination_token,
  960. 'next_token': next_token
  961. }
  962. user = {
  963. 'id': user_id
  964. }
  965. return render_template('conversations.html', user = user, dm_events = dm_events, query = query)
  966. @twitter_app.route('/profile/<user_id>.html', methods=['GET'])
  967. def get_profile_html (user_id):
  968. token = g.twitter_user['access_token']
  969. is_me = user_id == g.twitter_user['id']
  970. pagination_token = request.args.get('pagination_token')
  971. exclude_replies = request.args.get('exclude_replies', '0')
  972. exclude_retweets = request.args.get('exclude_retweets', '0')
  973. tweet_source = ApiV2TweetSource(token)
  974. response_json = tweet_source.get_user_timeline(user_id,
  975. exclude_replies = exclude_replies == '1',
  976. exclude_retweets = exclude_retweets == '1',
  977. pagination_token = pagination_token,
  978. non_public_metrics = is_me)
  979. ts = int(time.time() * 1000)
  980. with open(f'{DATA_DIR}/cache/tl_{user_id}_{ts}_{pagination_token}.json', 'wt') as f:
  981. f.write(json.dumps(response_json))
  982. includes = response_json.get('includes')
  983. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  984. next_token = response_json.get('meta').get('next_token')
  985. query = {}
  986. if next_token:
  987. query = {
  988. **query,
  989. 'next_data_url': url_for('.get_profile_html', user_id=user_id, pagination_token=next_token, exclude_replies=exclude_replies, exclude_retweets=exclude_retweets),
  990. 'next_page_url': url_for('.get_profile_html', user_id=user_id , pagination_token=next_token, exclude_replies=exclude_replies, exclude_retweets=exclude_retweets)
  991. }
  992. profile_user = {
  993. 'id': user_id
  994. }
  995. if 'HX-Request' in request.headers:
  996. return render_template('partial/tweets-timeline.html', user = profile_user, tweets = tweets, query = query)
  997. else:
  998. return render_template('user-profile.html', user = profile_user, tweets = tweets, query = query)
  999. @twitter_app.route('/media/upload', methods=['POST'])
  1000. def post_media_upload ():
  1001. token = g.twitter_user['access_token']
  1002. form = {
  1003. 'media_category': 'tweet_image'
  1004. }
  1005. headers = {
  1006. 'Authorization': 'Bearer {}'.format(token)
  1007. }
  1008. url = 'http://localhost:5004/twitter/fake-twitter/media/upload'
  1009. #url = 'https://upload.twitter.com/1.1/media/upload.json' # .json
  1010. upload_media = {}
  1011. for e in request.files.items():
  1012. media_name = e[0]
  1013. f = e[1]
  1014. print('.')
  1015. files = {'media': [secure_filename(f.filename), BufferedReader(f), f.content_type]}
  1016. response = requests.post(url, files=files, data=form, headers=headers)
  1017. print(response.status_code)
  1018. print(response.text)
  1019. response_json = json.loads(response.text)
  1020. upload_media[media_name] = response_json
  1021. return jsonify({'upload_media': upload_media})
  1022. @twitter_app.route('/fake-twitter/media/upload', methods=['POST'])
  1023. def post_media_upload2 ():
  1024. print(request.content_type)
  1025. f = request.files.get('media')
  1026. f.seek(0,2)
  1027. media_size = f.tell()
  1028. media = {
  1029. #'_auth': request.headers.get('Authorization'),
  1030. 'media_key': '3_{}'.format(secure_filename(f.filename)),
  1031. 'media_id': secure_filename(f.filename),
  1032. 'size': media_size,
  1033. 'expires_after_secs': 86400,
  1034. 'image': {
  1035. 'image_type': f.content_type,
  1036. 'w': 1,
  1037. 'h': 1
  1038. }
  1039. }
  1040. return jsonify(media)