parser.nim 14 KB

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