parser.nim 29 KB

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