parser.nim 27 KB

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