# SPDX-License-Identifier: AGPL-3.0-only import strutils, options, times, math, tables import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard proc parseGraphTweet*(js: JsonNode): Tweet proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType = try: parseEnum[VerifiedType](s) except ValueError: current proc parseCommunityNote(js: JsonNode): string = let subtitle = js{"subtitle"} result = subtitle{"text"}.getStr with entities, subtitle{"entities"}: result = expandBirdwatchEntities(result, entities) proc parseUser(js: JsonNode; id=""): User = if js.isNull: return result = User( id: if id.len > 0: id else: js{"id_str"}.getStr, username: js{"screen_name"}.getStr, fullname: js{"name"}.getStr, location: js{"location"}.getStr, bio: js{"description"}.getStr, userPic: js{"profile_image_url_https"}.getImageStr.replace("_normal", ""), banner: js.getBanner, following: js{"friends_count"}.getInt, followers: js{"followers_count"}.getInt, tweets: js{"statuses_count"}.getInt, likes: js{"favourites_count"}.getInt, media: js{"media_count"}.getInt, protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool), joinDate: js{"created_at"}.getTime ) if js{"is_blue_verified"}.getBool(false): result.verifiedType = blue with verifiedType, js{"verified_type"}: result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) result.expandUserEntities(js) proc parseGraphUser(js: JsonNode): User = var user = js{"user_result", "result"} if user.isNull: user = js{"user_results", "result"} if user.isNull: if js{"core"}.notNull: user = js else: return result = parseUser(user{"legacy"}, user{"rest_id"}.getStr) if result.verifiedType == none and user{"is_blue_verified"}.getBool(false): result.verifiedType = blue # fallback to support UserMedia/recent GraphQL updates if result.username.len == 0: result.id = user{"rest_id"}.getStr result.username = user{"core", "screen_name"}.getStr result.fullname = user{"core", "name"}.getStr result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") if user{"is_blue_verified"}.getBool( user{"verification", "is_blue_verified"}.getBool(false)): result.verifiedType = blue with verifiedType, user{"verification", "verified_type"}: result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) proc parseAboutAccount*(js: JsonNode): AccountInfo = if js.isNull: return let user = ? js{"data", "user_result_by_screen_name", "result"} if user{"unavailable_reason"}.getStr == "Suspended": result.suspended = true return result = AccountInfo( username: user{"core", "screen_name"}.getStr, fullname: user{"core", "name"}.getStr, joinDate: user{"core", "created_at"}.getTime, userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""), affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr, ) if user{"is_blue_verified"}.getBool(false): result.verifiedType = blue with verifiedType, user{"verification", "verified_type"}: result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) with about, user{"about_profile"}: result.basedIn = about{"account_based_in"}.getStr result.source = about{"source"}.getStr result.affiliateUsername = about{"affiliate_username"}.getStr try: result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt except ValueError: discard with lastChange, about{"username_changes", "last_changed_at_msec"}: result.lastUsernameChange = lastChange.getTimeFromMsStr with info, user{"verification_info"}: result.isIdentityVerified = info{"is_identity_verified"}.getBool with reason, info{"reason"}: result.overrideVerifiedYear = reason{"override_verified_year"}.getInt with since, reason{"verified_since_msec"}: result.verifiedSince = since.getTimeFromMsStr proc parseBroadcastInfo*(js: JsonNode): Broadcast = let bc = ? js{"data", "broadcast"} result = Broadcast( id: bc{"broadcast_id"}.getStr, title: bc{"status"}.getStr, state: bc{"state"}.getStr.toUpperAscii, thumb: bc{"image_url"}.getStr, mediaKey: bc{"media_key"}.getStr, totalWatched: bc{"total_watched"}.getInt, startTime: bc{"start_time"}.getTimeFromMs, endTime: bc{"end_time"}.getTimeFromMs, replayStart: bc{"edited_replay", "start_time"}.getInt, availableForReplay: bc{"available_for_replay"}.getBool, user: parseGraphUser(bc) ) proc parseGraphList*(js: JsonNode): List = if js.isNull: return var list = js{"data", "user_by_screen_name", "list"} if list.isNull: list = js{"data", "list"} if list.isNull: return result = List( id: list{"id_str"}.getStr, name: list{"name"}.getStr, username: list{"user_results", "result", "legacy", "screen_name"}.getStr, userId: list{"user_results", "result", "rest_id"}.getStr, description: list{"description"}.getStr, members: list{"member_count"}.getInt, banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr ) proc parsePoll(js: JsonNode): Poll = let vals = js{"binding_values"} # name format is pollNchoice_* for i in '1' .. js{"name"}.getStr[4]: let choice = "choice" & i result.values.add parseInt(vals{choice & "_count"}.getStrVal("0")) result.options.add vals{choice & "_label"}.getStrVal let time = vals{"end_datetime_utc", "string_value"}.getDateTime if time > now(): let timeLeft = $(time - now()) result.status = timeLeft[0 ..< timeLeft.find(",")] else: result.status = "Final results" result.leader = result.values.find(max(result.values)) result.votes = result.values.sum proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] = result = @[] for v in variants: let url = v{"url"}.getStr contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4")) bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0)) result.add VideoVariant( contentType: contentType, bitrate: bitrate, url: url, resolution: if contentType == mp4: getMp4Resolution(url) else: 0 ) proc parseVideo(js: JsonNode): Video = result = Video( thumb: js{"media_url_https"}.getImageStr, available: true, title: js{"ext_alt_text"}.getStr, durationMs: js{"video_info", "duration_millis"}.getInt # playbackType: mp4 ) with status, js{"ext_media_availability", "status"}: if status.getStr.len > 0 and status.getStr.toLowerAscii != "available": result.available = false with title, js{"additional_media_info", "title"}: result.title = title.getStr with description, js{"additional_media_info", "description"}: result.description = description.getStr result.variants = parseVideoVariants(js{"video_info", "variants"}) proc addMedia(media: var MediaEntities; photo: Photo) = media.add Media(kind: photoMedia, photo: photo) proc addMedia(media: var MediaEntities; video: Video) = media.add Media(kind: videoMedia, video: video) proc addMedia(media: var MediaEntities; gif: Gif) = media.add Media(kind: gifMedia, gif: gif) proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = with jsMedia, js{"extended_entities", "media"}: for m in jsMedia: case m.getTypeName: of "photo": result.media.addMedia(Photo( url: m{"media_url_https"}.getImageStr, altText: m{"ext_alt_text"}.getStr )) of "video": result.media.addMedia(parseVideo(m)) with user, m{"additional_media_info", "source_user"}: if user{"id"}.getInt > 0: result.attribution = some(parseUser(user)) else: result.attribution = some(parseGraphUser(user)) of "animated_gif": result.media.addMedia(Gif( url: m{"video_info", "variants"}[0]{"url"}.getImageStr, thumb: m{"media_url_https"}.getImageStr, altText: m{"ext_alt_text"}.getStr )) else: discard with url, m{"url"}: if result.text.endsWith(url.getStr): result.text.removeSuffix(url.getStr) result.text = result.text.strip() proc parseMediaEntities(js: JsonNode; result: var Tweet) = with mediaEntities, js{"media_entities"}: var parsedMedia: MediaEntities for mediaEntity in mediaEntities: with mediaInfo, mediaEntity{"media_results", "result", "media_info"}: case mediaInfo.getTypeName of "ApiImage": parsedMedia.addMedia(Photo( url: mediaInfo{"original_img_url"}.getImageStr, altText: mediaInfo{"alt_text"}.getStr )) of "ApiVideo": let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} parsedMedia.addMedia(Video( available: status.getStr == "Available", thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, title: mediaInfo{"alt_text"}.getStr, durationMs: mediaInfo{"duration_millis"}.getInt, variants: parseVideoVariants(mediaInfo{"variants"}) )) # Parse source user for video attribution with sourceUser, mediaEntity{"source_user_results", "result"}: if result.attribution.isNone: let expanded = mediaEntity{"expanded_url"}.getStr pathStart = expanded.find('/', expanded.find("://") + 3) if pathStart >= 0: result.attributionLink = expanded[pathStart .. ^1].replace("/video/1", "") result.attribution = some(User( id: sourceUser{"rest_id"}.getStr, fullname: sourceUser{"core", "name"}.getStr, userPic: sourceUser{"avatar", "image_url"}.getImageStr.replace("_normal", "") )) of "ApiGif": parsedMedia.addMedia(Gif( url: mediaInfo{"variants"}[0]{"url"}.getImageStr, thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, altText: mediaInfo{"alt_text"}.getStr )) else: discard if "expanded_url" in mediaEntity: let expandedUrl = js.getExpandedUrl if result.text.endsWith(expandedUrl): result.text.removeSuffix(expandedUrl) result.text = result.text.strip() if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len: result.media = parsedMedia # Remove media URLs from text with mediaList, js{"legacy", "entities", "media"}: for url in mediaList: let expandedUrl = url.getExpandedUrl if result.text.endsWith(expandedUrl): result.text.removeSuffix(expandedUrl) result.text = result.text.strip() proc parsePromoVideo(js: JsonNode): Video = result = Video( thumb: js{"player_image_large"}.getImageVal, available: true, durationMs: js{"content_duration_seconds"}.getStrVal("0").parseInt * 1000, playbackType: vmap ) var variant = VideoVariant( contentType: vmap, url: js{"player_hls_url"}.getStrVal(js{"player_stream_url"}.getStrVal( js{"amplify_url_vmap"}.getStrVal())) ) if "m3u8" in variant.url: variant.contentType = m3u8 result.playbackType = m3u8 result.variants.add variant proc parseBroadcast(js: JsonNode): Card = let image = js{"broadcast_thumbnail_large"}.getImageVal broadcastUrl = js{"broadcast_url"}.getStrVal broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1] streamUrl = "/i/broadcasts/" & broadcastId & "/stream" result = Card( kind: broadcast, url: "/i/broadcasts/" & broadcastId, title: js{"broadcaster_display_name"}.getStrVal, text: js{"broadcast_title"}.getStrVal, image: image, video: some Video( thumb: image, available: true, playbackType: m3u8, variants: @[VideoVariant(contentType: m3u8, url: streamUrl)] ) ) proc parseCard(js: JsonNode; urls: JsonNode): Card = const imageTypes = ["summary_photo_image", "player_image", "promo_image", "photo_image_full_size", "thumbnail_image", "thumbnail", "event_thumbnail", "image"] let vals = ? js{"binding_values"} name = js{"name"}.getStr kind = parseEnum[CardKind](name[(name.find(":") + 1) ..< name.len], unknown) if kind == unified: return parseUnifiedCard(vals{"unified_card", "string_value"}.getStr) result = Card( kind: kind, url: vals.getCardUrl(kind), dest: vals.getCardDomain(kind), title: vals.getCardTitle(kind), text: vals{"description"}.getStrVal ) if result.url.len == 0: result.url = js{"url"}.getStr case kind of promoVideo, promoVideoConvo, appPlayer, videoDirectMessage: result.video = some parsePromoVideo(vals) if kind == appPlayer: result.text = vals{"app_category"}.getStrVal(result.text) of broadcast: result = parseBroadcast(vals) of liveEvent: result.text = vals{"event_title"}.getStrVal of player: result.url = vals{"player_url"}.getStrVal if "youtube.com" in result.url: result.url = result.url.replace("/embed/", "/watch?v=") of audiospace, unknown: result.title = "This card type is not supported." else: discard for typ in imageTypes: with img, vals{typ & "_large"}: result.image = img.getImageVal break for u in ? urls: if u{"url"}.getStr == result.url: result.url = u.getExpandedUrl(result.url) break if kind in {videoDirectMessage, imageDirectMessage}: result.url.setLen 0 if kind in {promoImageConvo, promoImageApp, imageDirectMessage} and result.url.len == 0 or result.url.startsWith("card://"): result.url = getPicUrl(result.image) proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); replyId: int64 = 0): Tweet = if js.isNull: return Tweet() let time = if js{"created_at"}.notNull: js{"created_at"}.getTime else: js{"created_at_ms"}.getTimeFromMs result = Tweet( id: js{"id_str"}.getId, threadId: js{"conversation_id_str"}.getId, replyId: js{"in_reply_to_status_id_str"}.getId, text: js{"full_text"}.getStr, time: time, hasThread: js{"self_thread"}.notNull, available: true, user: User(id: js{"user_id_str"}.getStr), stats: TweetStats( replies: js{"reply_count"}.getInt, retweets: js{"retweet_count"}.getInt, likes: js{"favorite_count"}.getInt, views: js{"views_count"}.getInt ) ) if result.replyId == 0: result.replyId = replyId # fix for pinned threads if result.hasThread and result.threadId == 0: result.threadId = js{"self_thread", "id_str"}.getId if "retweeted_status" in js: result.retweet = some Tweet() elif js{"is_quote_status"}.getBool: result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId) # legacy with rt, js{"retweeted_status_id_str"}: result.retweet = some Tweet(id: rt.getId) return # graphql with rt, js{"retweeted_status_result", "result"}: # needed due to weird edgecase where the actual tweet data isn't included if "legacy" in rt or "rest_id" in rt: result.retweet = some parseGraphTweet(rt) return with reposts, js{"repostedStatusResults"}: with rt, reposts{"result"}: if "legacy" in rt or "rest_id" in rt: result.retweet = some parseGraphTweet(rt) return if jsCard.kind != JNull: let name = jsCard{"name"}.getStr if "poll" in name: if "image" in name: result.media.addMedia(Photo( url: jsCard{"binding_values", "image_large"}.getImageVal )) result.poll = some parsePoll(jsCard) elif name == "amplify": result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) elif name.len > 0 and jsCard{"binding_values"}.notNull: result.card = some parseCard(jsCard, js{"entities", "urls"}) result.expandTweetEntities(js) parseLegacyMediaEntities(js, result) with jsWithheld, js{"withheld_in_countries"}: let withheldInCountries: seq[string] = if jsWithheld.kind != JArray: @[] else: jsWithheld.to(seq[string]) # XX - Content is withheld in all countries # XY - Content is withheld due to a DMCA request. if js{"withheld_copyright"}.getBool or withheldInCountries.len > 0 and ("XX" in withheldInCountries or "XY" in withheldInCountries or "withheld" in result.text): result.text.removeSuffix(" Learn more.") result.available = false proc parseGraphTweet*(js: JsonNode): Tweet = if js.kind == JNull: return Tweet() case js.getTypeName: of "TweetUnavailable": return Tweet() of "TweetTombstone": with text, select(js{"tombstone", "richText"}, js{"tombstone", "text"}): return Tweet(text: text.getTombstone) return Tweet() of "TweetPreviewDisplay": return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.") of "TweetWithVisibilityResults": return parseGraphTweet(js{"tweet"}) else: discard if "legacy" notin js and "rest_id" notin js: return Tweet() var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"}) if jsCard.kind != JNull: let legacyCard = jsCard{"legacy"} if legacyCard.kind != JNull: let bindingArray = legacyCard{"binding_values"} if bindingArray.kind == JArray: var bindingObj: seq[(string, JsonNode)] for item in bindingArray: bindingObj.add((item{"key"}.getStr, item{"value"})) # Create a new card object with flattened structure jsCard = %*{ "name": legacyCard{"name"}, "url": legacyCard{"url"}, "binding_values": %bindingObj } var replyId = 0 with restId, js{"reply_to_results", "rest_id"}: replyId = restId.getId if "details" in js: result = Tweet( id: js{"rest_id"}.getId, available: true, text: js{"details", "full_text"}.getStr, time: js{"details", "created_at_ms"}.getTimeFromMs, replyId: js{"reply_to_results", "rest_id"}.getId, isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool, isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool, stats: TweetStats( replies: js{"counts", "reply_count"}.getInt, retweets: js{"counts", "retweet_count"}.getInt, likes: js{"counts", "favorite_count"}.getInt, ) ) if jsCard.kind != JNull: let name = jsCard{"name"}.getStr if "poll" in name: if "image" in name: result.media.addMedia(Photo( url: jsCard{"binding_values", "image_large"}.getImageVal )) result.poll = some parsePoll(jsCard) elif name == "amplify": result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) elif name.len > 0 and jsCard{"binding_values"}.notNull: result.card = some parseCard(jsCard, js{"url_entities"}) result.expandTweetEntitiesV2(js) # Strip video source URL from text (for videos from other tweets) with mediaEntities, js{"media_entities"}: for m in mediaEntities: if "source_status_id_str" in m: let mediaUrl = m{"url"}.getStr if mediaUrl.len > 0: let idx = result.text.rfind(mediaUrl) if idx >= 0: result.text = result.text[0 ..< idx].strip() break else: result = parseTweet(js{"legacy"}, jsCard, replyId) result.id = js{"rest_id"}.getId result.user = parseGraphUser(js{"core"}) if result.reply.len == 0: with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}: result.reply = @[replyTo.getStr] with count, js{"views", "count"}: result.stats.views = count.getStr("0").parseInt with noteTweet, js{"note_tweet", "note_tweet_results", "result"}: result.expandNoteTweetEntities(noteTweet) parseMediaEntities(js, result) # Handle retweets - check both legacy and top-level paths with reposts, js{"legacy", "repostedStatusResults"}: with rt, reposts{"result"}: if "legacy" in rt or "rest_id" in rt: result.retweet = some parseGraphTweet(rt) with quoted, js{"quoted_status_result", "result"}: result.quote = some(parseGraphTweet(quoted)) with quoted, js{"quotedPostResults"}: if "result" in quoted: result.quote = some(parseGraphTweet(quoted{"result"})) else: result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId) with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}: for id in ids: result.history.add parseBiggestInt(id.getStr) with birdwatch, js{"birdwatch_pivot"}: result.note = parseCommunityNote(birdwatch) proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = for t in ? js{"content", "items"}: let entryId = t.getEntryId if "tweet-" in entryId and "promoted" notin entryId: let tweet = t.getTweetResult("item") if tweet.notNull: result.thread.content.add parseGraphTweet(tweet) let tweetDisplayType = select( t{"item", "content", "tweet_display_type"}, t{"item", "itemContent", "tweetDisplayType"} ) if tweetDisplayType.getStr == "SelfThread": result.self = true else: result.thread.content.add Tweet(id: entryId.getId) elif "cursor-showmore" in entryId: let cursor = t{"item", "content", "value"} result.thread.cursor = cursor.getStr result.thread.hasMore = true proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: result = parseGraphTweet(tweet) proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) let instructions = ? select( js{"data", "timelineResponse", "instructions"}, js{"data", "timeline_response", "instructions"}, js{"data", "threaded_conversation_with_injections_v2", "instructions"} ) if instructions.len == 0: return for i in instructions: if i.getTypeName == "TimelineAddEntries": for e in i{"entries"}: let entryId = e.getEntryId if entryId.startsWith("tweet-"): let tweetResult = getTweetResult(e) if tweetResult.notNull: let tweet = parseGraphTweet(tweetResult) if not tweet.available: tweet.id = entryId.getId if entryId.endsWith(tweetId): result.tweet = tweet else: result.before.content.add tweet elif not entryId.endsWith(tweetId): result.before.content.add Tweet(id: entryId.getId) elif entryId.startsWith("conversationthread"): let (thread, self) = parseGraphThread(e) if self: result.after = thread elif thread.content.len > 0: result.replies.content.add thread elif entryId.startsWith("tombstone"): let content = select(e{"content", "content"}, e{"content", "itemContent"}) tweet = Tweet( id: entryId.getId, available: false, text: content{"tombstoneInfo", "richText"}.getTombstone ) if $tweet.id == tweetId: result.tweet = tweet else: result.before.content.add tweet elif entryId.startsWith("cursor-bottom"): var cursorValue = select( e{"content", "value"}, e{"content", "content", "value"}, e{"content", "itemContent", "value"} ) result.replies.bottom = cursorValue.getStr proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory = let instructions = ? js{ "data", "tweet_result_by_rest_id", "result", "edit_history_timeline", "timeline", "instructions" } if instructions.len == 0: return for i in instructions: if i.getTypeName == "TimelineAddEntries": for e in i{"entries"}: let entryId = e.getEntryId if entryId == "latestTweet": with item, e{"content", "items"}[0]: let tweetResult = item.getTweetResult("item") if tweetResult.notNull: result.latest = parseGraphTweet(tweetResult) elif entryId == "staleTweets": for item in e{"content", "items"}: let tweetResult = item.getTweetResult("item") if tweetResult.notNull: result.history.add parseGraphTweet(tweetResult) proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = with tweetResult, getTweetResult(e): var tweet = parseGraphTweet(tweetResult) if not tweet.available: tweet.id = e.getEntryId.getId result.add tweet return for item in e{"content", "items"}: with tweetResult, item.getTweetResult("item"): var tweet = parseGraphTweet(tweetResult) if not tweet.available: tweet.id = item.getEntryId.getId result.add tweet proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) let instructions = ? select( js{"data", "list", "timeline_response", "timeline", "instructions"}, js{"data", "user", "result", "timeline", "timeline", "instructions"}, js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} ) if instructions.len == 0: return for i in instructions: if i{"moduleItems"}.notNull: for item in i{"moduleItems"}: with tweetResult, item.getTweetResult("item"): let tweet = parseGraphTweet(tweetResult) if not tweet.available: tweet.id = item.getEntryId.getId result.tweets.content.add tweet continue if i{"entries"}.notNull: for e in i{"entries"}: let entryId = e.getEntryId if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): for tweet in extractTweetsFromEntry(e): result.tweets.content.add tweet elif "-conversation-" in entryId or entryId.startsWith("homeConversation"): let (thread, self) = parseGraphThread(e) result.tweets.content.add thread.content elif entryId.startsWith("cursor-bottom"): result.tweets.bottom = e{"content", "value"}.getStr if after.len == 0: if i.getTypeName == "TimelinePinEntry": let tweets = extractTweetsFromEntry(i{"entry"}) if tweets.len > 0: var tweet = tweets[0] tweet.pinned = true result.pinned = some tweet proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = result = @[] let instructions = select( js{"data", "user", "result", "timeline", "timeline", "instructions"}, js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} ) if instructions.len == 0: return for i in instructions: if i{"moduleItems"}.notNull: for item in i{"moduleItems"}: with tweetResult, item.getTweetResult("item"): let t = parseGraphTweet(tweetResult) if not t.available: t.id = item.getEntryId.getId let photo = extractGalleryPhoto(t) if photo.url.len > 0: result.add photo if result.len == 16: return continue if i.getTypeName != "TimelineAddEntries": continue for e in i{"entries"}: let entryId = e.getEntryId if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): for t in extractTweetsFromEntry(e): let photo = extractGalleryPhoto(t) if photo.url.len > 0: result.add photo if result.len == 16: return proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = result = Result[T](beginning: after.len == 0) let instructions = select( js{"data", "search", "timeline_response", "timeline", "instructions"}, js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"} ) if instructions.len == 0: return for instruction in instructions: let typ = getTypeName(instruction) if typ == "TimelineAddEntries": for e in instruction{"entries"}: let entryId = e.getEntryId when T is Tweets: if entryId.startsWith("tweet"): with tweetRes, getTweetResult(e): let tweet = parseGraphTweet(tweetRes) if not tweet.available: tweet.id = entryId.getId result.content.add tweet elif T is User: if entryId.startsWith("user"): with userRes, e{"content", "itemContent"}: result.content.add parseGraphUser(userRes) if entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr elif typ == "TimelineReplaceEntry": if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): result.bottom = instruction{"entry", "content", "value"}.getStr