parser.nim 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  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; isLegacy=false): 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. verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
  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"}, user{"rest_id"}.getStr)
  32. if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
  33. result.verifiedType = blue
  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. # fix for pinned threads
  189. if result.hasThread and result.threadId == 0:
  190. result.threadId = js{"self_thread", "id_str"}.getId
  191. if "retweeted_status" in js:
  192. result.retweet = some Tweet()
  193. elif js{"is_quote_status"}.getBool:
  194. result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
  195. # legacy
  196. with rt, js{"retweeted_status_id_str"}:
  197. result.retweet = some Tweet(id: rt.getId)
  198. return
  199. # graphql
  200. with rt, js{"retweeted_status_result", "result"}:
  201. # needed due to weird edgecase where the actual tweet data isn't included
  202. if "legacy" in rt:
  203. result.retweet = some parseGraphTweet(rt)
  204. return
  205. if jsCard.kind != JNull:
  206. let name = jsCard{"name"}.getStr
  207. if "poll" in name:
  208. if "image" in name:
  209. result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
  210. result.poll = some parsePoll(jsCard)
  211. elif name == "amplify":
  212. result.video = some(parsePromoVideo(jsCard{"binding_values"}))
  213. else:
  214. result.card = some parseCard(jsCard, js{"entities", "urls"})
  215. result.expandTweetEntities(js)
  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 parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
  248. if js.kind == JNull:
  249. return Tweet()
  250. case js{"__typename"}.getStr
  251. of "TweetUnavailable":
  252. return Tweet()
  253. of "TweetTombstone":
  254. with text, js{"tombstone", "richText"}:
  255. return Tweet(text: text.getTombstone)
  256. with text, js{"tombstone", "text"}:
  257. return Tweet(text: text.getTombstone)
  258. return Tweet()
  259. of "TweetPreviewDisplay":
  260. return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
  261. of "TweetWithVisibilityResults":
  262. return parseGraphTweet(js{"tweet"}, isLegacy)
  263. else:
  264. discard
  265. if not js.hasKey("legacy"):
  266. return Tweet()
  267. var jsCard = copy(js{if isLegacy: "card" else: "tweet_card", "legacy"})
  268. if jsCard.kind != JNull:
  269. var values = newJObject()
  270. for val in jsCard["binding_values"]:
  271. values[val["key"].getStr] = val["value"]
  272. jsCard["binding_values"] = values
  273. result = parseTweet(js{"legacy"}, jsCard)
  274. result.id = js{"rest_id"}.getId
  275. result.user = parseGraphUser(js{"core"})
  276. with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
  277. result.expandNoteTweetEntities(noteTweet)
  278. if result.quote.isSome:
  279. result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy))
  280. proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
  281. for t in js{"content", "items"}:
  282. let entryId = t{"entryId"}.getStr
  283. if "cursor-showmore" in entryId:
  284. let cursor = t{"item", "content", "value"}
  285. result.thread.cursor = cursor.getStr
  286. result.thread.hasMore = true
  287. elif "tweet" in entryId:
  288. let
  289. isLegacy = t{"item"}.hasKey("itemContent")
  290. (contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
  291. else: ("content", "tweetResult")
  292. with content, t{"item", contentKey}:
  293. result.thread.content.add parseGraphTweet(content{resultKey, "result"}, isLegacy)
  294. if content{"tweetDisplayType"}.getStr == "SelfThread":
  295. result.self = true
  296. proc parseGraphTweetResult*(js: JsonNode): Tweet =
  297. with tweet, js{"data", "tweet_result", "result"}:
  298. result = parseGraphTweet(tweet, false)
  299. proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversation =
  300. result = Conversation(replies: Result[Chain](beginning: true))
  301. let
  302. rootKey = if v2: "timeline_response" else: "threaded_conversation_with_injections_v2"
  303. contentKey = if v2: "content" else: "itemContent"
  304. resultKey = if v2: "tweetResult" else: "tweet_results"
  305. let instructions = ? js{"data", rootKey, "instructions"}
  306. if instructions.len == 0:
  307. return
  308. for e in instructions[0]{"entries"}:
  309. let entryId = e{"entryId"}.getStr
  310. if entryId.startsWith("tweet"):
  311. with tweetResult, e{"content", contentKey, resultKey, "result"}:
  312. let tweet = parseGraphTweet(tweetResult, not v2)
  313. if not tweet.available:
  314. tweet.id = parseBiggestInt(entryId.getId())
  315. if $tweet.id == tweetId:
  316. result.tweet = tweet
  317. else:
  318. result.before.content.add tweet
  319. elif entryId.startsWith("conversationthread"):
  320. let (thread, self) = parseGraphThread(e)
  321. if self:
  322. result.after = thread
  323. else:
  324. result.replies.content.add thread
  325. elif entryId.startsWith("tombstone"):
  326. let id = entryId.getId()
  327. let tweet = Tweet(
  328. id: parseBiggestInt(id),
  329. available: false,
  330. text: e{"content", contentKey, "tombstoneInfo", "richText"}.getTombstone
  331. )
  332. if id == tweetId:
  333. result.tweet = tweet
  334. else:
  335. result.before.content.add tweet
  336. elif entryId.startsWith("cursor-bottom"):
  337. result.replies.bottom = e{"content", contentKey, "value"}.getStr
  338. proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
  339. result = Profile(tweets: Timeline(beginning: after.len == 0))
  340. let instructions =
  341. if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"}
  342. else: ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
  343. if instructions.len == 0:
  344. return
  345. for i in instructions:
  346. if i{"__typename"}.getStr == "TimelineAddEntries":
  347. for e in i{"entries"}:
  348. let entryId = e{"entryId"}.getStr
  349. if entryId.startsWith("tweet"):
  350. with tweetResult, e{"content", "content", "tweetResult", "result"}:
  351. let tweet = parseGraphTweet(tweetResult, false)
  352. if not tweet.available:
  353. tweet.id = parseBiggestInt(entryId.getId())
  354. result.tweets.content.add tweet
  355. elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
  356. let (thread, self) = parseGraphThread(e)
  357. result.tweets.content.add thread.content
  358. elif entryId.startsWith("cursor-bottom"):
  359. result.tweets.bottom = e{"content", "value"}.getStr
  360. if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry":
  361. with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}:
  362. let tweet = parseGraphTweet(tweetResult, false)
  363. tweet.pinned = true
  364. if not tweet.available and tweet.tombstone.len == 0:
  365. let entryId = i{"entry", "entryId"}.getEntryId
  366. if entryId.len > 0:
  367. tweet.id = parseBiggestInt(entryId)
  368. result.pinned = some tweet
  369. proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
  370. result = @[]
  371. let instructions =
  372. ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
  373. for i in instructions:
  374. if i{"__typename"}.getStr == "TimelineAddEntries":
  375. for e in i{"entries"}:
  376. let entryId = e{"entryId"}.getStr
  377. if entryId.startsWith("tweet"):
  378. with tweetResult, e{"content", "content", "tweetResult", "result"}:
  379. let t = parseGraphTweet(tweetResult, false)
  380. if not t.available:
  381. t.id = parseBiggestInt(entryId.getId())
  382. let url =
  383. if t.photos.len > 0: t.photos[0]
  384. elif t.video.isSome: get(t.video).thumb
  385. elif t.gif.isSome: get(t.gif).thumb
  386. elif t.card.isSome: get(t.card).image
  387. else: ""
  388. result.add GalleryPhoto(url: url, tweetId: $t.id)
  389. if result.len == 16:
  390. break
  391. proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
  392. result = Result[T](beginning: after.len == 0)
  393. let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
  394. if instructions.len == 0:
  395. return
  396. for instruction in instructions:
  397. let typ = instruction{"type"}.getStr
  398. if typ == "TimelineAddEntries":
  399. for e in instruction{"entries"}:
  400. let entryId = e{"entryId"}.getStr
  401. when T is Tweets:
  402. if entryId.startsWith("tweet"):
  403. with tweetRes, e{"content", "itemContent", "tweet_results", "result"}:
  404. let tweet = parseGraphTweet(tweetRes)
  405. if not tweet.available:
  406. tweet.id = parseBiggestInt(entryId.getId())
  407. result.content.add tweet
  408. elif T is User:
  409. if entryId.startsWith("user"):
  410. with userRes, e{"content", "itemContent"}:
  411. result.content.add parseGraphUser(userRes)
  412. if entryId.startsWith("cursor-bottom"):
  413. result.bottom = e{"content", "value"}.getStr
  414. elif typ == "TimelineReplaceEntry":
  415. if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
  416. result.bottom = instruction{"entry", "content", "value"}.getStr