parser.nim 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031
  1. # SPDX-License-Identifier: AGPL-3.0-only
  2. import strutils, options, times, math, tables, uri
  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 parseSpaceParticipant(js: JsonNode): SpaceParticipant =
  110. result = SpaceParticipant(
  111. userId: js{"user_results", "rest_id"}.getStr,
  112. username: js{"twitter_screen_name"}.getStr,
  113. displayName: js{"display_name"}.getStr,
  114. avatarUrl: js{"avatar_url"}.getStr,
  115. isVerified: js{"is_verified"}.getBool or
  116. js{"user_results", "result", "is_blue_verified"}.getBool
  117. )
  118. proc parseAudioSpace*(js: JsonNode): AudioSpace =
  119. let space = ? js{"data", "audioSpace"}
  120. let meta = space{"metadata"}
  121. result = AudioSpace(
  122. id: meta{"rest_id"}.getStr,
  123. title: meta{"title"}.getStr,
  124. state: meta{"state"}.getStr.toUpperAscii,
  125. mediaKey: meta{"media_key"}.getStr,
  126. totalLiveListeners: meta{"total_live_listeners"}.getInt,
  127. totalReplayWatched: meta{"total_replay_watched"}.getInt,
  128. availableForReplay: meta{"is_space_available_for_replay"}.getBool
  129. )
  130. let startedAt = meta{"started_at"}.getInt(0)
  131. if startedAt > 0:
  132. result.startTime = fromUnix(startedAt div 1000).utc()
  133. let endedAtStr = meta{"ended_at"}.getStr
  134. if endedAtStr.len > 0:
  135. try:
  136. let endedAt = parseBiggestInt(endedAtStr)
  137. if endedAt > 0:
  138. result.endTime = fromUnix(endedAt div 1000).utc()
  139. except ValueError:
  140. discard
  141. result.creator = parseGraphUser(meta{"creator_results", "result"})
  142. for admin in space{"participants", "admins"}:
  143. result.admins.add parseSpaceParticipant(admin)
  144. for speaker in space{"participants", "speakers"}:
  145. result.speakers.add parseSpaceParticipant(speaker)
  146. proc parseGraphCommunity*(js: JsonNode): Community =
  147. if js.isNull: return
  148. let c = ? js{"data", "communityResults", "result"}
  149. result = Community(
  150. id: c{"rest_id"}.getStr(c{"id_str"}.getStr),
  151. name: c{"name"}.getStr,
  152. description: c{"description"}.getStr,
  153. memberCount: c{"member_count"}.getInt,
  154. joinPolicy: c{"join_policy"}.getStr,
  155. category: c{"primary_community_topic", "topic_name"}.getStr,
  156. banner: c{"custom_banner_media", "media_info", "original_img_url"}.getImageStr,
  157. creator: parseGraphUser(c{"creator_results", "result"}),
  158. )
  159. let createdMs = c{"created_at"}.getInt(0)
  160. if createdMs > 0:
  161. result.createdAt = fromUnix(createdMs div 1000).utc()
  162. for rule in c{"rules"}:
  163. result.rules.add CommunityRule(
  164. name: rule{"name"}.getStr,
  165. description: rule{"description"}.getStr
  166. )
  167. for item in c{"trending_hashtags_slice", "items"}:
  168. let tag = item{"hashtag"}.getStr
  169. if tag.len > 0:
  170. result.hashtags.add tag
  171. proc parseListObject(js: JsonNode; owner: User): List =
  172. List(
  173. id: js{"id_str"}.getStr,
  174. name: js{"name"}.getStr,
  175. username: owner.username,
  176. userId: owner.id,
  177. description: js{"description"}.getStr,
  178. members: js{"member_count"}.getInt,
  179. banner: select(
  180. js{"custom_banner_media", "media_info", "original_img_url"},
  181. js{"default_banner_media", "media_info", "original_img_url"}
  182. ).getImageStr
  183. )
  184. proc parseGraphList*(js: JsonNode): List =
  185. if js.isNull: return
  186. var list = js{"data", "user_by_screen_name", "list"}
  187. if list.isNull:
  188. list = js{"data", "list"}
  189. if list.isNull:
  190. return
  191. result = parseListObject(list, parseGraphUser(list))
  192. proc parseGraphSearchList(js: JsonNode): ListSearchResult =
  193. let owner = parseGraphUser(js)
  194. result = ListSearchResult(
  195. list: parseListObject(js, owner),
  196. owner: owner,
  197. followersContext: js{"followers_context"}.getStr
  198. )
  199. for url in js{"facepile_urls"}:
  200. result.facepiles.add url.getStr
  201. proc parsePoll(js: JsonNode): Poll =
  202. let vals = js{"binding_values"}
  203. # name format is pollNchoice_*
  204. for i in '1' .. js{"name"}.getStr[4]:
  205. let choice = "choice" & i
  206. result.values.add parseInt(vals{choice & "_count"}.getStrVal("0"))
  207. result.options.add vals{choice & "_label"}.getStrVal
  208. let time = vals{"end_datetime_utc", "string_value"}.getDateTime
  209. if time > now():
  210. let timeLeft = $(time - now())
  211. result.status = timeLeft[0 ..< timeLeft.find(",")]
  212. else:
  213. result.status = "Final results"
  214. result.leader = result.values.find(max(result.values))
  215. result.votes = result.values.sum
  216. proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] =
  217. result = @[]
  218. for v in variants:
  219. let
  220. url = v{"url"}.getStr
  221. contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4"))
  222. bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0))
  223. result.add VideoVariant(
  224. contentType: contentType,
  225. bitrate: bitrate,
  226. url: url,
  227. resolution: if contentType == mp4: getMp4Resolution(url) else: 0
  228. )
  229. proc parseVideo(js: JsonNode): Video =
  230. result = Video(
  231. thumb: js{"media_url_https"}.getImageStr,
  232. available: true,
  233. title: js{"ext_alt_text"}.getStr,
  234. durationMs: js{"video_info", "duration_millis"}.getInt
  235. # playbackType: mp4
  236. )
  237. with status, js{"ext_media_availability", "status"}:
  238. if status.getStr.len > 0 and status.getStr.toLowerAscii != "available":
  239. result.available = false
  240. with title, js{"additional_media_info", "title"}:
  241. result.title = title.getStr
  242. with description, js{"additional_media_info", "description"}:
  243. result.description = description.getStr
  244. result.variants = parseVideoVariants(js{"video_info", "variants"})
  245. proc addMedia(media: var MediaEntities; photo: Photo) =
  246. media.add Media(kind: photoMedia, photo: photo)
  247. proc addMedia(media: var MediaEntities; video: Video) =
  248. media.add Media(kind: videoMedia, video: video)
  249. proc addMedia(media: var MediaEntities; gif: Gif) =
  250. media.add Media(kind: gifMedia, gif: gif)
  251. proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
  252. with jsMedia, js{"extended_entities", "media"}:
  253. for m in jsMedia:
  254. case m.getTypeName:
  255. of "photo":
  256. result.media.addMedia(Photo(
  257. url: m{"media_url_https"}.getImageStr,
  258. altText: m{"ext_alt_text"}.getStr
  259. ))
  260. of "video":
  261. result.media.addMedia(parseVideo(m))
  262. with user, m{"additional_media_info", "source_user"}:
  263. if user{"id"}.getInt > 0:
  264. result.attribution = some(parseUser(user))
  265. else:
  266. result.attribution = some(parseGraphUser(user))
  267. # Set attribution link from expanded_url (strip /video/N suffix)
  268. let expanded = m{"expanded_url"}.getStr
  269. if expanded.len > 0:
  270. result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
  271. of "animated_gif":
  272. result.media.addMedia(Gif(
  273. url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
  274. thumb: m{"media_url_https"}.getImageStr,
  275. altText: m{"ext_alt_text"}.getStr
  276. ))
  277. else: discard
  278. proc parseMediaEntities(js: JsonNode; result: var Tweet) =
  279. with mediaEntities, js{"media_entities"}:
  280. var parsedMedia: MediaEntities
  281. for mediaEntity in mediaEntities:
  282. with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
  283. case mediaInfo.getTypeName
  284. of "ApiImage":
  285. parsedMedia.addMedia(Photo(
  286. url: mediaInfo{"original_img_url"}.getImageStr,
  287. altText: mediaInfo{"alt_text"}.getStr
  288. ))
  289. of "ApiVideo":
  290. let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
  291. parsedMedia.addMedia(Video(
  292. available: status.getStr == "Available",
  293. thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
  294. title: mediaInfo{"alt_text"}.getStr,
  295. durationMs: mediaInfo{"duration_millis"}.getInt,
  296. variants: parseVideoVariants(mediaInfo{"variants"})
  297. ))
  298. # Parse source user for video attribution
  299. with sourceUser, mediaEntity{"source_user_results", "result"}:
  300. if result.attribution.isNone:
  301. let expanded = mediaEntity{"expanded_url"}.getStr
  302. if expanded.len > 0:
  303. result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
  304. result.attribution = some(User(
  305. id: sourceUser{"rest_id"}.getStr,
  306. fullname: sourceUser{"core", "name"}.getStr,
  307. userPic: sourceUser{"avatar", "image_url"}.getImageStr.replace("_normal", "")
  308. ))
  309. of "ApiGif":
  310. parsedMedia.addMedia(Gif(
  311. url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
  312. thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
  313. altText: mediaInfo{"alt_text"}.getStr
  314. ))
  315. else: discard
  316. if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
  317. result.media = parsedMedia
  318. proc parsePromoVideo(js: JsonNode): Video =
  319. result = Video(
  320. thumb: js{"player_image_large"}.getImageVal,
  321. available: true,
  322. durationMs: js{"content_duration_seconds"}.getStrVal("0").parseInt * 1000,
  323. playbackType: vmap
  324. )
  325. var variant = VideoVariant(
  326. contentType: vmap,
  327. url: js{"player_hls_url"}.getStrVal(js{"player_stream_url"}.getStrVal(
  328. js{"amplify_url_vmap"}.getStrVal()))
  329. )
  330. if "m3u8" in variant.url:
  331. variant.contentType = m3u8
  332. result.playbackType = m3u8
  333. result.variants.add variant
  334. proc parseBroadcast(js: JsonNode): Card =
  335. let
  336. image = js{"broadcast_thumbnail_large"}.getImageVal
  337. broadcastUrl = js{"broadcast_url"}.getStrVal
  338. broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
  339. streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
  340. result = Card(
  341. kind: broadcast,
  342. url: "/i/broadcasts/" & broadcastId,
  343. title: js{"broadcaster_display_name"}.getStrVal,
  344. text: js{"broadcast_title"}.getStrVal,
  345. image: image,
  346. video: some Video(
  347. thumb: image,
  348. available: true,
  349. playbackType: m3u8,
  350. variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
  351. )
  352. )
  353. proc parseCard(js: JsonNode; urls: JsonNode): Card =
  354. const imageTypes = ["summary_photo_image", "player_image", "promo_image",
  355. "photo_image_full_size", "thumbnail_image", "thumbnail",
  356. "event_thumbnail", "image"]
  357. let
  358. vals = ? js{"binding_values"}
  359. name = js{"name"}.getStr
  360. kind = parseEnum[CardKind](name[(name.find(":") + 1) ..< name.len], unknown)
  361. if kind == unified:
  362. return parseUnifiedCard(vals{"unified_card", "string_value"}.getStr)
  363. result = Card(
  364. kind: kind,
  365. url: vals.getCardUrl(kind),
  366. dest: vals.getCardDomain(kind),
  367. title: vals.getCardTitle(kind),
  368. text: vals{"description"}.getStrVal
  369. )
  370. if result.url.len == 0:
  371. result.url = js{"url"}.getStr
  372. case kind
  373. of promoVideo, promoVideoConvo, appPlayer, videoDirectMessage:
  374. result.video = some parsePromoVideo(vals)
  375. if kind == appPlayer:
  376. result.text = vals{"app_category"}.getStrVal(result.text)
  377. of broadcast:
  378. result = parseBroadcast(vals)
  379. of liveEvent:
  380. result.text = vals{"event_title"}.getStrVal
  381. of player:
  382. result.url = vals{"player_url"}.getStrVal
  383. if "youtube.com" in result.url:
  384. result.url = result.url.replace("/embed/", "/watch?v=")
  385. of audiospace:
  386. let spaceId = vals{"id"}.getStrVal
  387. if spaceId.len > 0:
  388. result.url = "/i/spaces/" & spaceId
  389. result.title = "Twitter Space"
  390. result.text = "Click to view Space"
  391. of unknown:
  392. result.title = "This card type is not supported."
  393. else: discard
  394. for typ in imageTypes:
  395. with img, vals{typ & "_large"}:
  396. result.image = img.getImageVal
  397. break
  398. for u in ? urls:
  399. if u{"url"}.getStr == result.url:
  400. result.url = u.getExpandedUrl(result.url)
  401. break
  402. if kind in {videoDirectMessage, imageDirectMessage}:
  403. result.url.setLen 0
  404. if kind in {promoImageConvo, promoImageApp, imageDirectMessage} and
  405. result.url.len == 0 or result.url.startsWith("card://"):
  406. result.url = getPicUrl(result.image)
  407. proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
  408. replyId: int64 = 0): Tweet =
  409. if js.isNull: return Tweet()
  410. let time =
  411. if js{"created_at"}.notNull: js{"created_at"}.getTime
  412. else: js{"created_at_ms"}.getTimeFromMs
  413. result = Tweet(
  414. id: js{"id_str"}.getId,
  415. threadId: js{"conversation_id_str"}.getId,
  416. replyId: js{"in_reply_to_status_id_str"}.getId,
  417. text: js{"full_text"}.getStr,
  418. time: time,
  419. hasThread: js{"self_thread"}.notNull,
  420. available: true,
  421. user: User(id: js{"user_id_str"}.getStr),
  422. stats: TweetStats(
  423. replies: js{"reply_count"}.getInt,
  424. retweets: js{"retweet_count"}.getInt,
  425. likes: js{"favorite_count"}.getInt,
  426. views: js{"views_count"}.getInt
  427. )
  428. )
  429. if result.replyId == 0:
  430. result.replyId = replyId
  431. # fix for pinned threads
  432. if result.hasThread and result.threadId == 0:
  433. result.threadId = js{"self_thread", "id_str"}.getId
  434. if "retweeted_status" in js:
  435. result.retweet = some Tweet()
  436. elif js{"is_quote_status"}.getBool:
  437. result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
  438. # legacy
  439. with rt, js{"retweeted_status_id_str"}:
  440. result.retweet = some Tweet(id: rt.getId)
  441. return
  442. # graphql
  443. with rt, js{"retweeted_status_result", "result"}:
  444. # needed due to weird edgecase where the actual tweet data isn't included
  445. if "legacy" in rt or "rest_id" in rt:
  446. result.retweet = some parseGraphTweet(rt)
  447. return
  448. with reposts, js{"repostedStatusResults"}:
  449. with rt, reposts{"result"}:
  450. if "legacy" in rt or "rest_id" in rt:
  451. result.retweet = some parseGraphTweet(rt)
  452. return
  453. if jsCard.kind != JNull:
  454. let name = jsCard{"name"}.getStr
  455. if "poll" in name:
  456. if "image" in name:
  457. result.media.addMedia(Photo(
  458. url: jsCard{"binding_values", "image_large"}.getImageVal
  459. ))
  460. result.poll = some parsePoll(jsCard)
  461. elif name == "amplify":
  462. result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
  463. elif name.len > 0 and jsCard{"binding_values"}.notNull:
  464. result.card = some parseCard(jsCard, js{"entities", "urls"})
  465. result.expandTweetEntities(js)
  466. parseLegacyMediaEntities(js, result)
  467. with jsWithheld, js{"withheld_in_countries"}:
  468. let withheldInCountries: seq[string] =
  469. if jsWithheld.kind != JArray: @[]
  470. else: jsWithheld.to(seq[string])
  471. # XX - Content is withheld in all countries
  472. # XY - Content is withheld due to a DMCA request.
  473. if js{"withheld_copyright"}.getBool or
  474. withheldInCountries.len > 0 and ("XX" in withheldInCountries or
  475. "XY" in withheldInCountries or
  476. "withheld" in result.text):
  477. result.text.removeSuffix(" Learn more.")
  478. result.available = false
  479. proc parseGraphTweet*(js: JsonNode): Tweet =
  480. if js.kind == JNull:
  481. return Tweet()
  482. case js.getTypeName:
  483. of "TweetUnavailable":
  484. return Tweet()
  485. of "TweetTombstone":
  486. with text, select(js{"tombstone", "richText"}, js{"tombstone", "text"}):
  487. return Tweet(text: text.getTombstone)
  488. return Tweet()
  489. of "TweetPreviewDisplay":
  490. return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
  491. of "TweetWithVisibilityResults":
  492. return parseGraphTweet(js{"tweet"})
  493. else:
  494. discard
  495. if "legacy" notin js and "rest_id" notin js:
  496. return Tweet()
  497. var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
  498. if jsCard.kind != JNull:
  499. let legacyCard = jsCard{"legacy"}
  500. if legacyCard.kind != JNull:
  501. let bindingArray = legacyCard{"binding_values"}
  502. if bindingArray.kind == JArray:
  503. var bindingObj: seq[(string, JsonNode)]
  504. for item in bindingArray:
  505. bindingObj.add((item{"key"}.getStr, item{"value"}))
  506. # Create a new card object with flattened structure
  507. jsCard = %*{
  508. "name": legacyCard{"name"},
  509. "url": legacyCard{"url"},
  510. "binding_values": %bindingObj
  511. }
  512. var replyId: int64 = 0
  513. with restId, js{"reply_to_results", "rest_id"}:
  514. replyId = restId.getId
  515. if "details" in js:
  516. result = Tweet(
  517. id: js{"rest_id"}.getId,
  518. available: true,
  519. text: js{"details", "full_text"}.getStr,
  520. time: js{"details", "created_at_ms"}.getTimeFromMs,
  521. replyId: js{"reply_to_results", "rest_id"}.getId,
  522. isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool,
  523. isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool,
  524. stats: TweetStats(
  525. replies: js{"counts", "reply_count"}.getInt,
  526. retweets: js{"counts", "retweet_count"}.getInt,
  527. likes: js{"counts", "favorite_count"}.getInt,
  528. )
  529. )
  530. if jsCard.kind != JNull:
  531. let name = jsCard{"name"}.getStr
  532. if "poll" in name:
  533. if "image" in name:
  534. result.media.addMedia(Photo(
  535. url: jsCard{"binding_values", "image_large"}.getImageVal
  536. ))
  537. result.poll = some parsePoll(jsCard)
  538. elif name == "amplify":
  539. result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
  540. elif name.len > 0 and jsCard{"binding_values"}.notNull:
  541. result.card = some parseCard(jsCard, js{"url_entities"})
  542. parseMediaEntities(js, result)
  543. if result.attribution.isNone:
  544. parseLegacyMediaEntities(js{"legacy"}, result)
  545. let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0
  546. result.expandTweetEntitiesV2(js, hasArticle)
  547. # Strip video source URL from text (for videos from other tweets)
  548. with mediaEntities, js{"media_entities"}:
  549. for m in mediaEntities:
  550. if "source_status_id_str" in m:
  551. let mediaUrl = m{"url"}.getStr
  552. if mediaUrl.len > 0:
  553. let idx = result.text.rfind(mediaUrl)
  554. if idx >= 0:
  555. result.text = result.text[0 ..< idx].strip()
  556. break
  557. else:
  558. result = parseTweet(js{"legacy"}, jsCard, replyId)
  559. result.id = js{"rest_id"}.getId
  560. with artNode, js{"article", "article_results", "result"}:
  561. let artTitle = artNode{"title"}.getStr
  562. if artTitle.len > 0:
  563. result.articlePreview = some ArticlePreview(
  564. title: artTitle,
  565. previewText: artNode{"preview_text"}.getStr,
  566. coverImage: artNode{"cover_media_results", "result", "media_info", "original_img_url"}.getImageStr,
  567. tweetId: result.id
  568. )
  569. result.user = parseGraphUser(js{"core"})
  570. if result.reply.len == 0:
  571. with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}:
  572. result.reply = @[replyTo.getStr]
  573. with count, js{"views", "count"}:
  574. result.stats.views = count.getStr("0").parseInt
  575. with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
  576. result.expandNoteTweetEntities(noteTweet)
  577. parseMediaEntities(js, result)
  578. # Hide card if it's redundant with attribution (same video shown via embed)
  579. if result.attribution.isSome and result.card.isSome:
  580. let cardUri = get(result.card).url.parseUri
  581. if cardUri.isTwitterUrl:
  582. let cardPath = cardUri.path.replace("/video/1", "")
  583. if cardPath.len > 0 and cardPath == result.attributionLink:
  584. get(result.card).kind = hidden
  585. # Handle retweets - check both legacy and top-level paths
  586. with reposts, js{"legacy", "repostedStatusResults"}:
  587. with rt, reposts{"result"}:
  588. if "legacy" in rt or "rest_id" in rt:
  589. result.retweet = some parseGraphTweet(rt)
  590. with quoted, js{"quoted_status_result", "result"}:
  591. result.quote = some(parseGraphTweet(quoted))
  592. with quoted, js{"quotedPostResults"}:
  593. if "result" in quoted:
  594. result.quote = some(parseGraphTweet(quoted{"result"}))
  595. else:
  596. result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId)
  597. with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}:
  598. for id in ids:
  599. result.history.add parseBiggestInt(id.getStr)
  600. with birdwatch, js{"birdwatch_pivot"}:
  601. result.note = parseCommunityNote(birdwatch)
  602. proc getConvSection(js: JsonNode): string =
  603. let details = select(
  604. js{"item", "client_event_info", "details"},
  605. js{"item", "clientEventInfo", "details"}
  606. )
  607. select(
  608. details{"conversation_details", "conversation_section"},
  609. details{"conversationDetails", "conversationSection"}
  610. ).getStr
  611. proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
  612. var checkedSection = false
  613. for t in ? js{"content", "items"}:
  614. let entryId = t.getEntryId
  615. if "tweet-" in entryId and "promoted" notin entryId:
  616. if not checkedSection:
  617. checkedSection = true
  618. if getConvSection(t) == "RelatedTweet":
  619. result.thread.related = true
  620. let tweet = t.getTweetResult("item")
  621. if tweet.notNull:
  622. result.thread.content.add parseGraphTweet(tweet)
  623. let tweetDisplayType = select(
  624. t{"item", "content", "tweet_display_type"},
  625. t{"item", "itemContent", "tweetDisplayType"}
  626. )
  627. if tweetDisplayType.getStr == "SelfThread":
  628. result.self = true
  629. else:
  630. result.thread.content.add Tweet(id: entryId.getId)
  631. elif "cursor-showmore" in entryId:
  632. let cursor = t{"item", "content", "value"}
  633. result.thread.cursor = cursor.getStr
  634. result.thread.hasMore = true
  635. proc parseGraphTweetResult*(js: JsonNode): Tweet =
  636. with tweet, js{"data", "tweet_result", "result"}:
  637. result = parseGraphTweet(tweet)
  638. proc parseTweetByRestId*(js: JsonNode): Tweet =
  639. with tweet, js{"data", "tweetResult", "result"}:
  640. result = parseGraphTweet(tweet)
  641. proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] =
  642. let results = js{"data", "tweetResult"}
  643. if results.kind != JArray: return
  644. for item in results:
  645. let tweet = item{"result"}
  646. if tweet.isNull: continue
  647. let t = parseGraphTweet(tweet)
  648. if t != nil:
  649. result.add t
  650. proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
  651. result = Conversation(replies: Result[Chain](beginning: true))
  652. let instructions = ? select(
  653. js{"data", "timelineResponse", "instructions"},
  654. js{"data", "timeline_response", "instructions"},
  655. js{"data", "threaded_conversation_with_injections_v2", "instructions"}
  656. )
  657. if instructions.len == 0:
  658. return
  659. for i in instructions:
  660. if i.getTypeName == "TimelineAddEntries":
  661. for e in i{"entries"}:
  662. let entryId = e.getEntryId
  663. if entryId.startsWith("tweet-"):
  664. let tweetResult = getTweetResult(e)
  665. if tweetResult.notNull:
  666. let tweet = parseGraphTweet(tweetResult)
  667. if not tweet.available:
  668. tweet.id = entryId.getId
  669. if entryId.endsWith(tweetId):
  670. result.tweet = tweet
  671. else:
  672. result.before.content.add tweet
  673. elif not entryId.endsWith(tweetId):
  674. result.before.content.add Tweet(id: entryId.getId)
  675. elif entryId.startsWith("conversationthread") or
  676. entryId.startsWith("tweetdetailrelatedtweets"):
  677. let (thread, self) = parseGraphThread(e)
  678. if self:
  679. result.after = thread
  680. elif thread.content.len > 0:
  681. result.replies.content.add thread
  682. elif entryId.startsWith("tombstone"):
  683. let
  684. content = select(e{"content", "content"}, e{"content", "itemContent"})
  685. tweet = Tweet(
  686. id: entryId.getId,
  687. available: false,
  688. text: content{"tombstoneInfo", "richText"}.getTombstone
  689. )
  690. if $tweet.id == tweetId:
  691. result.tweet = tweet
  692. else:
  693. result.before.content.add tweet
  694. elif entryId.startsWith("cursor-bottom"):
  695. var cursorValue = select(
  696. e{"content", "value"},
  697. e{"content", "content", "value"},
  698. e{"content", "itemContent", "value"}
  699. )
  700. result.replies.bottom = cursorValue.getStr
  701. proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory =
  702. let instructions = ? js{
  703. "data", "tweet_result_by_rest_id", "result",
  704. "edit_history_timeline", "timeline", "instructions"
  705. }
  706. if instructions.len == 0:
  707. return
  708. for i in instructions:
  709. if i.getTypeName == "TimelineAddEntries":
  710. for e in i{"entries"}:
  711. let entryId = e.getEntryId
  712. if entryId == "latestTweet":
  713. with item, e{"content", "items"}[0]:
  714. let tweetResult = item.getTweetResult("item")
  715. if tweetResult.notNull:
  716. result.latest = parseGraphTweet(tweetResult)
  717. elif entryId == "staleTweets":
  718. for item in e{"content", "items"}:
  719. let tweetResult = item.getTweetResult("item")
  720. if tweetResult.notNull:
  721. result.history.add parseGraphTweet(tweetResult)
  722. iterator extractTweetsFromModuleItems(items: JsonNode): Tweet =
  723. for item in items:
  724. with tweetResult, item.getTweetResult("item"):
  725. let tweet = parseGraphTweet(tweetResult)
  726. if not tweet.available:
  727. tweet.id = item.getEntryId.getId
  728. yield tweet
  729. iterator extractListsFromItems(items: JsonNode): ListSearchResult =
  730. for item in items:
  731. with listJs, item{"item", "itemContent", "list"}:
  732. let r = parseGraphSearchList(listJs)
  733. if r.list.id.len > 0:
  734. yield r
  735. proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
  736. with tweetResult, getTweetResult(e):
  737. let tweet = parseGraphTweet(tweetResult)
  738. if not tweet.available:
  739. tweet.id = e.getEntryId.getId
  740. result.add tweet
  741. return
  742. for tweet in extractTweetsFromModuleItems(e{"content", "items"}):
  743. result.add tweet
  744. proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
  745. result = Profile(tweets: Timeline(beginning: after.len == 0))
  746. let instructions = ? select(
  747. js{"data", "list", "timeline_response", "timeline", "instructions"},
  748. js{"data", "user", "result", "timeline", "timeline", "instructions"},
  749. js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
  750. )
  751. if instructions.len == 0:
  752. return
  753. for i in instructions:
  754. if i{"moduleItems"}.notNull:
  755. for tweet in extractTweetsFromModuleItems(i{"moduleItems"}):
  756. result.tweets.content.add tweet
  757. continue
  758. if i{"entries"}.notNull:
  759. for e in i{"entries"}:
  760. let entryId = e.getEntryId
  761. if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
  762. for tweet in extractTweetsFromEntry(e):
  763. result.tweets.content.add tweet
  764. elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
  765. let (thread, self) = parseGraphThread(e)
  766. result.tweets.content.add thread.content
  767. elif entryId.startsWith("cursor-bottom"):
  768. result.tweets.bottom = e{"content", "value"}.getStr
  769. if after.len == 0:
  770. if i.getTypeName == "TimelinePinEntry":
  771. let tweets = extractTweetsFromEntry(i{"entry"})
  772. if tweets.len > 0:
  773. var tweet = tweets[0]
  774. tweet.pinned = true
  775. result.pinned = some tweet
  776. proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
  777. result = @[]
  778. let instructions = select(
  779. js{"data", "user", "result", "timeline", "timeline", "instructions"},
  780. js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
  781. )
  782. if instructions.len == 0:
  783. return
  784. for i in instructions:
  785. if i{"moduleItems"}.notNull:
  786. for t in extractTweetsFromModuleItems(i{"moduleItems"}):
  787. let photo = extractGalleryPhoto(t)
  788. if photo.url.len > 0:
  789. result.add photo
  790. if result.len == 16:
  791. return
  792. continue
  793. if i.getTypeName != "TimelineAddEntries":
  794. continue
  795. for e in i{"entries"}:
  796. let entryId = e.getEntryId
  797. if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"):
  798. for t in extractTweetsFromEntry(e):
  799. let photo = extractGalleryPhoto(t)
  800. if photo.url.len > 0:
  801. result.add photo
  802. if result.len == 16:
  803. return
  804. proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=""): Result[T] =
  805. result = Result[T](beginning: after.len == 0)
  806. let instructions = select(
  807. js{"data", "search", "timeline_response", "timeline", "instructions"},
  808. js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
  809. )
  810. if instructions.len == 0:
  811. return
  812. for instruction in instructions:
  813. let typ = getTypeName(instruction)
  814. if typ == "TimelineAddEntries":
  815. for e in instruction{"entries"}:
  816. let entryId = e.getEntryId
  817. when T is Tweets:
  818. if entryId.startsWith("tweet") or entryId.startsWith("search-grid"):
  819. for tweet in extractTweetsFromEntry(e):
  820. result.content.add tweet
  821. elif T is User:
  822. if entryId.startsWith("user"):
  823. with userRes, e{"content", "itemContent"}:
  824. result.content.add parseGraphUser(userRes)
  825. elif T is ListSearchResult:
  826. if entryId.startsWith("list-search"):
  827. for list in extractListsFromItems(e{"content", "items"}):
  828. result.content.add list
  829. if entryId.startsWith("cursor-bottom"):
  830. result.bottom = e{"content", "value"}.getStr
  831. elif typ == "TimelineAddToModule":
  832. when T is Tweets:
  833. for tweet in extractTweetsFromModuleItems(instruction{"moduleItems"}):
  834. result.content.add tweet
  835. elif T is ListSearchResult:
  836. for list in extractListsFromItems(instruction{"moduleItems"}):
  837. result.content.add list
  838. elif typ == "TimelineReplaceEntry":
  839. if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
  840. result.bottom = instruction{"entry", "content", "value"}.getStr
  841. proc parseGraphCommunityTimeline*(js: JsonNode; after=""): Timeline =
  842. result = Timeline(beginning: after.len == 0)
  843. let communityResult = js{"data", "communityResults", "result"}
  844. let instructions = ? select(
  845. communityResult{"ranked_community_timeline", "timeline", "instructions"},
  846. communityResult{"community_media_timeline", "timeline", "instructions"},
  847. communityResult{"community_filtered_timeline", "timeline", "instructions"}
  848. )
  849. if instructions.len == 0:
  850. return
  851. for i in instructions:
  852. if i{"entries"}.notNull:
  853. for e in i{"entries"}:
  854. let entryId = e.getEntryId
  855. if entryId.startsWith("tweet") or entryId.startsWith("profile-grid") or
  856. entryId.startsWith("communities-grid"):
  857. for tweet in extractTweetsFromEntry(e):
  858. result.content.add tweet
  859. elif entryId.startsWith("cursor-bottom"):
  860. result.bottom = e{"content", "value"}.getStr
  861. if after.len == 0 and i.getTypeName == "TimelinePinEntry":
  862. var tweets = extractTweetsFromEntry(i{"entry"})
  863. for tweet in tweets.mitems:
  864. tweet.pinned = true
  865. if tweets.len > 0:
  866. result.content.insert(tweets, 0)
  867. proc parseGraphCommunityMembers*(js: JsonNode; after=""): Result[User] =
  868. result = Result[User](beginning: after.len == 0)
  869. let r = js{"data", "communityResults", "result"}
  870. let slice = if not r{"members_slice"}.isNull: r{"members_slice"}
  871. else: r{"moderators_slice"}
  872. for item in slice{"items_results"}:
  873. let user = parseGraphUser(item{"result"})
  874. if user.username.len > 0:
  875. result.content.add user
  876. let cursor = slice{"slice_info", "next_cursor"}.getStr
  877. if cursor.len > 0:
  878. result.bottom = cursor