parserutils.nim 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. # SPDX-License-Identifier: AGPL-3.0-only
  2. import std/[times, macros, htmlgen, options, algorithm, re]
  3. import std/strutils except escape
  4. import std/unicode except strip
  5. from xmltree import escape
  6. import packedjson
  7. import types, utils, formatters
  8. const
  9. unicodeOpen = "\uFFFA"
  10. unicodeClose = "\uFFFB"
  11. xmlOpen = escape("<")
  12. xmlClose = escape(">")
  13. let
  14. unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
  15. unReplace = "$1<a href=\"/$2\">@$2</a>"
  16. htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)"
  17. htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
  18. type
  19. ReplaceSliceKind = enum
  20. rkRemove, rkUrl, rkHashtag, rkMention
  21. ReplaceSlice = object
  22. slice: Slice[int]
  23. kind: ReplaceSliceKind
  24. url, display: string
  25. template isNull*(js: JsonNode): bool = js.kind == JNull
  26. template notNull*(js: JsonNode): bool = js.kind != JNull
  27. template `?`*(js: JsonNode): untyped =
  28. let j = js
  29. if j.isNull: return
  30. j
  31. template select*(a, b: JsonNode): untyped =
  32. if a.notNull: a else: b
  33. template select*(a, b, c: JsonNode): untyped =
  34. if a.notNull: a elif b.notNull: b else: c
  35. template with*(ident, value, body): untyped =
  36. if true:
  37. let ident {.inject.} = value
  38. if ident != nil: body
  39. template with*(ident; value: JsonNode; body): untyped =
  40. if true:
  41. let ident {.inject.} = value
  42. if value.notNull: body
  43. template getCursor*(js: JsonNode): string =
  44. js{"content", "operation", "cursor", "value"}.getStr
  45. template getError*(js: JsonNode): Error =
  46. if js.kind != JArray or js.len == 0: null
  47. else: Error(js[0]{"code"}.getInt)
  48. proc getTweetResult*(js: JsonNode; root="content"): JsonNode =
  49. select(
  50. js{root, "content", "tweet_results", "result"},
  51. js{root, "itemContent", "tweet_results", "result"},
  52. js{root, "content", "tweetResult", "result"}
  53. )
  54. template getTypeName*(js: JsonNode): string =
  55. js{"__typename"}.getStr(js{"type"}.getStr)
  56. template getEntryId*(e: JsonNode): string =
  57. e{"entryId"}.getStr(e{"entry_id"}.getStr)
  58. template parseTime(time: string; f: static string; flen: int): DateTime =
  59. if time.len != flen: return
  60. parse(time, f, utc())
  61. proc getDateTime*(js: JsonNode): DateTime =
  62. parseTime(js.getStr, "yyyy-MM-dd\'T\'HH:mm:ss\'Z\'", 20)
  63. proc getTime*(js: JsonNode): DateTime =
  64. parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
  65. proc getTimeFromMs*(js: JsonNode): DateTime =
  66. let ms = js.getInt(0)
  67. if ms == 0: return
  68. let seconds = ms div 1000
  69. return fromUnix(seconds).utc()
  70. proc getTimeFromMsStr*(js: JsonNode): DateTime =
  71. var ms: int64
  72. try: ms = parseBiggestInt(js.getStr("0"))
  73. except ValueError: return
  74. if ms == 0: return
  75. let seconds = ms div 1000
  76. return fromUnix(seconds).utc()
  77. proc getId*(id: string): int64 {.inline.} =
  78. let start = id.rfind("-")
  79. if start < 0:
  80. return parseBiggestInt(id)
  81. return parseBiggestInt(id[start + 1 ..< id.len])
  82. proc getId*(js: JsonNode): int64 {.inline.} =
  83. case js.kind
  84. of JString: return js.getStr("0").getId
  85. of JInt: return js.getBiggestInt()
  86. else: return 0
  87. template getStrVal*(js: JsonNode; default=""): string =
  88. js{"string_value"}.getStr(default)
  89. proc getImageStr*(js: JsonNode): string =
  90. result = js.getStr
  91. result.removePrefix(https)
  92. result.removePrefix(twimg)
  93. template getImageVal*(js: JsonNode): string =
  94. js{"image_value", "url"}.getImageStr
  95. template getExpandedUrl*(js: JsonNode; fallback=""): string =
  96. js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
  97. proc getCardUrl*(js: JsonNode; kind: CardKind): string =
  98. result = js{"website_url"}.getStrVal
  99. if kind == promoVideoConvo:
  100. result = js{"thank_you_url"}.getStrVal(result)
  101. if result.startsWith("card://"):
  102. result = ""
  103. proc getCardDomain*(js: JsonNode; kind: CardKind): string =
  104. result = js{"vanity_url"}.getStrVal(js{"domain"}.getStr)
  105. if kind == promoVideoConvo:
  106. result = js{"thank_you_vanity_url"}.getStrVal(result)
  107. proc getCardTitle*(js: JsonNode; kind: CardKind): string =
  108. result = js{"title"}.getStrVal
  109. if kind == promoVideoConvo:
  110. result = js{"thank_you_text"}.getStrVal(result)
  111. elif kind == liveEvent:
  112. result = js{"event_category"}.getStrVal
  113. elif kind in {videoDirectMessage, imageDirectMessage}:
  114. result = js{"cta1"}.getStrVal
  115. proc getBanner*(js: JsonNode): string =
  116. let url = js{"profile_banner_url"}.getImageStr
  117. if url.len > 0:
  118. return url & "/1500x500"
  119. let color = js{"profile_link_color"}.getStr
  120. if color.len > 0:
  121. return '#' & color
  122. # use primary color from profile picture color histogram
  123. with p, js{"profile_image_extensions", "mediaColor", "r", "ok", "palette"}:
  124. if p.len > 0:
  125. let pal = p[0]{"rgb"}
  126. result = "#"
  127. result.add toHex(pal{"red"}.getInt, 2)
  128. result.add toHex(pal{"green"}.getInt, 2)
  129. result.add toHex(pal{"blue"}.getInt, 2)
  130. return
  131. proc getTombstone*(js: JsonNode): string =
  132. result = js{"text"}.getStr
  133. result.removeSuffix(" Learn more")
  134. proc getMp4Resolution*(url: string): int =
  135. # parses the height out of a URL like this one:
  136. # https://video.twimg.com/ext_tw_video/<tweet-id>/pu/vid/720x1280/<random>.mp4
  137. const vidSep = "/vid/"
  138. let
  139. vidIdx = url.find(vidSep) + vidSep.len
  140. resIdx = url.find('x', vidIdx) + 1
  141. res = url[resIdx ..< url.find("/", resIdx)]
  142. try:
  143. return parseInt(res)
  144. except ValueError:
  145. # cannot determine resolution (e.g. m3u8/non-mp4 video)
  146. return 0
  147. proc extractSlice(js: JsonNode): Slice[int] =
  148. result = js["indices"][0].getInt ..< js["indices"][1].getInt
  149. proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
  150. textLen: int; hideTwitter = false;
  151. hideArticle = false) =
  152. let
  153. url = js.getExpandedUrl
  154. slice = js.extractSlice
  155. if hideArticle and url.isTwitterUrl and "/article/" in url:
  156. if slice.a < textLen:
  157. result.add ReplaceSlice(kind: rkRemove, slice: slice)
  158. elif hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
  159. if slice.a < textLen:
  160. result.add ReplaceSlice(kind: rkRemove, slice: slice)
  161. else:
  162. result.add ReplaceSlice(kind: rkUrl, url: url,
  163. display: url.shortLink, slice: slice)
  164. proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
  165. result.add ReplaceSlice(kind: rkHashtag, slice: js.extractSlice)
  166. proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
  167. textSlice: Slice[int]): string =
  168. let
  169. runeLen = runes.len
  170. safeStart = max(0, textSlice.a)
  171. safeEnd = min(runeLen, textSlice.b)
  172. var validRepls: seq[ReplaceSlice]
  173. for rep in repls:
  174. if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b:
  175. validRepls.add rep
  176. template extractLowerBound(i: int; idx): int =
  177. if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart
  178. result = newStringOfCap(runes.len)
  179. for i, rep in validRepls:
  180. let lower = extractLowerBound(i, i - 1)
  181. if lower < rep.slice.a:
  182. result.add $runes[lower ..< rep.slice.a]
  183. case rep.kind
  184. of rkHashtag:
  185. if rep.slice.a.succ <= rep.slice.b:
  186. let
  187. name = $runes[rep.slice.a.succ .. rep.slice.b]
  188. symbol = $runes[rep.slice.a]
  189. result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
  190. of rkMention:
  191. result.add a($runes[rep.slice], href = rep.url, title = rep.display)
  192. of rkUrl:
  193. result.add a(rep.display, href = rep.url)
  194. of rkRemove:
  195. discard
  196. let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd
  197. if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen:
  198. result.add $runes[rest]
  199. proc deduplicate(s: var seq[ReplaceSlice]) =
  200. var
  201. len = s.len
  202. i = 0
  203. while i < len:
  204. var j = i + 1
  205. while j < len:
  206. if s[i].slice.a == s[j].slice.a:
  207. s.del j
  208. dec len
  209. else:
  210. inc j
  211. inc i
  212. proc cmp(x, y: ReplaceSlice): int = cmp(x.slice.a, y.slice.b)
  213. proc expandUserEntities*(user: var User; js: JsonNode) =
  214. let
  215. orig = user.bio.toRunes
  216. ent = ? js{"entities"}
  217. with urls, ent{"url", "urls"}:
  218. user.website = urls[0].getExpandedUrl
  219. var replacements = newSeq[ReplaceSlice]()
  220. with urls, ent{"description", "urls"}:
  221. for u in urls:
  222. replacements.extractUrls(u, orig.high)
  223. replacements.deduplicate
  224. replacements.sort(cmp)
  225. user.bio = orig.replacedWith(replacements, 0 .. orig.len)
  226. user.bio = user.bio.replacef(unRegex, unReplace)
  227. .replacef(htRegex, htReplace)
  228. proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
  229. replyTo=""; hasRedundantLink=false) =
  230. let hasCard = tweet.card.isSome
  231. var replacements = newSeq[ReplaceSlice]()
  232. with urls, entities{"urls"}:
  233. for u in urls:
  234. let urlStr = u["url"].getStr
  235. if urlStr.len == 0 or urlStr notin text:
  236. continue
  237. replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
  238. if hasCard and u{"url"}.getStr == get(tweet.card).url:
  239. get(tweet.card).url = u.getExpandedUrl
  240. with media, entities{"media"}:
  241. for m in media:
  242. replacements.extractUrls(m, textSlice.b, hideTwitter = true)
  243. if "hashtags" in entities:
  244. for hashtag in entities["hashtags"]:
  245. replacements.extractHashtags(hashtag)
  246. if "symbols" in entities:
  247. for symbol in entities["symbols"]:
  248. replacements.extractHashtags(symbol)
  249. if "user_mentions" in entities:
  250. for mention in entities["user_mentions"]:
  251. let
  252. name = mention{"screen_name"}.getStr
  253. slice = mention.extractSlice
  254. idx = tweet.reply.find(name)
  255. if slice.a >= textSlice.a:
  256. replacements.add ReplaceSlice(kind: rkMention, slice: slice,
  257. url: "/" & name, display: mention["name"].getStr)
  258. if idx > -1 and name != replyTo:
  259. tweet.reply.delete idx
  260. elif idx == -1 and tweet.replyId != 0:
  261. tweet.reply.add name
  262. replacements.deduplicate
  263. replacements.sort(cmp)
  264. tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
  265. proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
  266. let
  267. entities = ? js{"entities"}
  268. textRange = js{"display_text_range"}
  269. textSlice = textRange{0}.getInt .. textRange{1}.getInt
  270. hasQuote = js{"is_quote_status"}.getBool
  271. hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
  272. var replyTo = ""
  273. if tweet.replyId != 0:
  274. with reply, js{"in_reply_to_screen_name"}:
  275. replyTo = reply.getStr
  276. tweet.reply.add replyTo
  277. tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo,
  278. hasQuote or hasJobCard)
  279. proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
  280. hasRedundantLink=false; hasArticle=false) =
  281. let hasCard = tweet.card.isSome
  282. var replacements = newSeq[ReplaceSlice]()
  283. with urls, js{"url_entities"}:
  284. for u in urls:
  285. let urlStr = u["url"].getStr
  286. if urlStr.len == 0 or urlStr notin text:
  287. continue
  288. replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink,
  289. hideArticle = hasArticle)
  290. if hasCard and u{"url"}.getStr == get(tweet.card).url:
  291. get(tweet.card).url = u.getExpandedUrl
  292. with hashtags, js{"details", "hashtag_entities"}:
  293. for hashtag in hashtags:
  294. replacements.extractHashtags(hashtag)
  295. with cashtags, js{"details", "cashtag_entities"}:
  296. for cashtag in cashtags:
  297. replacements.extractHashtags(cashtag)
  298. with mentions, js{"mention_entities"}:
  299. for mention in mentions:
  300. let
  301. name = mention{"screen_name"}.getStr
  302. slice = mention.extractSlice
  303. idx = tweet.reply.find(name)
  304. if slice.a >= textSlice.a:
  305. replacements.add ReplaceSlice(kind: rkMention, slice: slice,
  306. url: "/" & name, display: mention["name"].getStr)
  307. elif idx == -1 and tweet.replyId != 0:
  308. tweet.reply.add name
  309. replacements.deduplicate
  310. replacements.sort(cmp)
  311. tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
  312. proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode; hasArticle=false) =
  313. let
  314. textRange = js{"details", "display_text_range"}
  315. textSlice = textRange{0}.getInt .. textRange{1}.getInt
  316. hasQuote = "quoted_tweet_results" in js
  317. hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
  318. hasAttribution = tweet.attribution.isSome
  319. tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
  320. hasQuote or hasJobCard or hasAttribution,
  321. hasArticle)
  322. proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
  323. let
  324. entities = ? js{"entity_set"}
  325. text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
  326. textSlice = 0..text.runeLen
  327. hasAttribution = tweet.attribution.isSome
  328. tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution)
  329. tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
  330. proc expandBirdwatchEntities*(text: string; entities: JsonNode): string =
  331. let runes = text.toRunes
  332. var replacements: seq[ReplaceSlice]
  333. for entity in entities:
  334. let
  335. fromIdx = entity{"from_index"}.getInt
  336. toIdx = entity{"to_index"}.getInt
  337. url = entity{"ref", "url"}.getStr
  338. if url.len > 0:
  339. replacements.add ReplaceSlice(
  340. kind: rkUrl,
  341. slice: fromIdx ..< toIdx,
  342. url: url,
  343. display: $runes[fromIdx ..< min(toIdx, runes.len)]
  344. )
  345. replacements.sort(cmp)
  346. result = runes.replacedWith(replacements, 0 ..< runes.len)
  347. proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
  348. let url =
  349. if t.media.len > 0: t.media[0].getThumb
  350. elif t.card.isSome: get(t.card).image
  351. else: ""
  352. result = GalleryPhoto(url: url, tweetId: $t.id)