item_collections.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. from dataclasses import asdict, replace
  2. from importlib.util import find_spec
  3. import os
  4. import json
  5. from flask import request, g, jsonify, render_template, Blueprint, url_for, session
  6. from twitter_v2.api import ApiV2TweetSource
  7. from .view_model import FeedItem, cleandict
  8. from .content_system import get_all_content, register_content_source
  9. twitter_enabled = False
  10. if find_spec('twitter_v2_facade'):
  11. from twitter_v2_facade.view_model import tweet_model_dc_vm
  12. twitter_enabled = True
  13. youtube_enabled = False
  14. if find_spec('youtube_facade'):
  15. from youtube_facade import youtube_model, get_youtube_builder
  16. youtube_enabled = True
  17. DATA_DIR=".data"
  18. item_collections_bp = Blueprint('item_collections', 'item_collections',
  19. static_folder='static',
  20. static_url_path='',
  21. url_prefix='/')
  22. def get_tweet_collection (collection_id):
  23. with open(f'{DATA_DIR}/collection/{collection_id}.json', 'rt', encoding='utf-8') as f:
  24. collection = json.loads(f.read())
  25. return collection
  26. def collection_from_card_source (url):
  27. """
  28. temp1 = await fetch('http://localhost:5000/notes/cards/search?q=twitter.com/&limit=10').then(r => r.json())
  29. re = /(http[^\s]+twitter\.com\/[^\/]+\/status\/[\d]+)/ig
  30. tweetLinks = temp1.cards.map(c => c.card.content).map(c => c.match(re))
  31. tweetLinks2 = tweetLinks.flat().filter(l => l)
  32. tweetLinksS = Array.from(new Set(tweetLinks2))
  33. statusUrls = tweetLinksS.map(s => new URL(s))
  34. //users = Array.from(new Set(statusUrls.map(s => s.pathname.split('/')[1])))
  35. ids = Array.from(new Set(statusUrls.map(s => parseInt(s.pathname.split('/')[3]))))
  36. """
  37. """
  38. temp1 = JSON.parse(document.body.innerText)
  39. // get swipe note + created_at + tweet user + tweet ID
  40. tweetCards = temp1.cards.map(c => c.card).filter(c => c.content.match(re))
  41. tweets = tweetCards.map(c => ({created_at: c.created_at, content: c.content, tweets: c.content.match(re).map(m => new URL(m))}))
  42. tweets.filter(t => t.tweets.filter(t2 => t2.user.toLowerCase() == 'stephenmpinto').length)
  43. // HN
  44. re = /(http[^\s]+news.ycombinator\.com\/[^\s]+\=[\d]+)/ig
  45. linkCards = temp1.cards.map(c => c.card).filter(c => c.content.match(re))
  46. links = linkCards.map(c => ({created_at: c.created_at, content: c.content, links: c.content.match(re).map(m => new URL(m))}))
  47. // YT (I thnk I've already done this one)
  48. """
  49. # more in 2022 twitter report
  50. return None
  51. def expand_item (item, me, tweets = None, includes = None, yt_videos = None):
  52. if 'id' in item:
  53. t = list(filter(lambda t: item['id'] == t.id, tweets))
  54. if not len(t):
  55. print("no tweet for item: " + item['id'])
  56. feed_item = FeedItem(
  57. id = item['id'],
  58. text = "(Deleted, suspended or blocked)",
  59. created_at = "",
  60. handle = "error",
  61. display_name = "Error"
  62. )
  63. # FIXME 1) put this in relative order to the collection
  64. # FIXME 2) we can use the tweet link to get the user ID...
  65. else:
  66. t = t[0]
  67. feed_item = tweet_model_dc_vm(includes, t, me)
  68. note = item.get('note')
  69. feed_item = replace(feed_item, note = note)
  70. elif 'yt_id' in item:
  71. yt_id = item['yt_id']
  72. vid = list(filter(lambda v: v['id'] == yt_id, yt_videos))[0]
  73. feed_item = youtube_model(vid)
  74. note = item.get('note')
  75. feed_item.update({'note': note})
  76. return feed_item
  77. # pagination token is the next tweet_ID
  78. @item_collections_bp.get('/collection/<collection_id>.html')
  79. def get_collection_html (collection_id):
  80. me = request.args.get('me')
  81. acct = session.get(me)
  82. max_results = int(request.args.get('max_results', 10))
  83. pagination_token = int(request.args.get('pagination_token', 0))
  84. collection = get_tweet_collection(collection_id)
  85. if 'authorized_users' in collection and (not acct or not me in collection['authorized_users']):
  86. return 'access denied.', 403
  87. items = collection['items'][pagination_token:(pagination_token + max_results)]
  88. if not len(items):
  89. return 'no tweets', 404
  90. twitter_token = os.environ.get('BEARER_TOKEN')
  91. if me and me.startswith('twitter:') and acct:
  92. twitter_token = acct['access_token']
  93. tweet_source = ApiV2TweetSource(twitter_token)
  94. tweet_ids = filter(lambda i: 'id' in i, items)
  95. tweet_ids = list(map(lambda item: item['id'], tweet_ids))
  96. tweets_response = tweet_source.get_tweets( tweet_ids, return_dataclass=True )
  97. yt_ids = filter(lambda i: 'yt_id' in i, items)
  98. yt_ids = list(map(lambda item: item['yt_id'], yt_ids))
  99. youtube = get_youtube_builder()
  100. videos_response = youtube.videos().list(id=','.join(yt_ids), part='snippet,contentDetails,liveStreamingDetails,statistics,recordingDetails', maxResults=1).execute()
  101. #print(response_json)
  102. if tweets_response.errors:
  103. # types:
  104. # https://api.twitter.com/2/problems/not-authorized-for-resource (blocked or suspended)
  105. # https://api.twitter.com/2/problems/resource-not-found (deleted)
  106. #print(response_json.get('errors'))
  107. for err in tweets_response.errors:
  108. if not 'type' in err:
  109. print('unknown error type: ' + str(err))
  110. elif err['type'] == 'https://api.twitter.com/2/problems/not-authorized-for-resource':
  111. print('blocked or suspended tweet: ' + err['value'])
  112. elif err['type'] == 'https://api.twitter.com/2/problems/resource-not-found':
  113. print('deleted tweet: ' + err['value'])
  114. else:
  115. print('unknown error')
  116. print(json.dumps(err, indent=2))
  117. includes = tweets_response.includes
  118. tweets = tweets_response.data
  119. feed_items = list(map(lambda item: expand_item(item, me, tweets, includes, videos_response['items']), items))
  120. if request.args.get('format') == 'json':
  121. return jsonify({'ids': tweet_ids,
  122. 'tweets': cleandict(asdict(tweets_response)),
  123. 'feed_items': feed_items,
  124. 'items': items,
  125. 'pagination_token': pagination_token})
  126. else:
  127. query = {}
  128. if pagination_token:
  129. query['next_data_url'] = url_for('.get_collection_html', collection_id=collection_id, pagination_token=pagination_token)
  130. if 'HX-Request' in request.headers:
  131. return render_template('partial/tweets-timeline.html', tweets = feed_items, user = {}, query = query)
  132. else:
  133. if pagination_token:
  134. query['next_page_url'] = url_for('.get_collection_html', collection_id=collection_id, pagination_token=pagination_token)
  135. return render_template('tweet-collection.html', tweets = feed_items, user = {}, query = query)
  136. # pagination token is the next tweet_ID
  137. @item_collections_bp.get('/collections.html')
  138. def get_collections_html ():
  139. me = request.args.get('me')
  140. acct = session.get(me)
  141. collections = []
  142. with os.scandir('.data/collection') as collections_files:
  143. for collection_file in collections_files:
  144. if not collection_file.name.endswith('.json'):
  145. continue
  146. with open(collection_file.path, 'rt', encoding='utf-8') as f:
  147. coll = json.load(f)
  148. if 'authorized_users' in coll and (not acct or not me in coll['authorized_users']):
  149. continue
  150. collection_id = collection_file.name[:-len('.json')]
  151. coll_info = dict(
  152. collection_id = collection_id,
  153. href = url_for('.get_collection_html', collection_id=collection_id)
  154. )
  155. collections.append(coll_info)
  156. return jsonify(collections)
  157. @item_collections_bp.post('/data/collection/create/from-cards')
  158. def post_data_collection_create_from_cards ():
  159. """
  160. // create collection from search, supporting multiple Tweets per card and Tweets in multiple Cards.
  161. re = /(https?[a-z0-9\.\/\:]+twitter\.com\/[0-9a-z\_]+\/status\/[\d]+)/ig
  162. temp1 = await fetch('http://localhost:5000/notes/cards/search?q=twitter.com/').then(r => r.json())
  163. cardMatches = temp1.cards
  164. .map(cm => Object.assign({}, cm, {tweetLinks: Array.from(new Set(cm.card.content.match(re)))}))
  165. .filter(cm => cm.tweetLinks && cm.tweetLinks.length)
  166. .map(cm => Object.assign({}, cm, {tweetUrls: cm.tweetLinks.map(l => new URL(l))}))
  167. .map(cm => Object.assign({}, cm, {tweetInfos: cm.tweetUrls.map(u => ({user: u.pathname.split('/')[1], tweetId: u.pathname.split('/')[3]}))}));
  168. collectionCards = {}
  169. cardMatches.forEach(function (cm) {
  170. if (!cm.tweetLinks.length) { return; }
  171. cm.tweetInfos.forEach(function (ti) {
  172. if (!collectionCards[ti.tweetId]) {
  173. collectionCards[ti.tweetId] = [];
  174. }
  175. collectionCards[ti.tweetId].push(cm.card);
  176. })
  177. })
  178. var collectionItems = [];
  179. Object.entries(collectionCards).forEach(function (e) {
  180. var tweetId = e[0], cards = e[1];
  181. var note = cards.map(function (card) {
  182. return card.created_at + "\n\n" + card.content;
  183. }).join("\n\n-\n\n");
  184. collectionItems.push({id: tweetId, note: note, tweet_infos: cm.tweetInfos, card_infos: cards.map(c => 'card#' + c.id)});
  185. })
  186. """
  187. collection = {
  188. 'items': [], # described in JS function above
  189. 'authorized_users': [g.twitter_user['id']]
  190. }
  191. return jsonify(collection)
  192. def expand_item2 (item, me, tweet_contents = None, includes = None, youtube_contents = None):
  193. if 'id' in item:
  194. tweets_response = tweet_contents[ 'twitter:tweet:' + item['id'] ]
  195. tweets = tweets_response.items
  196. t = list(filter(lambda t: item['id'] == t.id, tweets))
  197. if not len(t):
  198. print("no tweet for item: " + item['id'])
  199. feed_item = FeedItem(
  200. id = item['id'],
  201. text = "(Deleted, suspended or blocked)",
  202. created_at = "",
  203. handle = "error",
  204. display_name = "Error"
  205. )
  206. # FIXME 1) put this in relative order to the collection
  207. # FIXME 2) we can use the tweet link to get the user ID...
  208. else:
  209. feed_item = t[0]
  210. note = item.get('note')
  211. feed_item = replace(feed_item, note = note)
  212. elif 'yt_id' in item:
  213. yt_id = item['yt_id']
  214. yt_videos = youtube_contents[ 'youtube:video:' + yt_id ]
  215. feed_item = list(filter(lambda v: v['id'] == yt_id, yt_videos))[0]
  216. note = item.get('note')
  217. feed_item.update({'note': note})
  218. return feed_item
  219. def get_collection (collection_id, pagination_token=None, max_results=10):
  220. collection = get_tweet_collection(collection_id)
  221. return collection
  222. register_content_source("collection:", get_collection, id_pattern="([^:]+)")
  223. # pagination token is the next tweet_ID
  224. @item_collections_bp.get('/collection2/<collection_id>.html')
  225. def get_collection2_html (collection_id):
  226. me = request.args.get('me')
  227. acct = session.get(me)
  228. max_results = int(request.args.get('max_results', 10))
  229. pagination_token = int(request.args.get('pagination_token', 0))
  230. #collection = get_tweet_collection(collection_id)
  231. collection = get_content(f'collection:{collection_id}',
  232. pagination_token=pagination_token,
  233. max_results=max_results)
  234. if 'authorized_users' in collection and (not acct or not me in collection['authorized_users']):
  235. return 'access denied.', 403
  236. items = collection['items'][pagination_token:(pagination_token + max_results)]
  237. if not len(items):
  238. return 'no tweets', 404
  239. tweet_ids = filter(lambda i: 'id' in i, items)
  240. tweet_ids = list(map(lambda item: 'twitter:tweet:' + item['id'], tweet_ids))
  241. tweet_contents = get_all_content( tweet_ids )
  242. yt_ids = filter(lambda i: 'yt_id' in i, items)
  243. yt_ids = list(map(lambda item: 'youtube:video:' + item['yt_id'], yt_ids))
  244. youtube_contents = get_all_content( yt_ids )
  245. includes = None
  246. feed_items = list(map(lambda item: expand_item2(item, me, tweet_contents, includes, youtube_contents), items))
  247. if request.args.get('format') == 'json':
  248. return jsonify({'ids': tweet_ids,
  249. 'tweets': cleandict(asdict(tweets_response)),
  250. 'feed_items': feed_items,
  251. 'items': items,
  252. 'pagination_token': pagination_token})
  253. else:
  254. query = {}
  255. if pagination_token:
  256. query['next_data_url'] = url_for('.get_collection_html', collection_id=collection_id, pagination_token=pagination_token)
  257. if 'HX-Request' in request.headers:
  258. return render_template('partial/tweets-timeline.html', tweets = feed_items, user = {}, query = query)
  259. else:
  260. if pagination_token:
  261. query['next_page_url'] = url_for('.get_collection_html', collection_id=collection_id, pagination_token=pagination_token)
  262. return render_template('tweet-collection.html', tweets = feed_items, user = {}, query = query)