2021-12-27 02:37:38 +01:00
|
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
2023-08-22 03:44:11 +02:00
|
|
|
import strutils, options, times, math
|
2022-01-13 00:36:30 +01:00
|
|
|
import packedjson, packedjson/deserialiser
|
2020-06-03 02:33:34 +02:00
|
|
|
import types, parserutils, utils
|
2022-01-13 00:36:30 +01:00
|
|
|
import experimental/parser/unifiedcard
|
2023-09-01 17:44:44 -04:00
|
|
|
import std/tables
|
2020-06-01 02:16:24 +02:00
|
|
|
|
2023-08-26 05:16:38 +02:00
|
|
|
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
|
2023-04-21 12:41:30 +00:00
|
|
|
|
2022-01-23 07:04:50 +01:00
|
|
|
proc parseUser(js: JsonNode; id=""): User =
|
2020-06-02 16:22:44 +02:00
|
|
|
if js.isNull: return
|
2022-01-23 07:04:50 +01:00
|
|
|
result = User(
|
2020-06-01 02:16:24 +02:00
|
|
|
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,
|
2022-01-06 03:57:14 +01:00
|
|
|
userPic: js{"profile_image_url_https"}.getImageStr.replace("_normal", ""),
|
2020-06-01 02:16:24 +02:00
|
|
|
banner: js.getBanner,
|
2022-01-16 03:32:18 +01:00
|
|
|
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,
|
2023-11-25 10:06:12 +00:00
|
|
|
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
|
2020-06-01 02:16:24 +02:00
|
|
|
protected: js{"protected"}.getBool,
|
|
|
|
joinDate: js{"created_at"}.getTime
|
|
|
|
)
|
|
|
|
|
2022-01-23 07:04:50 +01:00
|
|
|
result.expandUserEntities(js)
|
|
|
|
|
2023-04-21 12:41:30 +00:00
|
|
|
proc parseGraphUser(js: JsonNode): User =
|
2023-08-19 00:25:14 +02:00
|
|
|
var user = js{"user_result", "result"}
|
|
|
|
if user.isNull:
|
2023-07-13 23:22:02 -04:00
|
|
|
user = ? js{"user_results", "result"}
|
2023-04-21 12:41:30 +00:00
|
|
|
result = parseUser(user{"legacy"})
|
|
|
|
|
2023-11-25 10:11:57 +00:00
|
|
|
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
|
2023-11-25 10:06:12 +00:00
|
|
|
result.verifiedType = blue
|
2023-04-21 12:41:30 +00:00
|
|
|
|
2020-06-01 02:16:24 +02:00
|
|
|
proc parseGraphList*(js: JsonNode): List =
|
2020-06-02 16:22:44 +02:00
|
|
|
if js.isNull: return
|
2020-06-01 02:16:24 +02:00
|
|
|
|
|
|
|
var list = js{"data", "user_by_screen_name", "list"}
|
2020-06-02 16:22:44 +02:00
|
|
|
if list.isNull:
|
2020-06-01 02:16:24 +02:00
|
|
|
list = js{"data", "list"}
|
2020-06-02 16:22:44 +02:00
|
|
|
if list.isNull:
|
2020-06-01 02:16:24 +02:00
|
|
|
return
|
|
|
|
|
|
|
|
result = List(
|
|
|
|
id: list{"id_str"}.getStr,
|
2021-10-02 16:13:56 +08:00
|
|
|
name: list{"name"}.getStr,
|
2023-04-21 12:41:30 +00:00
|
|
|
username: list{"user_results", "result", "legacy", "screen_name"}.getStr,
|
|
|
|
userId: list{"user_results", "result", "rest_id"}.getStr,
|
2020-06-01 02:16:24 +02:00
|
|
|
description: list{"description"}.getStr,
|
|
|
|
members: list{"member_count"}.getInt,
|
2023-04-21 12:41:30 +00:00
|
|
|
banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr
|
2020-06-01 02:16:24 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
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
|
2021-12-20 03:11:12 +01:00
|
|
|
if time > now():
|
|
|
|
let timeLeft = $(time - now())
|
2020-06-01 02:16:24 +02:00
|
|
|
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 parseGif(js: JsonNode): Gif =
|
2020-06-07 07:55:57 +02:00
|
|
|
result = Gif(
|
|
|
|
url: js{"video_info", "variants"}[0]{"url"}.getImageStr,
|
|
|
|
thumb: js{"media_url_https"}.getImageStr
|
2020-06-01 02:16:24 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
proc parseVideo(js: JsonNode): Video =
|
|
|
|
result = Video(
|
2020-06-07 07:55:57 +02:00
|
|
|
thumb: js{"media_url_https"}.getImageStr,
|
2023-08-08 02:09:56 +02:00
|
|
|
views: getVideoViewCount(js),
|
2023-07-12 03:37:44 +02:00
|
|
|
available: true,
|
2020-06-01 02:16:24 +02:00
|
|
|
title: js{"ext_alt_text"}.getStr,
|
2022-01-02 10:58:02 +01:00
|
|
|
durationMs: js{"video_info", "duration_millis"}.getInt
|
2020-11-07 22:48:30 +01:00
|
|
|
# playbackType: mp4
|
2020-06-01 02:16:24 +02:00
|
|
|
)
|
|
|
|
|
2023-07-12 03:37:44 +02:00
|
|
|
with status, js{"ext_media_availability", "status"}:
|
|
|
|
if status.getStr.len > 0 and status.getStr.toLowerAscii != "available":
|
|
|
|
result.available = false
|
|
|
|
|
2020-06-10 17:09:38 +02:00
|
|
|
with title, js{"additional_media_info", "title"}:
|
|
|
|
result.title = title.getStr
|
|
|
|
|
2021-12-30 23:24:10 +01:00
|
|
|
with description, js{"additional_media_info", "description"}:
|
|
|
|
result.description = description.getStr
|
|
|
|
|
2020-06-01 02:16:24 +02:00
|
|
|
for v in js{"video_info", "variants"}:
|
2022-05-18 19:47:03 +02:00
|
|
|
let
|
|
|
|
contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary"))
|
|
|
|
url = v{"url"}.getStr
|
2022-06-04 01:16:37 +02:00
|
|
|
|
2020-06-01 02:16:24 +02:00
|
|
|
result.variants.add VideoVariant(
|
2022-05-18 19:47:03 +02:00
|
|
|
contentType: contentType,
|
2020-06-01 02:16:24 +02:00
|
|
|
bitrate: v{"bitrate"}.getInt,
|
2022-05-18 19:47:03 +02:00
|
|
|
url: url,
|
2022-06-04 01:16:37 +02:00
|
|
|
resolution: if contentType == mp4: getMp4Resolution(url) else: 0
|
2020-06-01 02:16:24 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
proc parsePromoVideo(js: JsonNode): Video =
|
|
|
|
result = Video(
|
2020-06-03 02:33:34 +02:00
|
|
|
thumb: js{"player_image_large"}.getImageVal,
|
2020-06-01 02:16:24 +02:00
|
|
|
available: true,
|
|
|
|
durationMs: js{"content_duration_seconds"}.getStrVal("0").parseInt * 1000,
|
2022-01-13 00:36:30 +01:00
|
|
|
playbackType: vmap
|
2020-06-01 02:16:24 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
var variant = VideoVariant(
|
2022-01-13 00:36:30 +01:00
|
|
|
contentType: vmap,
|
2020-06-03 02:33:34 +02:00
|
|
|
url: js{"player_hls_url"}.getStrVal(js{"player_stream_url"}.getStrVal(
|
|
|
|
js{"amplify_url_vmap"}.getStrVal()))
|
2020-06-01 02:16:24 +02:00
|
|
|
)
|
|
|
|
|
2020-06-03 02:33:34 +02:00
|
|
|
if "m3u8" in variant.url:
|
2022-01-13 00:36:30 +01:00
|
|
|
variant.contentType = m3u8
|
2020-06-03 02:33:34 +02:00
|
|
|
result.playbackType = m3u8
|
2020-06-01 02:16:24 +02:00
|
|
|
|
|
|
|
result.variants.add variant
|
|
|
|
|
|
|
|
proc parseBroadcast(js: JsonNode): Card =
|
2020-06-03 02:33:34 +02:00
|
|
|
let image = js{"broadcast_thumbnail_large"}.getImageVal
|
2020-06-01 02:16:24 +02:00
|
|
|
result = Card(
|
|
|
|
kind: broadcast,
|
|
|
|
url: js{"broadcast_url"}.getStrVal,
|
|
|
|
title: js{"broadcaster_display_name"}.getStrVal,
|
|
|
|
text: js{"broadcast_title"}.getStrVal,
|
|
|
|
image: image,
|
2022-01-13 00:36:30 +01:00
|
|
|
video: some Video(thumb: image)
|
2020-06-01 02:16:24 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
2020-06-03 02:33:34 +02:00
|
|
|
const imageTypes = ["summary_photo_image", "player_image", "promo_image",
|
|
|
|
"photo_image_full_size", "thumbnail_image", "thumbnail",
|
2020-06-10 16:13:40 +02:00
|
|
|
"event_thumbnail", "image"]
|
2020-06-01 02:16:24 +02:00
|
|
|
let
|
|
|
|
vals = ? js{"binding_values"}
|
|
|
|
name = js{"name"}.getStr
|
2021-11-20 22:12:33 +00:00
|
|
|
kind = parseEnum[CardKind](name[(name.find(":") + 1) ..< name.len], unknown)
|
2020-06-01 02:16:24 +02:00
|
|
|
|
2022-01-13 00:36:30 +01:00
|
|
|
if kind == unified:
|
|
|
|
return parseUnifiedCard(vals{"unified_card", "string_value"}.getStr)
|
|
|
|
|
2020-06-01 02:16:24 +02:00
|
|
|
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
|
2020-06-10 16:13:40 +02:00
|
|
|
of promoVideo, promoVideoConvo, appPlayer, videoDirectMessage:
|
2020-06-01 02:16:24 +02:00
|
|
|
result.video = some parsePromoVideo(vals)
|
2020-06-03 02:33:34 +02:00
|
|
|
if kind == appPlayer:
|
|
|
|
result.text = vals{"app_category"}.getStrVal(result.text)
|
2020-06-01 02:16:24 +02:00
|
|
|
of broadcast:
|
|
|
|
result = parseBroadcast(vals)
|
2020-06-03 02:33:34 +02:00
|
|
|
of liveEvent:
|
|
|
|
result.text = vals{"event_title"}.getStrVal
|
2020-06-01 02:16:24 +02:00
|
|
|
of player:
|
|
|
|
result.url = vals{"player_url"}.getStrVal
|
|
|
|
if "youtube.com" in result.url:
|
|
|
|
result.url = result.url.replace("/embed/", "/watch?v=")
|
2022-01-13 00:36:30 +01:00
|
|
|
of audiospace, unknown:
|
2020-06-03 02:33:34 +02:00
|
|
|
result.title = "This card type is not supported."
|
2020-06-01 02:16:24 +02:00
|
|
|
else: discard
|
|
|
|
|
|
|
|
for typ in imageTypes:
|
|
|
|
with img, vals{typ & "_large"}:
|
2020-06-03 02:33:34 +02:00
|
|
|
result.image = img.getImageVal
|
2020-06-01 02:16:24 +02:00
|
|
|
break
|
|
|
|
|
|
|
|
for u in ? urls:
|
|
|
|
if u{"url"}.getStr == result.url:
|
|
|
|
result.url = u{"expanded_url"}.getStr
|
|
|
|
break
|
|
|
|
|
2020-06-10 16:13:40 +02:00
|
|
|
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://"):
|
2020-06-03 02:33:34 +02:00
|
|
|
result.url = getPicUrl(result.image)
|
|
|
|
|
2023-02-24 01:01:22 +01:00
|
|
|
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
2020-06-02 16:22:44 +02:00
|
|
|
if js.isNull: return
|
2020-06-01 02:16:24 +02:00
|
|
|
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: js{"created_at"}.getTime,
|
2020-06-02 16:22:44 +02:00
|
|
|
hasThread: js{"self_thread"}.notNull,
|
2020-06-01 02:16:24 +02:00
|
|
|
available: true,
|
2022-01-23 07:04:50 +01:00
|
|
|
user: User(id: js{"user_id_str"}.getStr),
|
2020-06-01 02:16:24 +02:00
|
|
|
stats: TweetStats(
|
|
|
|
replies: js{"reply_count"}.getInt,
|
|
|
|
retweets: js{"retweet_count"}.getInt,
|
|
|
|
likes: js{"favorite_count"}.getInt,
|
2020-11-08 01:32:17 +01:00
|
|
|
quotes: js{"quote_count"}.getInt
|
2020-06-01 02:16:24 +02:00
|
|
|
)
|
|
|
|
)
|
|
|
|
|
2022-11-27 01:57:32 +01:00
|
|
|
# fix for pinned threads
|
|
|
|
if result.hasThread and result.threadId == 0:
|
|
|
|
result.threadId = js{"self_thread", "id_str"}.getId
|
|
|
|
|
2023-07-12 03:37:44 +02:00
|
|
|
if "retweeted_status" in js:
|
|
|
|
result.retweet = some Tweet()
|
|
|
|
elif js{"is_quote_status"}.getBool:
|
2020-06-01 02:16:24 +02:00
|
|
|
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
|
|
|
|
|
2023-04-21 12:41:30 +00:00
|
|
|
# legacy
|
2020-06-01 02:16:24 +02:00
|
|
|
with rt, js{"retweeted_status_id_str"}:
|
|
|
|
result.retweet = some Tweet(id: rt.getId)
|
|
|
|
return
|
|
|
|
|
2023-04-21 12:41:30 +00:00
|
|
|
# graphql
|
|
|
|
with rt, js{"retweeted_status_result", "result"}:
|
|
|
|
# needed due to weird edgecase where the actual tweet data isn't included
|
2023-06-17 23:06:20 -04:00
|
|
|
var rt_tweet = rt
|
|
|
|
if "tweet" in rt:
|
|
|
|
rt_tweet = rt{"tweet"}
|
|
|
|
if "legacy" in rt_tweet:
|
|
|
|
result.retweet = some parseGraphTweet(rt_tweet)
|
2023-04-21 12:41:30 +00:00
|
|
|
return
|
|
|
|
|
2023-02-24 01:01:22 +01:00
|
|
|
if jsCard.kind != JNull:
|
2020-06-01 02:16:24 +02:00
|
|
|
let name = jsCard{"name"}.getStr
|
|
|
|
if "poll" in name:
|
|
|
|
if "image" in name:
|
2020-06-03 02:33:34 +02:00
|
|
|
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
|
2020-06-01 02:16:24 +02:00
|
|
|
|
|
|
|
result.poll = some parsePoll(jsCard)
|
2020-06-03 02:33:34 +02:00
|
|
|
elif name == "amplify":
|
|
|
|
result.video = some(parsePromoVideo(jsCard{"binding_values"}))
|
2020-06-01 02:16:24 +02:00
|
|
|
else:
|
|
|
|
result.card = some parseCard(jsCard, js{"entities", "urls"})
|
|
|
|
|
2023-11-25 05:31:15 +00:00
|
|
|
result.expandTweetEntities(js)
|
|
|
|
|
2020-06-01 02:16:24 +02:00
|
|
|
with jsMedia, js{"extended_entities", "media"}:
|
|
|
|
for m in jsMedia:
|
|
|
|
case m{"type"}.getStr
|
|
|
|
of "photo":
|
2020-06-07 07:55:57 +02:00
|
|
|
result.photos.add m{"media_url_https"}.getImageStr
|
2020-06-01 02:16:24 +02:00
|
|
|
of "video":
|
|
|
|
result.video = some(parseVideo(m))
|
2020-06-10 17:05:55 +02:00
|
|
|
with user, m{"additional_media_info", "source_user"}:
|
2023-04-21 12:41:30 +00:00
|
|
|
if user{"id"}.getInt > 0:
|
|
|
|
result.attribution = some(parseUser(user))
|
|
|
|
else:
|
|
|
|
result.attribution = some(parseGraphUser(user))
|
2020-06-01 02:16:24 +02:00
|
|
|
of "animated_gif":
|
|
|
|
result.gif = some(parseGif(m))
|
|
|
|
else: discard
|
|
|
|
|
2023-07-10 11:25:34 +02:00
|
|
|
with url, m{"url"}:
|
|
|
|
if result.text.endsWith(url.getStr):
|
|
|
|
result.text.removeSuffix(url.getStr)
|
|
|
|
result.text = result.text.strip()
|
|
|
|
|
2021-12-26 06:48:55 +01:00
|
|
|
with jsWithheld, js{"withheld_in_countries"}:
|
2021-12-29 06:41:00 +01:00
|
|
|
let withheldInCountries: seq[string] =
|
|
|
|
if jsWithheld.kind != JArray: @[]
|
|
|
|
else: jsWithheld.to(seq[string])
|
2021-12-26 06:48:55 +01:00
|
|
|
|
|
|
|
# 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):
|
2021-12-29 06:41:00 +01:00
|
|
|
result.text.removeSuffix(" Learn more.")
|
2021-12-26 06:48:55 +01:00
|
|
|
result.available = false
|
2021-08-21 12:13:38 -03:00
|
|
|
|
2023-07-12 03:37:44 +02:00
|
|
|
proc parseLegacyTweet(js: JsonNode): Tweet =
|
|
|
|
result = parseTweet(js, js{"card"})
|
|
|
|
if not result.isNil and result.available:
|
|
|
|
result.user = parseUser(js{"user"})
|
|
|
|
|
|
|
|
if result.quote.isSome:
|
|
|
|
result.quote = some parseLegacyTweet(js{"quoted_status"})
|
|
|
|
|
2023-07-22 03:03:45 +02:00
|
|
|
proc parseTweetSearch*(js: JsonNode; after=""): Timeline =
|
|
|
|
result.beginning = after.len == 0
|
2023-07-12 03:37:44 +02:00
|
|
|
|
2023-07-22 03:03:45 +02:00
|
|
|
if js.kind == JNull or "modules" notin js or js{"modules"}.len == 0:
|
2020-06-07 07:53:40 +02:00
|
|
|
return
|
|
|
|
|
2023-07-22 03:03:45 +02:00
|
|
|
for item in js{"modules"}:
|
|
|
|
with tweet, item{"status", "data"}:
|
|
|
|
let parsed = parseLegacyTweet(tweet)
|
|
|
|
|
|
|
|
if parsed.retweet.isSome:
|
|
|
|
parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"})
|
|
|
|
|
|
|
|
result.content.add @[parsed]
|
|
|
|
|
|
|
|
if result.content.len > 0:
|
|
|
|
result.bottom = $(result.content[^1][0].id - 1)
|
|
|
|
|
2023-07-22 11:48:49 -04:00
|
|
|
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
|
|
|
|
let intId = if id.len > 0: parseBiggestInt(id) else: 0
|
|
|
|
result = global.tweets.getOrDefault(id, Tweet(id: intId))
|
|
|
|
|
|
|
|
if result.quote.isSome:
|
|
|
|
let quote = get(result.quote).id
|
|
|
|
if $quote in global.tweets:
|
|
|
|
result.quote = some global.tweets[$quote]
|
|
|
|
else:
|
|
|
|
result.quote = some Tweet()
|
|
|
|
|
|
|
|
if result.retweet.isSome:
|
|
|
|
let rt = get(result.retweet).id
|
|
|
|
if $rt in global.tweets:
|
|
|
|
result.retweet = some finalizeTweet(global, $rt)
|
|
|
|
else:
|
|
|
|
result.retweet = some Tweet()
|
|
|
|
|
|
|
|
proc parsePin(js: JsonNode; global: GlobalObjects): Tweet =
|
|
|
|
let pin = js{"pinEntry", "entry", "entryId"}.getStr
|
|
|
|
if pin.len == 0: return
|
|
|
|
|
|
|
|
let id = pin.getId
|
|
|
|
if id notin global.tweets: return
|
|
|
|
|
|
|
|
global.tweets[id].pinned = true
|
|
|
|
return finalizeTweet(global, id)
|
|
|
|
|
|
|
|
proc parseGlobalObjects(js: JsonNode): GlobalObjects =
|
|
|
|
result = GlobalObjects()
|
|
|
|
let
|
|
|
|
tweets = ? js{"globalObjects", "tweets"}
|
|
|
|
users = ? js{"globalObjects", "users"}
|
|
|
|
|
|
|
|
for k, v in users:
|
|
|
|
result.users[k] = parseUser(v, k)
|
|
|
|
|
|
|
|
for k, v in tweets:
|
|
|
|
var tweet = parseTweet(v, v{"card"})
|
|
|
|
if tweet.user.id in result.users:
|
|
|
|
tweet.user = result.users[tweet.user.id]
|
|
|
|
result.tweets[k] = tweet
|
|
|
|
|
|
|
|
proc parseInstructions(res: var Profile; global: GlobalObjects; js: JsonNode) =
|
|
|
|
if js.kind != JArray or js.len == 0:
|
|
|
|
return
|
|
|
|
|
|
|
|
for i in js:
|
|
|
|
if res.tweets.beginning and i{"pinEntry"}.notNull:
|
|
|
|
with pin, parsePin(i, global):
|
|
|
|
res.pinned = some pin
|
|
|
|
|
|
|
|
with r, i{"replaceEntry", "entry"}:
|
|
|
|
if "top" in r{"entryId"}.getStr:
|
|
|
|
res.tweets.top = r.getCursor
|
|
|
|
elif "bottom" in r{"entryId"}.getStr:
|
|
|
|
res.tweets.bottom = r.getCursor
|
|
|
|
|
|
|
|
proc parseTimeline*(js: JsonNode; after=""): Profile =
|
|
|
|
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
|
|
|
let global = parseGlobalObjects(? js)
|
|
|
|
|
|
|
|
let instructions = ? js{"timeline", "instructions"}
|
|
|
|
if instructions.len == 0: return
|
|
|
|
|
|
|
|
result.parseInstructions(global, instructions)
|
|
|
|
|
|
|
|
var entries: JsonNode
|
|
|
|
for i in instructions:
|
|
|
|
if "addEntries" in i:
|
|
|
|
entries = i{"addEntries", "entries"}
|
|
|
|
|
|
|
|
for e in ? entries:
|
|
|
|
let entry = e{"entryId"}.getStr
|
|
|
|
if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry:
|
|
|
|
let tweet = finalizeTweet(global, e.getEntryId)
|
|
|
|
if not tweet.available: continue
|
|
|
|
result.tweets.content.add tweet
|
|
|
|
elif "cursor-top" in entry:
|
|
|
|
result.tweets.top = e.getCursor
|
|
|
|
elif "cursor-bottom" in entry:
|
|
|
|
result.tweets.bottom = e.getCursor
|
|
|
|
elif entry.startsWith("sq-cursor"):
|
|
|
|
with cursor, e{"content", "operation", "cursor"}:
|
|
|
|
if cursor{"cursorType"}.getStr == "Bottom":
|
|
|
|
result.tweets.bottom = cursor{"value"}.getStr
|
|
|
|
else:
|
|
|
|
result.tweets.top = cursor{"value"}.getStr
|
2020-06-01 02:16:24 +02:00
|
|
|
|
2020-06-07 07:54:20 +02:00
|
|
|
proc parsePhotoRail*(js: JsonNode): PhotoRail =
|
2023-07-12 03:47:37 +02:00
|
|
|
with error, js{"error"}:
|
|
|
|
if error.getStr == "Not authorized.":
|
|
|
|
return
|
|
|
|
|
2020-06-17 00:20:34 +02:00
|
|
|
for tweet in js:
|
|
|
|
let
|
2023-07-10 11:25:34 +02:00
|
|
|
t = parseTweet(tweet, js{"tweet_card"})
|
2020-06-17 00:20:34 +02:00
|
|
|
url = if t.photos.len > 0: t.photos[0]
|
|
|
|
elif t.video.isSome: get(t.video).thumb
|
|
|
|
elif t.gif.isSome: get(t.gif).thumb
|
|
|
|
elif t.card.isSome: get(t.card).image
|
|
|
|
else: ""
|
|
|
|
|
|
|
|
if url.len == 0: continue
|
|
|
|
result.add GalleryPhoto(url: url, tweetId: $t.id)
|
2023-02-24 01:01:22 +01:00
|
|
|
|
2023-08-26 05:16:38 +02:00
|
|
|
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
2023-04-21 12:41:30 +00:00
|
|
|
if js.kind == JNull:
|
2023-05-20 02:10:37 +02:00
|
|
|
return Tweet()
|
2023-02-24 01:01:22 +01:00
|
|
|
|
2023-04-21 12:41:30 +00:00
|
|
|
case js{"__typename"}.getStr
|
|
|
|
of "TweetUnavailable":
|
2023-05-20 02:10:37 +02:00
|
|
|
return Tweet()
|
2023-04-21 12:41:30 +00:00
|
|
|
of "TweetTombstone":
|
2023-07-10 11:25:34 +02:00
|
|
|
with text, js{"tombstone", "richText"}:
|
|
|
|
return Tweet(text: text.getTombstone)
|
|
|
|
with text, js{"tombstone", "text"}:
|
|
|
|
return Tweet(text: text.getTombstone)
|
|
|
|
return Tweet()
|
2023-05-20 02:10:37 +02:00
|
|
|
of "TweetPreviewDisplay":
|
|
|
|
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
2023-04-21 12:41:30 +00:00
|
|
|
of "TweetWithVisibilityResults":
|
2023-08-26 05:16:38 +02:00
|
|
|
return parseGraphTweet(js{"tweet"}, isLegacy)
|
2023-11-01 04:04:45 +00:00
|
|
|
else:
|
|
|
|
discard
|
2023-04-21 12:41:30 +00:00
|
|
|
|
2023-09-14 23:35:41 +00:00
|
|
|
if not js.hasKey("legacy"):
|
|
|
|
return Tweet()
|
|
|
|
|
2023-08-26 05:16:38 +02:00
|
|
|
var jsCard = copy(js{if isLegacy: "card" else: "tweet_card", "legacy"})
|
2023-02-24 01:01:22 +01:00
|
|
|
if jsCard.kind != JNull:
|
|
|
|
var values = newJObject()
|
|
|
|
for val in jsCard["binding_values"]:
|
|
|
|
values[val["key"].getStr] = val["value"]
|
|
|
|
jsCard["binding_values"] = values
|
|
|
|
|
|
|
|
result = parseTweet(js{"legacy"}, jsCard)
|
2023-07-10 11:25:34 +02:00
|
|
|
result.id = js{"rest_id"}.getId
|
2023-04-21 12:41:30 +00:00
|
|
|
result.user = parseGraphUser(js{"core"})
|
2023-02-24 01:01:22 +01:00
|
|
|
|
2023-03-01 16:13:36 +01:00
|
|
|
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
|
|
|
result.expandNoteTweetEntities(noteTweet)
|
2023-03-01 00:53:44 +01:00
|
|
|
|
2023-02-24 01:01:22 +01:00
|
|
|
if result.quote.isSome:
|
2023-08-26 05:16:38 +02:00
|
|
|
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy))
|
2023-02-24 01:01:22 +01:00
|
|
|
|
|
|
|
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
|
|
|
for t in js{"content", "items"}:
|
|
|
|
let entryId = t{"entryId"}.getStr
|
|
|
|
if "cursor-showmore" in entryId:
|
2023-07-10 11:25:34 +02:00
|
|
|
let cursor = t{"item", "content", "value"}
|
2023-02-24 01:01:22 +01:00
|
|
|
result.thread.cursor = cursor.getStr
|
|
|
|
result.thread.hasMore = true
|
|
|
|
elif "tweet" in entryId:
|
2023-08-25 16:28:30 +02:00
|
|
|
let
|
|
|
|
isLegacy = t{"item"}.hasKey("itemContent")
|
|
|
|
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
|
|
|
|
else: ("content", "tweetResult")
|
2023-02-24 01:01:22 +01:00
|
|
|
|
2023-08-25 16:28:30 +02:00
|
|
|
with content, t{"item", contentKey}:
|
2023-08-26 05:16:38 +02:00
|
|
|
result.thread.content.add parseGraphTweet(content{resultKey, "result"}, isLegacy)
|
2023-08-25 16:28:30 +02:00
|
|
|
|
|
|
|
if content{"tweetDisplayType"}.getStr == "SelfThread":
|
|
|
|
result.self = true
|
2023-02-24 01:01:22 +01:00
|
|
|
|
2023-04-21 12:41:30 +00:00
|
|
|
proc parseGraphTweetResult*(js: JsonNode): Tweet =
|
2023-07-10 11:25:34 +02:00
|
|
|
with tweet, js{"data", "tweet_result", "result"}:
|
2023-08-26 05:16:38 +02:00
|
|
|
result = parseGraphTweet(tweet, false)
|
2023-04-21 12:41:30 +00:00
|
|
|
|
2023-02-24 01:01:22 +01:00
|
|
|
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
|
|
|
result = Conversation(replies: Result[Chain](beginning: true))
|
|
|
|
|
2023-08-25 16:28:30 +02:00
|
|
|
let instructions = ? js{"data", "threaded_conversation_with_injections_v2", "instructions"}
|
2023-02-24 01:01:22 +01:00
|
|
|
if instructions.len == 0:
|
|
|
|
return
|
|
|
|
|
|
|
|
for e in instructions[0]{"entries"}:
|
|
|
|
let entryId = e{"entryId"}.getStr
|
|
|
|
if entryId.startsWith("tweet"):
|
2023-08-25 16:28:30 +02:00
|
|
|
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
2023-08-26 05:16:38 +02:00
|
|
|
let tweet = parseGraphTweet(tweetResult, true)
|
2023-02-24 01:01:22 +01:00
|
|
|
|
2023-04-21 12:41:30 +00:00
|
|
|
if not tweet.available:
|
|
|
|
tweet.id = parseBiggestInt(entryId.getId())
|
2023-02-25 18:25:02 +01:00
|
|
|
|
2023-04-21 12:41:30 +00:00
|
|
|
if $tweet.id == tweetId:
|
|
|
|
result.tweet = tweet
|
|
|
|
else:
|
|
|
|
result.before.content.add tweet
|
|
|
|
elif entryId.startsWith("tombstone"):
|
|
|
|
let id = entryId.getId()
|
|
|
|
let tweet = Tweet(
|
|
|
|
id: parseBiggestInt(id),
|
|
|
|
available: false,
|
2023-08-25 16:28:30 +02:00
|
|
|
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
|
2023-04-21 12:41:30 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
if id == tweetId:
|
2023-02-24 01:01:22 +01:00
|
|
|
result.tweet = tweet
|
|
|
|
else:
|
|
|
|
result.before.content.add tweet
|
|
|
|
elif entryId.startsWith("conversationthread"):
|
|
|
|
let (thread, self) = parseGraphThread(e)
|
|
|
|
if self:
|
|
|
|
result.after = thread
|
|
|
|
else:
|
|
|
|
result.replies.content.add thread
|
|
|
|
elif entryId.startsWith("cursor-bottom"):
|
2023-08-25 16:28:30 +02:00
|
|
|
result.replies.bottom = e{"content", "itemContent", "value"}.getStr
|
2023-04-21 12:41:30 +00:00
|
|
|
|
2023-07-10 11:25:34 +02:00
|
|
|
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
|
|
|
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
2023-04-21 12:41:30 +00:00
|
|
|
|
|
|
|
let instructions =
|
2023-07-10 11:25:34 +02:00
|
|
|
if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"}
|
|
|
|
else: ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
2023-04-21 12:41:30 +00:00
|
|
|
|
|
|
|
if instructions.len == 0:
|
|
|
|
return
|
|
|
|
|
|
|
|
for i in instructions:
|
2023-07-10 11:25:34 +02:00
|
|
|
if i{"__typename"}.getStr == "TimelineAddEntries":
|
2023-04-21 12:41:30 +00:00
|
|
|
for e in i{"entries"}:
|
|
|
|
let entryId = e{"entryId"}.getStr
|
|
|
|
if entryId.startsWith("tweet"):
|
2023-07-10 11:25:34 +02:00
|
|
|
with tweetResult, e{"content", "content", "tweetResult", "result"}:
|
2023-08-26 05:16:38 +02:00
|
|
|
let tweet = parseGraphTweet(tweetResult, false)
|
2023-04-21 12:41:30 +00:00
|
|
|
if not tweet.available:
|
|
|
|
tweet.id = parseBiggestInt(entryId.getId())
|
2023-07-10 11:25:34 +02:00
|
|
|
result.tweets.content.add tweet
|
|
|
|
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
|
2023-05-26 17:23:40 -04:00
|
|
|
let (thread, self) = parseGraphThread(e)
|
2023-07-12 01:34:39 +02:00
|
|
|
result.tweets.content.add thread.content
|
2023-04-21 12:41:30 +00:00
|
|
|
elif entryId.startsWith("cursor-bottom"):
|
2023-07-10 11:25:34 +02:00
|
|
|
result.tweets.bottom = e{"content", "value"}.getStr
|
|
|
|
if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry":
|
|
|
|
with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}:
|
2023-08-26 05:16:38 +02:00
|
|
|
let tweet = parseGraphTweet(tweetResult, false)
|
2023-07-10 11:25:34 +02:00
|
|
|
tweet.pinned = true
|
|
|
|
if not tweet.available and tweet.tombstone.len == 0:
|
|
|
|
let entryId = i{"entry", "entryId"}.getEntryId
|
|
|
|
if entryId.len > 0:
|
|
|
|
tweet.id = parseBiggestInt(entryId)
|
|
|
|
result.pinned = some tweet
|
2023-04-21 12:41:30 +00:00
|
|
|
|
2023-06-05 22:38:17 -04:00
|
|
|
proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline =
|
2023-06-02 23:47:05 -04:00
|
|
|
result = UsersTimeline(beginning: after.len == 0)
|
|
|
|
|
2023-06-05 22:38:17 -04:00
|
|
|
let instructions = ? timeline{"instructions"}
|
2023-06-02 23:47:05 -04:00
|
|
|
|
|
|
|
if instructions.len == 0:
|
|
|
|
return
|
|
|
|
|
|
|
|
for i in instructions:
|
|
|
|
if i{"type"}.getStr == "TimelineAddEntries":
|
|
|
|
for e in i{"entries"}:
|
|
|
|
let entryId = e{"entryId"}.getStr
|
|
|
|
if entryId.startsWith("user"):
|
|
|
|
with graphUser, e{"content", "itemContent"}:
|
|
|
|
let user = parseGraphUser(graphUser)
|
|
|
|
result.content.add user
|
|
|
|
elif entryId.startsWith("cursor-bottom"):
|
|
|
|
result.bottom = e{"content", "value"}.getStr
|
|
|
|
elif entryId.startsWith("cursor-top"):
|
|
|
|
result.top = e{"content", "value"}.getStr
|
|
|
|
|
|
|
|
proc parseGraphFavoritersTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
|
2023-06-05 22:38:17 -04:00
|
|
|
return parseGraphUsersTimeline(js{"data", "favoriters_timeline", "timeline"}, after)
|
2023-06-02 23:47:05 -04:00
|
|
|
|
|
|
|
proc parseGraphRetweetersTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
|
2023-06-05 22:38:17 -04:00
|
|
|
return parseGraphUsersTimeline(js{"data", "retweeters_timeline", "timeline"}, after)
|
|
|
|
|
|
|
|
proc parseGraphFollowTimeline*(js: JsonNode; root: string; after=""): UsersTimeline =
|
|
|
|
return parseGraphUsersTimeline(js{"data", "user", "result", "timeline", "timeline"}, after)
|
2023-06-02 23:47:05 -04:00
|
|
|
|
2023-10-30 13:13:06 +01:00
|
|
|
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
|
|
|
result = Result[T](beginning: after.len == 0)
|
2023-04-21 12:41:30 +00:00
|
|
|
|
|
|
|
let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"}
|
|
|
|
if instructions.len == 0:
|
|
|
|
return
|
|
|
|
|
|
|
|
for instruction in instructions:
|
|
|
|
let typ = instruction{"type"}.getStr
|
|
|
|
if typ == "TimelineAddEntries":
|
2023-08-19 00:25:14 +02:00
|
|
|
for e in instruction{"entries"}:
|
2023-04-21 12:41:30 +00:00
|
|
|
let entryId = e{"entryId"}.getStr
|
2023-10-30 13:13:06 +01:00
|
|
|
when T is Tweets:
|
|
|
|
if entryId.startsWith("tweet"):
|
|
|
|
with tweetRes, e{"content", "itemContent", "tweet_results", "result"}:
|
|
|
|
let tweet = parseGraphTweet(tweetRes)
|
|
|
|
if not tweet.available:
|
|
|
|
tweet.id = parseBiggestInt(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"):
|
2023-04-21 12:41:30 +00:00
|
|
|
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
|