parser.nim 12 KB

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