parser.nim 13 KB

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