parserutils.nim 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  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?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 with*(ident, value, body): untyped =
  32. if true:
  33. let ident {.inject.} = value
  34. if ident != nil: body
  35. template with*(ident; value: JsonNode; body): untyped =
  36. if true:
  37. let ident {.inject.} = value
  38. # value.notNull causes a compilation error for versions < 1.6.14
  39. if notNull(value): body
  40. template getCursor*(js: JsonNode): string =
  41. js{"content", "operation", "cursor", "value"}.getStr
  42. template getError*(js: JsonNode): Error =
  43. if js.kind != JArray or js.len == 0: null
  44. else: Error(js[0]{"code"}.getInt)
  45. template parseTime(time: string; f: static string; flen: int): DateTime =
  46. if time.len != flen: return
  47. parse(time, f, utc())
  48. proc getDateTime*(js: JsonNode): DateTime =
  49. parseTime(js.getStr, "yyyy-MM-dd\'T\'HH:mm:ss\'Z\'", 20)
  50. proc getTime*(js: JsonNode): DateTime =
  51. parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
  52. proc getId*(id: string): string {.inline.} =
  53. let start = id.rfind("-")
  54. if start < 0: return id
  55. id[start + 1 ..< id.len]
  56. proc getId*(js: JsonNode): int64 {.inline.} =
  57. case js.kind
  58. of JString: return parseBiggestInt(js.getStr("0"))
  59. of JInt: return js.getBiggestInt()
  60. else: return 0
  61. proc getEntryId*(js: JsonNode): string {.inline.} =
  62. let entry = js{"entryId"}.getStr
  63. if entry.len == 0: return
  64. if "tweet" in entry or "sq-I-t" in entry:
  65. return entry.getId
  66. elif "tombstone" in entry:
  67. return js{"content", "item", "content", "tombstone", "tweet", "id"}.getStr
  68. else:
  69. echo "unknown entry: ", entry
  70. return
  71. template getStrVal*(js: JsonNode; default=""): string =
  72. js{"string_value"}.getStr(default)
  73. proc getImageStr*(js: JsonNode): string =
  74. result = js.getStr
  75. result.removePrefix(https)
  76. result.removePrefix(twimg)
  77. template getImageVal*(js: JsonNode): string =
  78. js{"image_value", "url"}.getImageStr
  79. proc getCardUrl*(js: JsonNode; kind: CardKind): string =
  80. result = js{"website_url"}.getStrVal
  81. if kind == promoVideoConvo:
  82. result = js{"thank_you_url"}.getStrVal(result)
  83. if result.startsWith("card://"):
  84. result = ""
  85. proc getCardDomain*(js: JsonNode; kind: CardKind): string =
  86. result = js{"vanity_url"}.getStrVal(js{"domain"}.getStr)
  87. if kind == promoVideoConvo:
  88. result = js{"thank_you_vanity_url"}.getStrVal(result)
  89. proc getCardTitle*(js: JsonNode; kind: CardKind): string =
  90. result = js{"title"}.getStrVal
  91. if kind == promoVideoConvo:
  92. result = js{"thank_you_text"}.getStrVal(result)
  93. elif kind == liveEvent:
  94. result = js{"event_category"}.getStrVal
  95. elif kind in {videoDirectMessage, imageDirectMessage}:
  96. result = js{"cta1"}.getStrVal
  97. proc getBanner*(js: JsonNode): string =
  98. let url = js{"profile_banner_url"}.getImageStr
  99. if url.len > 0:
  100. return url & "/1500x500"
  101. let color = js{"profile_link_color"}.getStr
  102. if color.len > 0:
  103. return '#' & color
  104. # use primary color from profile picture color histogram
  105. with p, js{"profile_image_extensions", "mediaColor", "r", "ok", "palette"}:
  106. if p.len > 0:
  107. let pal = p[0]{"rgb"}
  108. result = "#"
  109. result.add toHex(pal{"red"}.getInt, 2)
  110. result.add toHex(pal{"green"}.getInt, 2)
  111. result.add toHex(pal{"blue"}.getInt, 2)
  112. return
  113. proc getTombstone*(js: JsonNode): string =
  114. result = js{"text"}.getStr
  115. result.removeSuffix(" Learn more")
  116. proc getMp4Resolution*(url: string): int =
  117. # parses the height out of a URL like this one:
  118. # https://video.twimg.com/ext_tw_video/<tweet-id>/pu/vid/720x1280/<random>.mp4
  119. const vidSep = "/vid/"
  120. let
  121. vidIdx = url.find(vidSep) + vidSep.len
  122. resIdx = url.find('x', vidIdx) + 1
  123. res = url[resIdx ..< url.find("/", resIdx)]
  124. try:
  125. return parseInt(res)
  126. except ValueError:
  127. # cannot determine resolution (e.g. m3u8/non-mp4 video)
  128. return 0
  129. proc getVideoViewCount*(js: JsonNode): string =
  130. with stats, js{"ext_media_stats"}:
  131. return stats{"view_count"}.getStr($stats{"viewCount"}.getInt)
  132. return $js{"mediaStats", "viewCount"}.getInt(0)
  133. proc extractSlice(js: JsonNode): Slice[int] =
  134. result = js["indices"][0].getInt ..< js["indices"][1].getInt
  135. proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
  136. textLen: int; hideTwitter = false) =
  137. let
  138. url = js["expanded_url"].getStr
  139. slice = js.extractSlice
  140. if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
  141. if slice.a < textLen:
  142. result.add ReplaceSlice(kind: rkRemove, slice: slice)
  143. else:
  144. result.add ReplaceSlice(kind: rkUrl, url: url,
  145. display: url.shortLink, slice: slice)
  146. proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
  147. result.add ReplaceSlice(kind: rkHashtag, slice: js.extractSlice)
  148. proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
  149. textSlice: Slice[int]): string =
  150. template extractLowerBound(i: int; idx): int =
  151. if i > 0: repls[idx].slice.b.succ else: textSlice.a
  152. result = newStringOfCap(runes.len)
  153. for i, rep in repls:
  154. result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a]
  155. case rep.kind
  156. of rkHashtag:
  157. let
  158. name = $runes[rep.slice.a.succ .. rep.slice.b]
  159. symbol = $runes[rep.slice.a]
  160. result.add a(symbol & name, href = "/search?q=%23" & name)
  161. of rkMention:
  162. result.add a($runes[rep.slice], href = rep.url, title = rep.display)
  163. of rkUrl:
  164. result.add a(rep.display, href = rep.url)
  165. of rkRemove:
  166. discard
  167. let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b
  168. if rest.a <= rest.b:
  169. result.add $runes[rest]
  170. proc deduplicate(s: var seq[ReplaceSlice]) =
  171. var
  172. len = s.len
  173. i = 0
  174. while i < len:
  175. var j = i + 1
  176. while j < len:
  177. if s[i].slice.a == s[j].slice.a:
  178. s.del j
  179. dec len
  180. else:
  181. inc j
  182. inc i
  183. proc cmp(x, y: ReplaceSlice): int = cmp(x.slice.a, y.slice.b)
  184. proc expandUserEntities*(user: var User; js: JsonNode) =
  185. let
  186. orig = user.bio.toRunes
  187. ent = ? js{"entities"}
  188. with urls, ent{"url", "urls"}:
  189. user.website = urls[0]{"expanded_url"}.getStr
  190. var replacements = newSeq[ReplaceSlice]()
  191. with urls, ent{"description", "urls"}:
  192. for u in urls:
  193. replacements.extractUrls(u, orig.high)
  194. replacements.deduplicate
  195. replacements.sort(cmp)
  196. user.bio = orig.replacedWith(replacements, 0 .. orig.len)
  197. user.bio = user.bio.replacef(unRegex, unReplace)
  198. .replacef(htRegex, htReplace)
  199. proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
  200. replyTo=""; hasQuote=false) =
  201. let hasCard = tweet.card.isSome
  202. var replacements = newSeq[ReplaceSlice]()
  203. with urls, entities{"urls"}:
  204. for u in urls:
  205. let urlStr = u["url"].getStr
  206. if urlStr.len == 0 or urlStr notin text:
  207. continue
  208. replacements.extractUrls(u, textSlice.b, hideTwitter = hasQuote)
  209. if hasCard and u{"url"}.getStr == get(tweet.card).url:
  210. get(tweet.card).url = u{"expanded_url"}.getStr
  211. with media, entities{"media"}:
  212. for m in media:
  213. replacements.extractUrls(m, textSlice.b, hideTwitter = true)
  214. if "hashtags" in entities:
  215. for hashtag in entities["hashtags"]:
  216. replacements.extractHashtags(hashtag)
  217. if "symbols" in entities:
  218. for symbol in entities["symbols"]:
  219. replacements.extractHashtags(symbol)
  220. if "user_mentions" in entities:
  221. for mention in entities["user_mentions"]:
  222. let
  223. name = mention{"screen_name"}.getStr
  224. slice = mention.extractSlice
  225. idx = tweet.reply.find(name)
  226. if slice.a >= textSlice.a:
  227. replacements.add ReplaceSlice(kind: rkMention, slice: slice,
  228. url: "/" & name, display: mention["name"].getStr)
  229. if idx > -1 and name != replyTo:
  230. tweet.reply.delete idx
  231. elif idx == -1 and tweet.replyId != 0:
  232. tweet.reply.add name
  233. replacements.deduplicate
  234. replacements.sort(cmp)
  235. tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
  236. proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
  237. let
  238. entities = ? js{"entities"}
  239. hasQuote = js{"is_quote_status"}.getBool
  240. textRange = js{"display_text_range"}
  241. textSlice = textRange{0}.getInt .. textRange{1}.getInt
  242. var replyTo = ""
  243. if tweet.replyId != 0:
  244. with reply, js{"in_reply_to_screen_name"}:
  245. replyTo = reply.getStr
  246. tweet.reply.add replyTo
  247. tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote)
  248. proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
  249. let
  250. entities = ? js{"entity_set"}
  251. text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
  252. textSlice = 0..text.runeLen
  253. tweet.expandTextEntities(entities, text, textSlice)
  254. tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))