parser.nim 15 KB

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