parser.nim 9.0 KB

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