parserutils.nim 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. # SPDX-License-Identifier: AGPL-3.0-only
  2. import strutils, times, macros, htmlgen, unicode, options, algorithm
  3. import regex, packedjson
  4. import types, utils, formatters
  5. const
  6. unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})"
  7. unReplace = "$1<a href=\"/$2\">@$2</a>"
  8. htRegex = re"(^|[^\w-_./?])([##$])([\w_]+)"
  9. htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>"
  10. type
  11. ReplaceSliceKind = enum
  12. rkRemove, rkUrl, rkHashtag, rkMention
  13. ReplaceSlice = object
  14. slice: Slice[int]
  15. kind: ReplaceSliceKind
  16. url, display: string
  17. template isNull*(js: JsonNode): bool = js.kind == JNull
  18. template notNull*(js: JsonNode): bool = js.kind != JNull
  19. template `?`*(js: JsonNode): untyped =
  20. let j = js
  21. if j.isNull: return
  22. j
  23. template `with`*(ident, value, body): untyped =
  24. block:
  25. let ident {.inject.} = value
  26. if ident != nil: body
  27. template `with`*(ident; value: JsonNode; body): untyped =
  28. block:
  29. let ident {.inject.} = value
  30. if value.notNull: body
  31. template getCursor*(js: JsonNode): string =
  32. js{"content", "operation", "cursor", "value"}.getStr
  33. template getError*(js: JsonNode): Error =
  34. if js.kind != JArray or js.len == 0: null
  35. else: Error(js[0]{"code"}.getInt)
  36. template parseTime(time: string; f: static string; flen: int): DateTime =
  37. if time.len != flen: return
  38. parse(time, f, utc())
  39. proc getDateTime*(js: JsonNode): DateTime =
  40. parseTime(js.getStr, "yyyy-MM-dd\'T\'HH:mm:ss\'Z\'", 20)
  41. proc getTime*(js: JsonNode): DateTime =
  42. parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30)
  43. proc getId*(id: string): string {.inline.} =
  44. let start = id.rfind("-")
  45. if start < 0: return id
  46. id[start + 1 ..< id.len]
  47. proc getId*(js: JsonNode): int64 {.inline.} =
  48. case js.kind
  49. of JString: return parseBiggestInt(js.getStr("0"))
  50. of JInt: return js.getBiggestInt()
  51. else: return 0
  52. proc getEntryId*(js: JsonNode): string {.inline.} =
  53. let entry = js{"entryId"}.getStr
  54. if entry.len == 0: return
  55. if "tweet" in entry or "sq-I-t" in entry:
  56. return entry.getId
  57. elif "tombstone" in entry:
  58. return js{"content", "item", "content", "tombstone", "tweet", "id"}.getStr
  59. else:
  60. echo "unknown entry: ", entry
  61. return
  62. template getStrVal*(js: JsonNode; default=""): string =
  63. js{"string_value"}.getStr(default)
  64. proc getImageStr*(js: JsonNode): string =
  65. result = js.getStr
  66. result.removePrefix(https)
  67. result.removePrefix(twimg)
  68. template getImageVal*(js: JsonNode): string =
  69. js{"image_value", "url"}.getImageStr
  70. proc getCardUrl*(js: JsonNode; kind: CardKind): string =
  71. result = js{"website_url"}.getStrVal
  72. if kind == promoVideoConvo:
  73. result = js{"thank_you_url"}.getStrVal(result)
  74. if result.startsWith("card://"):
  75. result = ""
  76. proc getCardDomain*(js: JsonNode; kind: CardKind): string =
  77. result = js{"vanity_url"}.getStrVal(js{"domain"}.getStr)
  78. if kind == promoVideoConvo:
  79. result = js{"thank_you_vanity_url"}.getStrVal(result)
  80. proc getCardTitle*(js: JsonNode; kind: CardKind): string =
  81. result = js{"title"}.getStrVal
  82. if kind == promoVideoConvo:
  83. result = js{"thank_you_text"}.getStrVal(result)
  84. elif kind == liveEvent:
  85. result = js{"event_category"}.getStrVal
  86. elif kind in {videoDirectMessage, imageDirectMessage}:
  87. result = js{"cta1"}.getStrVal
  88. proc getBanner*(js: JsonNode): string =
  89. let url = js{"profile_banner_url"}.getImageStr
  90. if url.len > 0:
  91. return url & "/1500x500"
  92. let color = js{"profile_link_color"}.getStr
  93. if color.len > 0:
  94. return '#' & color
  95. # use primary color from profile picture color histogram
  96. with p, js{"profile_image_extensions", "mediaColor", "r", "ok", "palette"}:
  97. if p.len > 0:
  98. let pal = p[0]{"rgb"}
  99. result = "#"
  100. result.add toHex(pal{"red"}.getInt, 2)
  101. result.add toHex(pal{"green"}.getInt, 2)
  102. result.add toHex(pal{"blue"}.getInt, 2)
  103. return
  104. return "#161616"
  105. proc getTombstone*(js: JsonNode): string =
  106. result = js{"tombstoneInfo", "richText", "text"}.getStr
  107. result.removeSuffix(" Learn more")
  108. proc extractSlice(js: JsonNode): Slice[int] =
  109. result = js["indices"][0].getInt ..< js["indices"][1].getInt
  110. proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
  111. textLen: int; hideTwitter = false) =
  112. let
  113. url = js["expanded_url"].getStr
  114. slice = js.extractSlice
  115. if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
  116. if slice.a < textLen:
  117. result.add ReplaceSlice(kind: rkRemove, slice: slice)
  118. else:
  119. result.add ReplaceSlice(kind: rkUrl, url: url,
  120. display: url.shortLink, slice: slice)
  121. proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
  122. result.add ReplaceSlice(kind: rkHashtag, slice: js.extractSlice)
  123. proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
  124. textSlice: Slice[int]): string =
  125. template extractLowerBound(i: int; idx): int =
  126. if i > 0: repls[idx].slice.b.succ else: textSlice.a
  127. result = newStringOfCap(runes.len)
  128. for i, rep in repls:
  129. result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a]
  130. case rep.kind
  131. of rkHashtag:
  132. let
  133. name = $runes[rep.slice.a.succ .. rep.slice.b]
  134. symbol = $runes[rep.slice.a]
  135. result.add a(symbol & name, href = "/search?q=%23" & name)
  136. of rkMention:
  137. result.add a($runes[rep.slice], href = rep.url, title = rep.display)
  138. of rkUrl:
  139. result.add a(rep.display, href = rep.url)
  140. of rkRemove:
  141. discard
  142. let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b
  143. if rest.a <= rest.b:
  144. result.add $runes[rest]
  145. proc deduplicate(s: var seq[ReplaceSlice]) =
  146. var
  147. len = s.len
  148. i = 0
  149. while i < len:
  150. var j = i + 1
  151. while j < len:
  152. if s[i].slice.a == s[j].slice.a:
  153. s.del j
  154. dec len
  155. else:
  156. inc j
  157. inc i
  158. proc cmp(x, y: ReplaceSlice): int = cmp(x.slice.a, y.slice.b)
  159. proc expandProfileEntities*(profile: var Profile; js: JsonNode) =
  160. let
  161. orig = profile.bio.toRunes
  162. ent = ? js{"entities"}
  163. with urls, ent{"url", "urls"}:
  164. profile.website = urls[0]{"expanded_url"}.getStr
  165. var replacements = newSeq[ReplaceSlice]()
  166. with urls, ent{"description", "urls"}:
  167. for u in urls:
  168. replacements.extractUrls(u, orig.high)
  169. replacements.deduplicate
  170. replacements.sort(cmp)
  171. profile.bio = orig.replacedWith(replacements, 0 .. orig.len)
  172. profile.bio = profile.bio.replace(unRegex, unReplace)
  173. .replace(htRegex, htReplace)
  174. proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
  175. let
  176. orig = tweet.text.toRunes
  177. textRange = js{"display_text_range"}
  178. textSlice = textRange{0}.getInt .. textRange{1}.getInt
  179. hasQuote = js{"is_quote_status"}.getBool
  180. hasCard = tweet.card.isSome
  181. var replyTo = ""
  182. if tweet.replyId != 0:
  183. with reply, js{"in_reply_to_screen_name"}:
  184. tweet.reply.add reply.getStr
  185. replyTo = reply.getStr
  186. let ent = ? js{"entities"}
  187. var replacements = newSeq[ReplaceSlice]()
  188. with urls, ent{"urls"}:
  189. for u in urls:
  190. let urlStr = u["url"].getStr
  191. if urlStr.len == 0 or urlStr notin tweet.text:
  192. continue
  193. replacements.extractUrls(u, textSlice.b, hideTwitter = hasQuote)
  194. if hasCard and u{"url"}.getStr == get(tweet.card).url:
  195. get(tweet.card).url = u{"expanded_url"}.getStr
  196. with media, ent{"media"}:
  197. for m in media:
  198. replacements.extractUrls(m, textSlice.b, hideTwitter = true)
  199. if "hashtags" in ent:
  200. for hashtag in ent["hashtags"]:
  201. replacements.extractHashtags(hashtag)
  202. if "symbols" in ent:
  203. for symbol in ent["symbols"]:
  204. replacements.extractHashtags(symbol)
  205. if "user_mentions" in ent:
  206. for mention in ent["user_mentions"]:
  207. let
  208. name = mention{"screen_name"}.getStr
  209. slice = mention.extractSlice
  210. idx = tweet.reply.find(name)
  211. if slice.a >= textSlice.a:
  212. replacements.add ReplaceSlice(kind: rkMention, slice: slice,
  213. url: "/" & name, display: mention["name"].getStr)
  214. if idx > -1 and name != replyTo:
  215. tweet.reply.delete idx
  216. elif idx == -1 and tweet.replyId != 0:
  217. tweet.reply.add name
  218. replacements.deduplicate
  219. replacements.sort(cmp)
  220. tweet.text = orig.replacedWith(replacements, textSlice)