parser.nim 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. # SPDX-License-Identifier: AGPL-3.0-only
  2. import strutils, options, times, math, tables
  3. import packedjson, packedjson/deserialiser
  4. import types, parserutils, utils
  5. import experimental/parser/unifiedcard
  6. proc parseGraphTweet(js: JsonNode): Tweet
  7. proc parseCommunityNote(js: JsonNode): string =
  8. let subtitle = js{"subtitle"}
  9. result = subtitle{"text"}.getStr
  10. with entities, subtitle{"entities"}:
  11. result = expandBirdwatchEntities(result, entities)
  12. proc parseUser(js: JsonNode; id=""): User =
  13. if js.isNull: return
  14. result = User(
  15. id: if id.len > 0: id else: js{"id_str"}.getStr,
  16. username: js{"screen_name"}.getStr,
  17. fullname: js{"name"}.getStr,
  18. location: js{"location"}.getStr,
  19. bio: js{"description"}.getStr,
  20. userPic: js{"profile_image_url_https"}.getImageStr.replace("_normal", ""),
  21. banner: js.getBanner,
  22. following: js{"friends_count"}.getInt,
  23. followers: js{"followers_count"}.getInt,
  24. tweets: js{"statuses_count"}.getInt,
  25. likes: js{"favourites_count"}.getInt,
  26. media: js{"media_count"}.getInt,
  27. protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
  28. joinDate: js{"created_at"}.getTime
  29. )
  30. if js{"is_blue_verified"}.getBool(false):
  31. result.verifiedType = blue
  32. with verifiedType, js{"verified_type"}:
  33. result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
  34. result.expandUserEntities(js)
  35. proc parseGraphUser(js: JsonNode): User =
  36. var user = js{"user_result", "result"}
  37. if user.isNull:
  38. user = ? js{"user_results", "result"}
  39. if user.isNull:
  40. if js{"core"}.notNull and js{"legacy"}.notNull:
  41. user = js
  42. else:
  43. return
  44. result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
  45. if result.verifiedType == none and user{"is_blue_verified"}.getBool(false):
  46. result.verifiedType = blue
  47. # fallback to support UserMedia/recent GraphQL updates
  48. if result.username.len == 0:
  49. result.username = user{"core", "screen_name"}.getStr
  50. result.fullname = user{"core", "name"}.getStr
  51. result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
  52. if user{"is_blue_verified"}.getBool(false):
  53. result.verifiedType = blue
  54. with verifiedType, user{"verification", "verified_type"}:
  55. result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
  56. proc parseGraphList*(js: JsonNode): List =
  57. if js.isNull: return
  58. var list = js{"data", "user_by_screen_name", "list"}
  59. if list.isNull:
  60. list = js{"data", "list"}
  61. if list.isNull:
  62. return
  63. result = List(
  64. id: list{"id_str"}.getStr,
  65. name: list{"name"}.getStr,
  66. username: list{"user_results", "result", "legacy", "screen_name"}.getStr,
  67. userId: list{"user_results", "result", "rest_id"}.getStr,
  68. description: list{"description"}.getStr,
  69. members: list{"member_count"}.getInt,
  70. banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr
  71. )
  72. proc parsePoll(js: JsonNode): Poll =
  73. let vals = js{"binding_values"}
  74. # name format is pollNchoice_*
  75. for i in '1' .. js{"name"}.getStr[4]:
  76. let choice = "choice" & i
  77. result.values.add parseInt(vals{choice & "_count"}.getStrVal("0"))
  78. result.options.add vals{choice & "_label"}.getStrVal
  79. let time = vals{"end_datetime_utc", "string_value"}.getDateTime
  80. if time > now():
  81. let timeLeft = $(time - now())
  82. result.status = timeLeft[0 ..< timeLeft.find(",")]
  83. else:
  84. result.status = "Final results"
  85. result.leader = result.values.find(max(result.values))
  86. result.votes = result.values.sum
  87. proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] =
  88. result = @[]
  89. for v in variants:
  90. let
  91. url = v{"url"}.getStr
  92. contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4"))
  93. bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0))
  94. result.add VideoVariant(
  95. contentType: contentType,
  96. bitrate: bitrate,
  97. url: url,
  98. resolution: if contentType == mp4: getMp4Resolution(url) else: 0
  99. )
  100. proc parseVideo(js: JsonNode): Video =
  101. result = Video(
  102. thumb: js{"media_url_https"}.getImageStr,
  103. available: true,
  104. title: js{"ext_alt_text"}.getStr,
  105. durationMs: js{"video_info", "duration_millis"}.getInt
  106. # playbackType: mp4
  107. )
  108. with status, js{"ext_media_availability", "status"}:
  109. if status.getStr.len > 0 and status.getStr.toLowerAscii != "available":
  110. result.available = false
  111. with title, js{"additional_media_info", "title"}:
  112. result.title = title.getStr
  113. with description, js{"additional_media_info", "description"}:
  114. result.description = description.getStr
  115. result.variants = parseVideoVariants(js{"video_info", "variants"})
  116. proc addMedia(media: var MediaEntities; photo: Photo) =
  117. media.add Media(kind: photoMedia, photo: photo)
  118. proc addMedia(media: var MediaEntities; video: Video) =
  119. media.add Media(kind: videoMedia, video: video)
  120. proc addMedia(media: var MediaEntities; gif: Gif) =
  121. media.add Media(kind: gifMedia, gif: gif)
  122. proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
  123. with jsMedia, js{"extended_entities", "media"}:
  124. for m in jsMedia:
  125. case m.getTypeName:
  126. of "photo":
  127. result.media.addMedia(Photo(
  128. url: m{"media_url_https"}.getImageStr,
  129. altText: m{"ext_alt_text"}.getStr
  130. ))
  131. of "video":
  132. result.media.addMedia(parseVideo(m))
  133. with user, m{"additional_media_info", "source_user"}:
  134. if user{"id"}.getInt > 0:
  135. result.attribution = some(parseUser(user))
  136. else:
  137. result.attribution = some(parseGraphUser(user))
  138. of "animated_gif":
  139. result.media.addMedia(Gif(
  140. url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
  141. thumb: m{"media_url_https"}.getImageStr,
  142. altText: m{"ext_alt_text"}.getStr
  143. ))
  144. else: discard
  145. with url, m{"url"}:
  146. if result.text.endsWith(url.getStr):
  147. result.text.removeSuffix(url.getStr)
  148. result.text = result.text.strip()
  149. proc parseMediaEntities(js: JsonNode; result: var Tweet) =
  150. with mediaEntities, js{"media_entities"}:
  151. var parsedMedia: MediaEntities
  152. for mediaEntity in mediaEntities:
  153. with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
  154. case mediaInfo.getTypeName
  155. of "ApiImage":
  156. parsedMedia.addMedia(Photo(
  157. url: mediaInfo{"original_img_url"}.getImageStr,
  158. altText: mediaInfo{"alt_text"}.getStr
  159. ))
  160. of "ApiVideo":
  161. let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
  162. parsedMedia.addMedia(Video(
  163. available: status.getStr == "Available",
  164. thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
  165. title: mediaInfo{"alt_text"}.getStr,
  166. durationMs: mediaInfo{"duration_millis"}.getInt,
  167. variants: parseVideoVariants(mediaInfo{"variants"})
  168. ))
  169. of "ApiGif":
  170. parsedMedia.addMedia(Gif(
  171. url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
  172. thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
  173. altText: mediaInfo{"alt_text"}.getStr
  174. ))
  175. else: discard
  176. if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
  177. result.media = parsedMedia
  178. # Remove media URLs from text
  179. with mediaList, js{"legacy", "entities", "media"}:
  180. for url in mediaList:
  181. let expandedUrl = url.getExpandedUrl
  182. if result.text.endsWith(expandedUrl):
  183. result.text.removeSuffix(expandedUrl)
  184. result.text = result.text.strip()
  185. proc parsePromoVideo(js: JsonNode): Video =
  186. result = Video(
  187. thumb: js{"player_image_large"}.getImageVal,
  188. available: true,
  189. durationMs: js{"content_duration_seconds"}.getStrVal("0").parseInt * 1000,
  190. playbackType: vmap
  191. )
  192. var variant = VideoVariant(
  193. contentType: vmap,
  194. url: js{"player_hls_url"}.getStrVal(js{"player_stream_url"}.getStrVal(
  195. js{"amplify_url_vmap"}.getStrVal()))
  196. )
  197. if "m3u8" in variant.url:
  198. variant.contentType = m3u8
  199. result.playbackType = m3u8
  200. result.variants.add variant
  201. proc parseBroadcast(js: JsonNode): Card =
  202. let image = js{"broadcast_thumbnail_large"}.getImageVal
  203. result = Card(
  204. kind: broadcast,
  205. url: js{"broadcast_url"}.getStrVal,
  206. title: js{"broadcaster_display_name"}.getStrVal,
  207. text: js{"broadcast_title"}.getStrVal,
  208. image: image,
  209. video: some Video(thumb: image)
  210. )
  211. proc parseCard(js: JsonNode; urls: JsonNode): Card =
  212. const imageTypes = ["summary_photo_image", "player_image", "promo_image",
  213. "photo_image_full_size", "thumbnail_image", "thumbnail",
  214. "event_thumbnail", "image"]
  215. let
  216. vals = ? js{"binding_values"}
  217. name = js{"name"}.getStr
  218. kind = parseEnum[CardKind](name[(name.find(":") + 1) ..< name.len], unknown)
  219. if kind == unified:
  220. return parseUnifiedCard(vals{"unified_card", "string_value"}.getStr)
  221. result = Card(
  222. kind: kind,
  223. url: vals.getCardUrl(kind),
  224. dest: vals.getCardDomain(kind),
  225. title: vals.getCardTitle(kind),
  226. text: vals{"description"}.getStrVal
  227. )
  228. if result.url.len == 0:
  229. result.url = js{"url"}.getStr
  230. case kind
  231. of promoVideo, promoVideoConvo, appPlayer, videoDirectMessage:
  232. result.video = some parsePromoVideo(vals)
  233. if kind == appPlayer:
  234. result.text = vals{"app_category"}.getStrVal(result.text)
  235. of broadcast:
  236. result = parseBroadcast(vals)
  237. of liveEvent:
  238. result.text = vals{"event_title"}.getStrVal
  239. of player:
  240. result.url = vals{"player_url"}.getStrVal
  241. if "youtube.com" in result.url:
  242. result.url = result.url.replace("/embed/", "/watch?v=")
  243. of audiospace, unknown:
  244. result.title = "This card type is not supported."
  245. else: discard
  246. for typ in imageTypes:
  247. with img, vals{typ & "_large"}:
  248. result.image = img.getImageVal
  249. break
  250. for u in ? urls:
  251. if u{"url"}.getStr == result.url:
  252. result.url = u.getExpandedUrl(result.url)
  253. break
  254. if kind in {videoDirectMessage, imageDirectMessage}:
  255. result.url.setLen 0
  256. if kind in {promoImageConvo, promoImageApp, imageDirectMessage} and
  257. result.url.len == 0 or result.url.startsWith("card://"):
  258. result.url = getPicUrl(result.image)
  259. proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
  260. replyId: int64 = 0): Tweet =
  261. if js.isNull: return
  262. let time =
  263. if js{"created_at"}.notNull: js{"created_at"}.getTime
  264. else: js{"created_at_ms"}.getTimeFromMs
  265. result = Tweet(
  266. id: js{"id_str"}.getId,
  267. threadId: js{"conversation_id_str"}.getId,
  268. replyId: js{"in_reply_to_status_id_str"}.getId,
  269. text: js{"full_text"}.getStr,
  270. time: time,
  271. hasThread: js{"self_thread"}.notNull,
  272. available: true,
  273. user: User(id: js{"user_id_str"}.getStr),
  274. stats: TweetStats(
  275. replies: js{"reply_count"}.getInt,
  276. retweets: js{"retweet_count"}.getInt,
  277. likes: js{"favorite_count"}.getInt,
  278. views: js{"views_count"}.getInt
  279. )
  280. )
  281. if result.replyId == 0:
  282. result.replyId = replyId
  283. # fix for pinned threads
  284. if result.hasThread and result.threadId == 0:
  285. result.threadId = js{"self_thread", "id_str"}.getId
  286. if "retweeted_status" in js:
  287. result.retweet = some Tweet()
  288. elif js{"is_quote_status"}.getBool:
  289. result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
  290. # legacy
  291. with rt, js{"retweeted_status_id_str"}:
  292. result.retweet = some Tweet(id: rt.getId)
  293. return
  294. # graphql
  295. with rt, js{"retweeted_status_result", "result"}:
  296. # needed due to weird edgecase where the actual tweet data isn't included
  297. if "legacy" in rt:
  298. result.retweet = some parseGraphTweet(rt)
  299. return
  300. with reposts, js{"repostedStatusResults"}:
  301. with rt, reposts{"result"}:
  302. if "legacy" in rt:
  303. result.retweet = some parseGraphTweet(rt)
  304. return
  305. if jsCard.kind != JNull:
  306. let name = jsCard{"name"}.getStr
  307. if "poll" in name:
  308. if "image" in name:
  309. result.media.addMedia(Photo(
  310. url: jsCard{"binding_values", "image_large"}.getImageVal
  311. ))
  312. result.poll = some parsePoll(jsCard)
  313. elif name == "amplify":
  314. result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
  315. else:
  316. result.card = some parseCard(jsCard, js{"entities", "urls"})
  317. result.expandTweetEntities(js)
  318. parseLegacyMediaEntities(js, result)
  319. with jsWithheld, js{"withheld_in_countries"}:
  320. let withheldInCountries: seq[string] =
  321. if jsWithheld.kind != JArray: @[]
  322. else: jsWithheld.to(seq[string])
  323. # XX - Content is withheld in all countries
  324. # XY - Content is withheld due to a DMCA request.
  325. if js{"withheld_copyright"}.getBool or
  326. withheldInCountries.len > 0 and ("XX" in withheldInCountries or
  327. "XY" in withheldInCountries or
  328. "withheld" in result.text):
  329. result.text.removeSuffix(" Learn more.")
  330. result.available = false
  331. proc parseGraphTweet(js: JsonNode): Tweet =
  332. if js.kind == JNull:
  333. return Tweet()
  334. case js.getTypeName:
  335. of "TweetUnavailable":
  336. return Tweet()
  337. of "TweetTombstone":
  338. with text, select(js{"tombstone", "richText"}, js{"tombstone", "text"}):
  339. return Tweet(text: text.getTombstone)
  340. return Tweet()
  341. of "TweetPreviewDisplay":
  342. return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
  343. of "TweetWithVisibilityResults":
  344. return parseGraphTweet(js{"tweet"})
  345. else:
  346. discard
  347. if not js.hasKey("legacy"):
  348. return Tweet()
  349. var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
  350. if jsCard.kind != JNull:
  351. let legacyCard = jsCard{"legacy"}
  352. if legacyCard.kind != JNull:
  353. let bindingArray = legacyCard{"binding_values"}
  354. if bindingArray.kind == JArray:
  355. var bindingObj: seq[(string, JsonNode)]
  356. for item in bindingArray:
  357. bindingObj.add((item{"key"}.getStr, item{"value"}))
  358. # Create a new card object with flattened structure
  359. jsCard = %*{
  360. "name": legacyCard{"name"},
  361. "url": legacyCard{"url"},
  362. "binding_values": %bindingObj
  363. }
  364. var replyId = 0
  365. with restId, js{"reply_to_results", "rest_id"}:
  366. replyId = restId.getId
  367. result = parseTweet(js{"legacy"}, jsCard, replyId)
  368. result.id = js{"rest_id"}.getId
  369. result.user = parseGraphUser(js{"core"})
  370. if result.reply.len == 0:
  371. with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}:
  372. result.reply = @[replyTo.getStr]
  373. with count, js{"views", "count"}:
  374. result.stats.views = count.getStr("0").parseInt
  375. with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
  376. result.expandNoteTweetEntities(noteTweet)
  377. parseMediaEntities(js, result)
  378. with quoted, js{"quoted_status_result", "result"}:
  379. result.quote = some(parseGraphTweet(quoted))
  380. with quoted, js{"quotedPostResults"}:
  381. if "result" in quoted:
  382. result.quote = some(parseGraphTweet(quoted{"result"}))
  383. else:
  384. result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId)
  385. with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}:
  386. for id in ids:
  387. result.history.add parseBiggestInt(id.getStr)
  388. with birdwatch, js{"birdwatch_pivot"}:
  389. result.note = parseCommunityNote(birdwatch)
  390. proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
  391. for t in ? js{"content", "items"}:
  392. let entryId = t.getEntryId
  393. if "tweet-" in entryId and "promoted" notin entryId:
  394. let tweet = t.getTweetResult("item")
  395. if tweet.notNull:
  396. result.thread.content.add parseGraphTweet(tweet)
  397. let tweetDisplayType = select(
  398. t{"item", "content", "tweet_display_type"},
  399. t{"item", "itemContent", "tweetDisplayType"}
  400. )
  401. if tweetDisplayType.getStr == "SelfThread":
  402. result.self = true
  403. else:
  404. result.thread.content.add Tweet(id: entryId.getId)
  405. elif "cursor-showmore" in entryId:
  406. let cursor = t{"item", "content", "value"}
  407. result.thread.cursor = cursor.getStr
  408. result.thread.hasMore = true
  409. proc parseGraphTweetResult*(js: JsonNode): Tweet =
  410. with tweet, js{"data", "tweet_result", "result"}:
  411. result = parseGraphTweet(tweet)
  412. proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
  413. result = Conversation(replies: Result[Chain](beginning: true))
  414. let instructions = ? select(
  415. js{"data", "timelineResponse", "instructions"},
  416. js{"data", "timeline_response", "instructions"},
  417. js{"data", "threaded_conversation_with_injections_v2", "instructions"}
  418. )
  419. if instructions.len == 0:
  420. return
  421. for i in instructions:
  422. if i.getTypeName == "TimelineAddEntries":
  423. for e in i{"entries"}:
  424. let entryId = e.getEntryId
  425. if entryId.startsWith("tweet-"):
  426. let tweetResult = getTweetResult(e)
  427. if tweetResult.notNull:
  428. let tweet = parseGraphTweet(tweetResult)
  429. if not tweet.available:
  430. tweet.id = entryId.getId
  431. if entryId.endsWith(tweetId):
  432. result.tweet = tweet
  433. else:
  434. result.before.content.add tweet
  435. elif not entryId.endsWith(tweetId):
  436. result.before.content.add Tweet(id: entryId.getId)
  437. elif entryId.startsWith("conversationthread"):
  438. let (thread, self) = parseGraphThread(e)
  439. if self:
  440. result.after = thread
  441. elif thread.content.len > 0:
  442. result.replies.content.add thread
  443. elif entryId.startsWith("tombstone"):
  444. let
  445. content = select(e{"content", "content"}, e{"content", "itemContent"})
  446. tweet = Tweet(
  447. id: entryId.getId,
  448. available: false,
  449. text: content{"tombstoneInfo", "richText"}.getTombstone
  450. )
  451. if $tweet.id == tweetId:
  452. result.tweet = tweet
  453. else:
  454. result.before.content.add tweet
  455. elif entryId.startsWith("cursor-bottom"):
  456. var cursorValue = select(
  457. e{"content", "value"},
  458. e{"content", "content", "value"},
  459. e{"content", "itemContent", "value"}
  460. )
  461. result.replies.bottom = cursorValue.getStr
  462. proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory =
  463. let instructions = ? js{
  464. "data", "tweet_result_by_rest_id", "result",
  465. "edit_history_timeline", "timeline", "instructions"
  466. }
  467. if instructions.len == 0:
  468. return
  469. for i in instructions:
  470. if i.getTypeName == "TimelineAddEntries":
  471. for e in i{"entries"}:
  472. let entryId = e.getEntryId
  473. if entryId == "latestTweet":
  474. with item, e{"content", "items"}[0]:
  475. let tweetResult = item.getTweetResult("item")
  476. if tweetResult.notNull:
  477. result.latest = parseGraphTweet(tweetResult)
  478. elif entryId == "staleTweets":
  479. for item in e{"content", "items"}:
  480. let tweetResult = item.getTweetResult("item")
  481. if tweetResult.notNull:
  482. result.history.add parseGraphTweet(tweetResult)
  483. proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
  484. with tweetResult, getTweetResult(e):
  485. var tweet = parseGraphTweet(tweetResult)
  486. if not tweet.available:
  487. tweet.id = e.getEntryId.getId
  488. result.add tweet
  489. return
  490. for item in e{"content", "items"}:
  491. with tweetResult, item.getTweetResult("item"):
  492. var tweet = parseGraphTweet(tweetResult)
  493. if not tweet.available:
  494. tweet.id = item.getEntryId.getId
  495. result.add tweet
  496. proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
  497. result = Profile(tweets: Timeline(beginning: after.len == 0))
  498. let instructions = ? select(
  499. js{"data", "list", "timeline_response", "timeline", "instructions"},
  500. js{"data", "user", "result", "timeline", "timeline", "instructions"},
  501. js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
  502. )
  503. if instructions.len == 0:
  504. return
  505. for i in instructions:
  506. if i{"moduleItems"}.notNull:
  507. for item in i{"moduleItems"}:
  508. with tweetResult, item.getTweetResult("item"):
  509. let tweet = parseGraphTweet(tweetResult)
  510. if not tweet.available:
  511. tweet.id = item.getEntryId.getId
  512. result.tweets.content.add tweet
  513. continue
  514. if i{"entries"}.notNull:
  515. for e in i{"entries"}:
  516. let entryId = e.getEntryId
  517. if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
  518. for tweet in extractTweetsFromEntry(e):
  519. result.tweets.content.add tweet
  520. elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
  521. let (thread, self) = parseGraphThread(e)
  522. result.tweets.content.add thread.content
  523. elif entryId.startsWith("cursor-bottom"):
  524. result.tweets.bottom = e{"content", "value"}.getStr
  525. if after.len == 0:
  526. if i.getTypeName == "TimelinePinEntry":
  527. let tweets = extractTweetsFromEntry(i{"entry"})
  528. if tweets.len > 0:
  529. var tweet = tweets[0]
  530. tweet.pinned = true
  531. result.pinned = some tweet
  532. proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
  533. result = @[]
  534. let instructions = select(
  535. js{"data", "user", "result", "timeline", "timeline", "instructions"},
  536. js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
  537. )
  538. if instructions.len == 0:
  539. return
  540. for i in instructions:
  541. if i{"moduleItems"}.notNull:
  542. for item in i{"moduleItems"}:
  543. with tweetResult, item.getTweetResult("item"):
  544. let t = parseGraphTweet(tweetResult)
  545. if not t.available:
  546. t.id = item.getEntryId.getId
  547. let photo = extractGalleryPhoto(t)
  548. if photo.url.len > 0:
  549. result.add photo
  550. if result.len == 16:
  551. return
  552. continue
  553. if i.getTypeName != "TimelineAddEntries":
  554. continue
  555. for e in i{"entries"}:
  556. let entryId = e.getEntryId
  557. if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
  558. for t in extractTweetsFromEntry(e):
  559. let photo = extractGalleryPhoto(t)
  560. if photo.url.len > 0:
  561. result.add photo
  562. if result.len == 16:
  563. return
  564. proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
  565. result = Result[T](beginning: after.len == 0)
  566. let instructions = select(
  567. js{"data", "search", "timeline_response", "timeline", "instructions"},
  568. js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
  569. )
  570. if instructions.len == 0:
  571. return
  572. for instruction in instructions:
  573. let typ = getTypeName(instruction)
  574. if typ == "TimelineAddEntries":
  575. for e in instruction{"entries"}:
  576. let entryId = e.getEntryId
  577. when T is Tweets:
  578. if entryId.startsWith("tweet"):
  579. with tweetRes, getTweetResult(e):
  580. let tweet = parseGraphTweet(tweetRes)
  581. if not tweet.available:
  582. tweet.id = entryId.getId
  583. result.content.add tweet
  584. elif T is User:
  585. if entryId.startsWith("user"):
  586. with userRes, e{"content", "itemContent"}:
  587. result.content.add parseGraphUser(userRes)
  588. if entryId.startsWith("cursor-bottom"):
  589. result.bottom = e{"content", "value"}.getStr
  590. elif typ == "TimelineReplaceEntry":
  591. if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
  592. result.bottom = instruction{"entry", "content", "value"}.getStr