parser.nim 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import xmltree, sequtils, strutils, json, options
  2. import types, parserutils, formatters
  3. proc parseJsonData*(node: XmlNode): JsonNode =
  4. let jsonData = node.selectAttr("input.json-data", "value")
  5. if jsonData.len > 0:
  6. return parseJson(jsonData)
  7. proc parseTimelineProfile*(node: XmlNode): Profile =
  8. let profile = node.select(".ProfileHeaderCard")
  9. if profile == nil:
  10. let data = parseJsonData(node)
  11. if data != nil and data{"sectionName"}.getStr == "suspended":
  12. let username = data{"internalReferer"}.getStr.strip(chars={'/'})
  13. return Profile(username: username, suspended: true)
  14. return
  15. let pre = ".ProfileHeaderCard-"
  16. let username = profile.getUsername(pre & "screenname")
  17. result = Profile(
  18. fullname: profile.getName(pre & "nameLink"),
  19. username: username,
  20. lowername: toLower(username),
  21. joinDate: profile.getDate(pre & "joinDateText"),
  22. website: profile.selectAttr(pre & "urlText a", "title"),
  23. bio: profile.getBio(pre & "bio"),
  24. location: getLocation(profile),
  25. userpic: node.getAvatar(".profile-picture img"),
  26. verified: isVerified(profile),
  27. protected: isProtected(profile),
  28. banner: getTimelineBanner(node),
  29. media: getMediaCount(node)
  30. )
  31. result.getProfileStats(node.select(".ProfileNav-list"))
  32. proc parsePopupProfile*(node: XmlNode; selector=".profile-card"): Profile =
  33. let profile = node.select(selector)
  34. if profile == nil: return
  35. let username = profile.getUsername(".username")
  36. result = Profile(
  37. fullname: profile.getName(".fullname"),
  38. username: username,
  39. lowername: toLower(username),
  40. bio: profile.getBio(".bio", fallback=".ProfileCard-bio"),
  41. userpic: profile.getAvatar(".ProfileCard-avatarImage"),
  42. verified: isVerified(profile),
  43. protected: isProtected(profile),
  44. banner: getBanner(profile)
  45. )
  46. result.getPopupStats(profile)
  47. proc parseListProfile*(profile: XmlNode): Profile =
  48. result = Profile(
  49. fullname: profile.getName(".fullname"),
  50. username: profile.getUsername(".username"),
  51. bio: profile.getBio(".bio").stripText(),
  52. userpic: profile.getAvatar(".avatar"),
  53. verified: isVerified(profile),
  54. protected: isProtected(profile),
  55. )
  56. proc parseIntentProfile*(profile: XmlNode): Profile =
  57. result = Profile(
  58. fullname: profile.getName("a.fn.url.alternate-context"),
  59. username: profile.getUsername(".nickname"),
  60. bio: profile.getBio("p.note"),
  61. userpic: profile.select(".profile.summary").getAvatar("img.photo"),
  62. verified: profile.select("li.verified") != nil,
  63. protected: profile.select("li.protected") != nil,
  64. banner: getBanner(profile)
  65. )
  66. result.getIntentStats(profile)
  67. proc parseTweetProfile*(profile: XmlNode): Profile =
  68. result = Profile(
  69. fullname: profile.attr("data-name").stripText(),
  70. username: profile.attr("data-screen-name"),
  71. userpic: profile.getAvatar(".avatar"),
  72. verified: isVerified(profile)
  73. )
  74. proc parseQuote*(quote: XmlNode): Quote =
  75. result = Quote(
  76. id: parseBiggestInt(quote.attr("data-item-id")),
  77. text: getQuoteText(quote),
  78. reply: parseTweetReply(quote),
  79. hasThread: quote.select(".self-thread-context") != nil,
  80. available: true
  81. )
  82. result.profile = Profile(
  83. fullname: quote.selectText(".QuoteTweet-fullname").stripText(),
  84. username: quote.attr("data-screen-name"),
  85. verified: isVerified(quote)
  86. )
  87. result.getQuoteMedia(quote)
  88. proc parseTweet*(node: XmlNode): Tweet =
  89. if node == nil:
  90. return Tweet()
  91. if "withheld" in node.attr("class"):
  92. return Tweet(tombstone: getTombstone(node.selectText(".Tombstone-label")))
  93. let tweet = node.select(".tweet")
  94. if tweet == nil:
  95. return Tweet()
  96. result = Tweet(
  97. id: parseBiggestInt(tweet.attr("data-item-id")),
  98. threadId: parseBiggestInt(tweet.attr("data-conversation-id")),
  99. text: getTweetText(tweet),
  100. time: getTimestamp(tweet),
  101. shortTime: getShortTime(tweet),
  102. profile: parseTweetProfile(tweet),
  103. stats: parseTweetStats(tweet),
  104. reply: parseTweetReply(tweet),
  105. mediaTags: getMediaTags(tweet),
  106. location: getTweetLocation(tweet),
  107. hasThread: tweet.select(".content > .self-thread-context") != nil,
  108. pinned: "pinned" in tweet.attr("class"),
  109. available: true
  110. )
  111. result.getTweetMedia(tweet)
  112. result.getTweetCard(tweet)
  113. let by = tweet.selectText(".js-retweet-text > a > b")
  114. if by.len > 0:
  115. result.retweet = some Retweet(
  116. by: stripText(by),
  117. id: parseBiggestInt(tweet.attr("data-retweet-id"))
  118. )
  119. let quote = tweet.select(".QuoteTweet-innerContainer")
  120. if quote != nil:
  121. result.quote = some parseQuote(quote)
  122. let tombstone = tweet.select(".Tombstone")
  123. if tombstone != nil:
  124. if "unavailable" in tombstone.innerText():
  125. let quote = Quote(tombstone: getTombstone(node.selectText(".Tombstone-label")))
  126. result.quote = some quote
  127. proc parseChain*(nodes: XmlNode): Chain =
  128. if nodes == nil: return
  129. result = Chain()
  130. for n in nodes.filterIt(it.kind != xnText):
  131. let class = n.attr("class").toLower()
  132. if "tombstone" in class or "unavailable" in class or "withheld" in class:
  133. result.content.add Tweet()
  134. elif "morereplies" in class:
  135. result.more = getMoreReplies(n)
  136. else:
  137. result.content.add parseTweet(n)
  138. proc parseReplies*(replies: XmlNode; skipFirst=false): Result[Chain] =
  139. new(result)
  140. for i, reply in replies.filterIt(it.kind != xnText):
  141. if skipFirst and i == 0: continue
  142. let class = reply.attr("class").toLower()
  143. if "lone" in class:
  144. result.content.add parseChain(reply)
  145. elif "showmore" in class:
  146. result.minId = reply.selectAttr("button", "data-cursor")
  147. result.hasMore = true
  148. else:
  149. result.content.add parseChain(reply.select(".stream-items"))
  150. proc parseConversation*(node: XmlNode; after: string): Conversation =
  151. let tweet = node.select(".permalink-tweet-container")
  152. if tweet == nil:
  153. return Conversation(tweet: parseTweet(node.select(".permalink-tweet-withheld")))
  154. result = Conversation(
  155. tweet: parseTweet(tweet),
  156. before: parseChain(node.select(".in-reply-to .stream-items")),
  157. )
  158. if result.before != nil:
  159. let maxId = node.selectAttr(".in-reply-to .stream-container", "data-max-position")
  160. if maxId.len > 0:
  161. result.before.more = -1
  162. let replies = node.select(".replies-to .stream-items")
  163. if replies == nil: return
  164. let nodes = replies.filterIt(it.kind != xnText and "self" in it.attr("class"))
  165. if nodes.len > 0 and "self" in nodes[0].attr("class"):
  166. result.after = parseChain(nodes[0].select(".stream-items"))
  167. result.replies = parseReplies(replies, result.after != nil)
  168. result.replies.beginning = after.len == 0
  169. if result.replies.minId.len == 0:
  170. result.replies.minId = node.selectAttr(".replies-to .stream-container", "data-min-position")
  171. result.replies.hasMore = node.select(".stream-footer .has-more-items") != nil
  172. proc parseTimeline*(node: XmlNode; after: string): Timeline =
  173. if node == nil: return Timeline()
  174. result = Timeline(
  175. content: parseChain(node.select(".stream > .stream-items")).content,
  176. minId: node.attr("data-min-position"),
  177. maxId: node.attr("data-max-position"),
  178. hasMore: node.select(".has-more-items") != nil,
  179. beginning: after.len == 0
  180. )
  181. proc parseVideo*(node: JsonNode; tweetId: int64): Video =
  182. let
  183. track = node{"track"}
  184. cType = track["contentType"].to(string)
  185. pType = track["playbackType"].to(string)
  186. case cType
  187. of "media_entity":
  188. result = Video(
  189. playbackType: if "mp4" in pType: mp4 else: m3u8,
  190. contentId: track["contentId"].to(string),
  191. durationMs: track["durationMs"].to(int),
  192. views: track["viewCount"].to(string),
  193. url: track["playbackUrl"].to(string),
  194. available: track{"mediaAvailability"}["status"].to(string) == "available",
  195. reason: track{"mediaAvailability"}["reason"].to(string))
  196. of "vmap":
  197. result = Video(
  198. playbackType: vmap,
  199. durationMs: track.getOrDefault("durationMs").getInt(0),
  200. url: track["vmapUrl"].to(string),
  201. available: true)
  202. else:
  203. echo "Can't parse video of type ", cType, " ", tweetId
  204. result.videoId = $tweetId
  205. result.thumb = node["posterImage"].to(string)
  206. proc parsePoll*(node: XmlNode): Poll =
  207. let
  208. choices = node.selectAll(".PollXChoice-choice")
  209. votes = node.selectText(".PollXChoice-footer--total")
  210. result.votes = votes.strip().split(" ")[0]
  211. result.status = node.selectText(".PollXChoice-footer--time")
  212. for choice in choices:
  213. for span in choice.select(".PollXChoice-choice--text").filterIt(it.kind != xnText):
  214. if span.attr("class").len == 0:
  215. result.options.add span.innerText()
  216. elif "progress" in span.attr("class"):
  217. result.values.add parseInt(span.innerText()[0 .. ^2])
  218. var highest = 0
  219. for i, n in result.values:
  220. if n > highest:
  221. highest = n
  222. result.leader = i
  223. proc parsePhotoRail*(node: XmlNode): seq[GalleryPhoto] =
  224. for img in node.selectAll(".tweet-media-img-placeholder"):
  225. result.add GalleryPhoto(
  226. url: img.attr("data-image-url"),
  227. tweetId: img.attr("data-tweet-id"),
  228. color: img.attr("background-color").replace("style: ", "")
  229. )
  230. proc parseCard*(card: var Card; node: XmlNode) =
  231. card.title = node.selectText("h2.TwitterCard-title")
  232. card.text = node.selectText("p.tcu-resetMargin")
  233. card.dest = node.selectText("span.SummaryCard-destination")
  234. if card.url.len == 0:
  235. card.url = node.selectAttr("a", "href")
  236. if card.url.len == 0:
  237. card.url = node.selectAttr(".ConvoCard-thankYouContent", "data-thank-you-url")
  238. let image = node.select(".tcu-imageWrapper img")
  239. if image != nil:
  240. # workaround for issue 11713
  241. card.image = some image.attr("data-src").replace("gname", "g&name")
  242. if card.kind == liveEvent:
  243. card.text = card.title
  244. card.title = node.selectText(".TwitterCard-attribution--category")