Twitter Linkify Trends

Make Twitter trends links (again)

目前为 2020-09-09 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter Linkify Trends
  3. // @description Make Twitter trends links (again)
  4. // @author chocolateboy
  5. // @copyright chocolateboy
  6. // @version 1.0.0
  7. // @namespace https://github.com/chocolateboy/userscripts
  8. // @license GPL: http://www.gnu.org/copyleft/gpl.html
  9. // @include https://twitter.com/
  10. // @include https://twitter.com/*
  11. // @include https://mobile.twitter.com/
  12. // @include https://mobile.twitter.com/*
  13. // @require https://code.jquery.com/jquery-3.5.1.slim.min.js
  14. // @require https://cdn.jsdelivr.net/gh/eclecto/jQuery-onMutate@79bbb2b8caccabfc9b9ade046fe63f15f593fef6/src/jquery.onmutate.min.js
  15. // @require https://cdn.jsdelivr.net/gh/chocolateboy/gm-compat@a26896b85770aa853b2cdaf2ff79029d8807d0c0/index.min.js
  16. // @require https://unpkg.com/@chocolateboy/uncommonjs@2.0.1/index.min.js
  17. // @require https://unpkg.com/tmp-cache@1.0.0/lib/index.js
  18. // @grant GM_log
  19. // @inject-into auto
  20. // ==/UserScript==
  21.  
  22. // XXX note: the unused grant is a workaround for a Greasemonkey bug:
  23. // https://github.com/greasemonkey/greasemonkey/issues/1614
  24.  
  25. // a map from event IDs to their URLs. populated via the intercepted trends
  26. // data (JSON)
  27. const CACHE = new exports.Cache({ maxAge: 60 * 60 * 1000 }) // one hour
  28.  
  29. // events to disable (stop propagating) on event and trend elements
  30. const DISABLED_EVENTS = 'click touch'
  31.  
  32. // path to the array of event records within the JSON document; each record
  33. // includes an ID, title, URL and image URL (which includes the ID)
  34. const EVENTS = 'timeline.instructions.*.addEntries.entries.*.content.timelineModule.items.*.item.content.eventSummary'
  35.  
  36. // an immutable array used to indicate "no values". static to avoid unnecessary
  37. // allocations
  38. const NONE = []
  39.  
  40. // selector for elements in the "What's happening" panel in the sidebar and the
  41. // dedicated trends pages (https://twitter.com/explore/tabs/*)
  42. //
  43. // includes actual trends as well as "events" (news items)
  44. const EVENT_SELECTOR = [
  45. 'div[role="link"]:not([data-testid])',
  46. ':has(> div > div:nth-child(2):nth-last-child(1) img[src])'
  47. ].join('')
  48.  
  49. const TREND_SELECTOR = 'div[role="link"][data-testid="trend"]'
  50.  
  51. const SELECTOR = [EVENT_SELECTOR, TREND_SELECTOR].join(', ')
  52.  
  53. // remove all of Twitter's interceptors for events raised on event elements
  54. function disableEventEvents (e) {
  55. // don't preventDefault: we still want links to work
  56. e.stopPropagation()
  57. }
  58.  
  59. // remove all of Twitter's interceptors for events raised on trend elements
  60. // apart from clicks on the caret, which opens a drop-down menu
  61. function disableTrendEvents (e) {
  62. const $target = $(e.target)
  63. const $caret = $target.closest('[data-testid="caret"]', this)
  64.  
  65. if (!$caret.length) {
  66. // don't preventDefault: we still want links to work
  67. e.stopPropagation()
  68. }
  69. }
  70.  
  71. // a version of lodash.get with support for wildcards
  72. function get (obj, path, $default) {
  73. if (!obj) {
  74. return $default
  75. }
  76.  
  77. let props, prop
  78.  
  79. if (Array.isArray(path)) {
  80. props = path.slice(0) // clone
  81. } else if (typeof path === 'string') {
  82. props = path.split('.')
  83. } else {
  84. throw new Error('path must be an array or string')
  85. }
  86.  
  87. while (props.length) {
  88. if (!obj) {
  89. return $default
  90. }
  91.  
  92. prop = props.shift()
  93.  
  94. if (prop === '*') {
  95. // Object.values is very forgiving and works with anything that
  96. // can be turned into an object via Object(...), i.e. everything
  97. // but undefined and null, which we've guarded against above.
  98. return Object.values(obj).flatMap(value => {
  99. return get(value, props.slice(0), NONE)
  100. })
  101. }
  102.  
  103. obj = obj[prop]
  104.  
  105. if (obj === undefined) {
  106. return $default
  107. }
  108. }
  109.  
  110. return obj
  111. }
  112.  
  113. // intercept XMLHTTPRequest#open calls which pull in data for the "What's
  114. // happening" (Trends) panel, and pass the response (JSON) to a custom handler
  115. // which extracts ID/URL pairs for the event elements
  116. function hookXHROpen (oldOpen) {
  117. return function open (_method, url) {
  118. const $url = new URL(url)
  119.  
  120. if ($url.pathname === '/2/guide.json') {
  121. // register a new listener
  122. this.addEventListener('load', () => processEvents(this.responseText))
  123. }
  124.  
  125. return oldOpen.apply(this, arguments)
  126. }
  127. }
  128.  
  129. // takes a URL and creates the link which is wrapped around the trend and event
  130. // titles
  131. function linkFor (href) {
  132. return $('<a></a>')
  133. .attr({ href, role: 'link', 'data-focusable': true })
  134. .css({ color: 'inherit', textDecoration: 'inherit' })
  135. }
  136.  
  137. // update an event element: the link is extracted from the JSON data used to
  138. // populate the "What's happening" panel.
  139. function onEvent ($event) {
  140. const { $target, title } = targetFor($event)
  141. const $title = JSON.stringify(title)
  142. const $image = $event.find('> div > div:nth-child(2) img[src]')
  143.  
  144. // console.debug(`event: ${$title}`)
  145.  
  146. if ($image.length === 0) {
  147. console.warn(`Can't find image in event: ${$title}`)
  148. return
  149. }
  150.  
  151. const key = new URL($image.attr('src')).pathname
  152. const url = CACHE.get(key)
  153.  
  154. if (url) {
  155. const $link = linkFor(url)
  156. $target.wrap($link)
  157. $event.find('div:has(> img)').wrap($link) // also wrap the image
  158. } else {
  159. console.warn(`Can't find URL for event: ${$title}`)
  160. }
  161. }
  162.  
  163. // update a trend element: the link is derived from the title in the element
  164. // rather than from the JSON
  165. function onTrend ($trend) {
  166. const { $target, title } = targetFor($trend)
  167. const unquoted = title.replace(/"/g, '')
  168.  
  169. // console.debug(`trend: ${JSON.stringify(unquoted)}`)
  170.  
  171. const query = encodeURIComponent('"' + unquoted + '"')
  172. const url = `${location.origin}/search?q=${query}`
  173.  
  174. $target.wrap(linkFor(url))
  175. }
  176.  
  177. // process a collection of newly-created trend or event elements. determines the
  178. // element's type and passes it to the appropriate handler
  179. function onTrends ($trends) {
  180. for (const el of $trends) {
  181. const $el = $(el)
  182.  
  183. // remove the fake pointer
  184. $el.css('cursor', 'auto')
  185.  
  186. // remove event hijacking and dispatch to the handler
  187. if ($el.data('testid') === 'trend') {
  188. $el.on(DISABLED_EVENTS, disableTrendEvents)
  189. onTrend($el)
  190. } else {
  191. $el.on(DISABLED_EVENTS, disableEventEvents)
  192. onEvent($el)
  193. }
  194. }
  195. }
  196.  
  197. // process the events data (JSON): extract ID/URL pairs for the event elements
  198. // and store them in a cache
  199. function processEvents (json) {
  200. const data = JSON.parse(json)
  201. const events = get(data, EVENTS, NONE)
  202.  
  203. if (!events.length) {
  204. return
  205. }
  206.  
  207. console.debug(`processing events: ${events.length}`)
  208.  
  209. for (const event of events) {
  210. const { image: { url: imageURL }, url: { url } } = event
  211. const key = new URL(imageURL).pathname.replace(/\.\w+$/, '')
  212.  
  213. CACHE.set(key, url)
  214. }
  215.  
  216. // keep track of the cache size (for now) to ensure it doesn't become a
  217. // memory hog
  218. console.debug(`cache size: ${CACHE.size}`)
  219. }
  220.  
  221. // given a trend or event element, return its target element — i.e. the SPAN
  222. // containing the element's title — along with its title text
  223. function targetFor ($el) {
  224. const $target = $el.find('div[dir="ltr"]').first().find('> span')
  225. const title = $target.text().trim()
  226.  
  227. return { $target, title }
  228. }
  229.  
  230. // hook HMLHTTPRequest#open so we can extract event data from the JSON
  231. const xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype
  232.  
  233. xhrProto.open = GMCompat.export(hookXHROpen(XMLHttpRequest.prototype.open))
  234.  
  235. // monitor the creation of trend/event elements
  236. $.onCreate(SELECTOR, onTrends, true /* multi */)