parser.nim 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. # SPDX-License-Identifier: AGPL-3.0-only
  2. import strutils, options, tables, times, math
  3. import packedjson, packedjson/deserialiser
  4. import types, parserutils, utils
  5. import experimental/parser/unifiedcard
  6. proc parseUser(js: JsonNode; id=""): User =
  7. if js.isNull: return
  8. result = User(
  9. id: if id.len > 0: id else: js{"id_str"}.getStr,
  10. username: js{"screen_name"}.getStr,
  11. fullname: js{"name"}.getStr,
  12. location: js{"location"}.getStr,
  13. bio: js{"description"}.getStr,
  14. userPic: js{"profile_image_url_https"}.getImageStr.replace("_normal", ""),
  15. banner: js.getBanner,
  16. following: js{"friends_count"}.getInt,
  17. followers: js{"followers_count"}.getInt,
  18. tweets: js{"statuses_count"}.getInt,
  19. likes: js{"favourites_count"}.getInt,
  20. media: js{"media_count"}.getInt,
  21. verified: js{"verified"}.getBool,
  22. protected: js{"protected"}.getBool,
  23. joinDate: js{"created_at"}.getTime
  24. )
  25. result.expandUserEntities(js)
  26. proc parseGraphList*(js: JsonNode): List =
  27. if js.isNull: return
  28. var list = js{"data", "user_by_screen_name", "list"}
  29. if list.isNull:
  30. list = js{"data", "list"}
  31. if list.isNull:
  32. return
  33. result = List(
  34. id: list{"id_str"}.getStr,
  35. name: list{"name"}.getStr,
  36. username: list{"user", "legacy", "screen_name"}.getStr,
  37. userId: list{"user", "rest_id"}.getStr,
  38. description: list{"description"}.getStr,
  39. members: list{"member_count"}.getInt,
  40. banner: list{"custom_banner_media", "media_info", "url"}.getImageStr
  41. )
  42. proc parsePoll(js: JsonNode): Poll =
  43. let vals = js{"binding_values"}
  44. # name format is pollNchoice_*
  45. for i in '1' .. js{"name"}.getStr[4]:
  46. let choice = "choice" & i
  47. result.values.add parseInt(vals{choice & "_count"}.getStrVal("0"))
  48. result.options.add vals{choice & "_label"}.getStrVal
  49. let time = vals{"end_datetime_utc", "string_value"}.getDateTime
  50. if time > now():
  51. let timeLeft = $(time - now())
  52. result.status = timeLeft[0 ..< timeLeft.find(",")]
  53. else:
  54. result.status = "Final results"
  55. result.leader = result.values.find(max(result.values))
  56. result.votes = result.values.sum
  57. proc parseGif(js: JsonNode): Gif =
  58. result = Gif(
  59. url: js{"video_info", "variants"}[0]{"url"}.getImageStr,
  60. thumb: js{"media_url_https"}.getImageStr
  61. )
  62. proc parseVideo(js: JsonNode): Video =
  63. result = Video(
  64. thumb: js{"media_url_https"}.getImageStr,
  65. views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt),
  66. available: js{"ext_media_availability", "status"}.getStr.toLowerAscii == "available",
  67. title: js{"ext_alt_text"}.getStr,
  68. durationMs: js{"video_info", "duration_millis"}.getInt
  69. # playbackType: mp4
  70. )
  71. with title, js{"additional_media_info", "title"}:
  72. result.title = title.getStr
  73. with description, js{"additional_media_info", "description"}:
  74. result.description = description.getStr
  75. for v in js{"video_info", "variants"}:
  76. let
  77. contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary"))
  78. url = v{"url"}.getStr
  79. result.variants.add VideoVariant(
  80. contentType: contentType,
  81. bitrate: v{"bitrate"}.getInt,
  82. url: url,
  83. resolution: if contentType == mp4: getMp4Resolution(url) else: 0
  84. )
  85. proc parsePromoVideo(js: JsonNode): Video =
  86. result = Video(
  87. thumb: js{"player_image_large"}.getImageVal,
  88. available: true,
  89. durationMs: js{"content_duration_seconds"}.getStrVal("0").parseInt * 1000,
  90. playbackType: vmap
  91. )
  92. var variant = VideoVariant(
  93. contentType: vmap,
  94. url: js{"player_hls_url"}.getStrVal(js{"player_stream_url"}.getStrVal(
  95. js{"amplify_url_vmap"}.getStrVal()))
  96. )
  97. if "m3u8" in variant.url:
  98. variant.contentType = m3u8
  99. result.playbackType = m3u8
  100. result.variants.add variant
  101. proc parseBroadcast(js: JsonNode): Card =
  102. let image = js{"broadcast_thumbnail_large"}.getImageVal
  103. result = Card(
  104. kind: broadcast,
  105. url: js{"broadcast_url"}.getStrVal,
  106. title: js{"broadcaster_display_name"}.getStrVal,
  107. text: js{"broadcast_title"}.getStrVal,
  108. image: image,
  109. video: some Video(thumb: image)
  110. )
  111. proc parseCard(js: JsonNode; urls: JsonNode): Card =
  112. const imageTypes = ["summary_photo_image", "player_image", "promo_image",
  113. "photo_image_full_size", "thumbnail_image", "thumbnail",
  114. "event_thumbnail", "image"]
  115. let
  116. vals = ? js{"binding_values"}
  117. name = js{"name"}.getStr
  118. kind = parseEnum[CardKind](name[(name.find(":") + 1) ..< name.len], unknown)
  119. if kind == unified:
  120. return parseUnifiedCard(vals{"unified_card", "string_value"}.getStr)
  121. result = Card(
  122. kind: kind,
  123. url: vals.getCardUrl(kind),
  124. dest: vals.getCardDomain(kind),
  125. title: vals.getCardTitle(kind),
  126. text: vals{"description"}.getStrVal
  127. )
  128. if result.url.len == 0:
  129. result.url = js{"url"}.getStr
  130. case kind
  131. of promoVideo, promoVideoConvo, appPlayer, videoDirectMessage:
  132. result.video = some parsePromoVideo(vals)
  133. if kind == appPlayer:
  134. result.text = vals{"app_category"}.getStrVal(result.text)
  135. of broadcast:
  136. result = parseBroadcast(vals)
  137. of liveEvent:
  138. result.text = vals{"event_title"}.getStrVal
  139. of player:
  140. result.url = vals{"player_url"}.getStrVal
  141. if "youtube.com" in result.url:
  142. result.url = result.url.replace("/embed/", "/watch?v=")
  143. of audiospace, unknown:
  144. result.title = "This card type is not supported."
  145. else: discard
  146. for typ in imageTypes:
  147. with img, vals{typ & "_large"}:
  148. result.image = img.getImageVal
  149. break
  150. for u in ? urls:
  151. if u{"url"}.getStr == result.url:
  152. result.url = u{"expanded_url"}.getStr
  153. break
  154. if kind in {videoDirectMessage, imageDirectMessage}:
  155. result.url.setLen 0
  156. if kind in {promoImageConvo, promoImageApp, imageDirectMessage} and
  157. result.url.len == 0 or result.url.startsWith("card://"):
  158. result.url = getPicUrl(result.image)
  159. proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
  160. if js.isNull: return
  161. result = Tweet(
  162. id: js{"id_str"}.getId,
  163. threadId: js{"conversation_id_str"}.getId,
  164. replyId: js{"in_reply_to_status_id_str"}.getId,
  165. text: js{"full_text"}.getStr,
  166. time: js{"created_at"}.getTime,
  167. hasThread: js{"self_thread"}.notNull,
  168. available: true,
  169. user: User(id: js{"user_id_str"}.getStr),
  170. stats: TweetStats(
  171. replies: js{"reply_count"}.getInt,
  172. retweets: js{"retweet_count"}.getInt,
  173. likes: js{"favorite_count"}.getInt,
  174. quotes: js{"quote_count"}.getInt
  175. )
  176. )
  177. # fix for pinned threads
  178. if result.hasThread and result.threadId == 0:
  179. result.threadId = js{"self_thread", "id_str"}.getId
  180. result.expandTweetEntities(js)
  181. if js{"is_quote_status"}.getBool:
  182. result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
  183. with rt, js{"retweeted_status_id_str"}:
  184. result.retweet = some Tweet(id: rt.getId)
  185. return
  186. if jsCard.kind != JNull:
  187. let name = jsCard{"name"}.getStr
  188. if "poll" in name:
  189. if "image" in name:
  190. result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
  191. result.poll = some parsePoll(jsCard)
  192. elif name == "amplify":
  193. result.video = some(parsePromoVideo(jsCard{"binding_values"}))
  194. else:
  195. result.card = some parseCard(jsCard, js{"entities", "urls"})
  196. with jsMedia, js{"extended_entities", "media"}:
  197. for m in jsMedia:
  198. case m{"type"}.getStr
  199. of "photo":
  200. result.photos.add m{"media_url_https"}.getImageStr
  201. of "video":
  202. result.video = some(parseVideo(m))
  203. with user, m{"additional_media_info", "source_user"}:
  204. result.attribution = some(parseUser(user))
  205. of "animated_gif":
  206. result.gif = some(parseGif(m))
  207. else: discard
  208. with jsWithheld, js{"withheld_in_countries"}:
  209. let withheldInCountries: seq[string] =
  210. if jsWithheld.kind != JArray: @[]
  211. else: jsWithheld.to(seq[string])
  212. # XX - Content is withheld in all countries
  213. # XY - Content is withheld due to a DMCA request.
  214. if js{"withheld_copyright"}.getBool or
  215. withheldInCountries.len > 0 and ("XX" in withheldInCountries or
  216. "XY" in withheldInCountries or
  217. "withheld" in result.text):
  218. result.text.removeSuffix(" Learn more.")
  219. result.available = false
  220. proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
  221. let intId = if id.len > 0: parseBiggestInt(id) else: 0
  222. result = global.tweets.getOrDefault(id, Tweet(id: intId))
  223. if result.quote.isSome:
  224. let quote = get(result.quote).id
  225. if $quote in global.tweets:
  226. result.quote = some global.tweets[$quote]
  227. else:
  228. result.quote = some Tweet()
  229. if result.retweet.isSome:
  230. let rt = get(result.retweet).id
  231. if $rt in global.tweets:
  232. result.retweet = some finalizeTweet(global, $rt)
  233. else:
  234. result.retweet = some Tweet()
  235. proc parsePin(js: JsonNode; global: GlobalObjects): Tweet =
  236. let pin = js{"pinEntry", "entry", "entryId"}.getStr
  237. if pin.len == 0: return
  238. let id = pin.getId
  239. if id notin global.tweets: return
  240. global.tweets[id].pinned = true
  241. return finalizeTweet(global, id)
  242. proc parseGlobalObjects(js: JsonNode): GlobalObjects =
  243. result = GlobalObjects()
  244. let
  245. tweets = ? js{"globalObjects", "tweets"}
  246. users = ? js{"globalObjects", "users"}
  247. for k, v in users:
  248. result.users[k] = parseUser(v, k)
  249. for k, v in tweets:
  250. var tweet = parseTweet(v, v{"card"})
  251. if tweet.user.id in result.users:
  252. tweet.user = result.users[tweet.user.id]
  253. result.tweets[k] = tweet
  254. proc parseStatus*(js: JsonNode): Tweet =
  255. with e, js{"errors"}:
  256. if e.getError == tweetNotFound:
  257. return
  258. result = parseTweet(js, js{"card"})
  259. if not result.isNil:
  260. result.user = parseUser(js{"user"})
  261. with quote, js{"quoted_status"}:
  262. result.quote = some parseStatus(js{"quoted_status"})
  263. proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNode) =
  264. if js.kind != JArray or js.len == 0:
  265. return
  266. for i in js:
  267. when T is Tweet:
  268. if res.beginning and i{"pinEntry"}.notNull:
  269. with pin, parsePin(i, global):
  270. res.content.add pin
  271. with r, i{"replaceEntry", "entry"}:
  272. if "top" in r{"entryId"}.getStr:
  273. res.top = r.getCursor
  274. elif "bottom" in r{"entryId"}.getStr:
  275. res.bottom = r.getCursor
  276. proc parseTimeline*(js: JsonNode; after=""): Timeline =
  277. result = Timeline(beginning: after.len == 0)
  278. let global = parseGlobalObjects(? js)
  279. let instructions = ? js{"timeline", "instructions"}
  280. if instructions.len == 0: return
  281. result.parseInstructions(global, instructions)
  282. var entries: JsonNode
  283. for i in instructions:
  284. if "addEntries" in i:
  285. entries = i{"addEntries", "entries"}
  286. for e in ? entries:
  287. let entry = e{"entryId"}.getStr
  288. if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry:
  289. let tweet = finalizeTweet(global, e.getEntryId)
  290. if not tweet.available: continue
  291. result.content.add tweet
  292. elif "cursor-top" in entry:
  293. result.top = e.getCursor
  294. elif "cursor-bottom" in entry:
  295. result.bottom = e.getCursor
  296. elif entry.startsWith("sq-C"):
  297. with cursor, e{"content", "operation", "cursor"}:
  298. if cursor{"cursorType"}.getStr == "Bottom":
  299. result.bottom = cursor{"value"}.getStr
  300. else:
  301. result.top = cursor{"value"}.getStr
  302. proc parsePhotoRail*(js: JsonNode): PhotoRail =
  303. for tweet in js:
  304. let
  305. t = parseTweet(tweet, js{"card"})
  306. url = if t.photos.len > 0: t.photos[0]
  307. elif t.video.isSome: get(t.video).thumb
  308. elif t.gif.isSome: get(t.gif).thumb
  309. elif t.card.isSome: get(t.card).image
  310. else: ""
  311. if url.len == 0: continue
  312. result.add GalleryPhoto(url: url, tweetId: $t.id)
  313. proc parseGraphTweet(js: JsonNode): Tweet =
  314. if js.kind == JNull or js{"__typename"}.getStr == "TweetUnavailable":
  315. return Tweet(available: false)
  316. var jsCard = copy(js{"card", "legacy"})
  317. if jsCard.kind != JNull:
  318. var values = newJObject()
  319. for val in jsCard["binding_values"]:
  320. values[val["key"].getStr] = val["value"]
  321. jsCard["binding_values"] = values
  322. result = parseTweet(js{"legacy"}, jsCard)
  323. result.user = parseUser(js{"core", "user_results", "result", "legacy"})
  324. with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
  325. result.expandNoteTweetEntities(noteTweet)
  326. if result.quote.isSome:
  327. result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
  328. proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
  329. let thread = js{"content", "items"}
  330. for t in js{"content", "items"}:
  331. let entryId = t{"entryId"}.getStr
  332. if "cursor-showmore" in entryId:
  333. let cursor = t{"item", "itemContent", "value"}
  334. result.thread.cursor = cursor.getStr
  335. result.thread.hasMore = true
  336. elif "tweet" in entryId:
  337. let tweet = parseGraphTweet(t{"item", "itemContent", "tweet_results", "result"})
  338. result.thread.content.add tweet
  339. if t{"item", "itemContent", "tweetDisplayType"}.getStr == "SelfThread":
  340. result.self = true
  341. proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
  342. result = Conversation(replies: Result[Chain](beginning: true))
  343. let instructions = ? js{"data", "threaded_conversation_with_injections_v2", "instructions"}
  344. if instructions.len == 0:
  345. return
  346. for e in instructions[0]{"entries"}:
  347. let entryId = e{"entryId"}.getStr
  348. # echo entryId
  349. if entryId.startsWith("tweet"):
  350. let tweet = parseGraphTweet(e{"content", "itemContent", "tweet_results", "result"})
  351. if not tweet.available:
  352. tweet.id = parseBiggestInt(entryId.getId())
  353. if $tweet.id == tweetId:
  354. result.tweet = tweet
  355. else:
  356. result.before.content.add tweet
  357. elif entryId.startsWith("conversationthread"):
  358. let (thread, self) = parseGraphThread(e)
  359. if self:
  360. result.after = thread
  361. else:
  362. result.replies.content.add thread
  363. elif entryId.startsWith("cursor-bottom"):
  364. result.replies.bottom = e{"content", "itemContent", "value"}.getStr