您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Remove t.co tracking links from Twitter
当前为
- // ==UserScript==
- // @name Twitter Direct
- // @description Remove t.co tracking links from Twitter
- // @author chocolateboy
- // @copyright chocolateboy
- // @version 2.1.5
- // @namespace https://github.com/chocolateboy/userscripts
- // @license GPL
- // @include https://twitter.com/
- // @include https://twitter.com/*
- // @include https://mobile.twitter.com/
- // @include https://mobile.twitter.com/*
- // @require https://unpkg.com/gm-compat@1.1.0/dist/index.iife.min.js
- // @run-at document-start
- // ==/UserScript==
- /*
- * a pattern which matches the content-type header of responses we scan for
- * URLs: "application/json" or "application/json; charset=utf-8"
- */
- const CONTENT_TYPE = /^application\/json\b/
- /*
- * document keys under which t.co URL nodes can be found when the document is a
- * plain object. not used when the document is an array.
- *
- * some densely-populated top-level paths don't contain t.co URLs, e.g.
- * $.timeline.
- */
- const DOCUMENT_ROOTS = [
- 'data',
- 'globalObjects',
- 'inbox_initial_state',
- 'users',
- ]
- /*
- * keys of "legacy" objects which URL data is known to be found in/under, e.g.
- * we're interested in legacy.user_refs.*, legacy.retweeted_status.* and
- * legacy.url, but not in legacy.created_at or legacy.reply_count.
- *
- * objects under the "legacy" key typically contain dozens of keys, but t.co
- * URLs only exist in a handful of these.
- *
- * typically this reduces the number of keys to iterate in a legacy object from
- * 30 on average (max 39) to 2 or 3
- */
- const LEGACY_KEYS = [
- 'binding_values',
- 'entities',
- 'extended_entities',
- 'quoted_status_permalink',
- 'retweeted_status',
- 'retweeted_status_result',
- 'user_refs',
- ]
- /*
- * the minimum size (in bytes) of documents we deem to be "not small"
- *
- * we log (to the console) misses (i.e. no URLs ever found/replaced) in
- * documents whose size is greater than or equal to this value
- */
- const LOG_THRESHOLD = 1024
- /*
- * nodes under these keys never contain t.co URLs so we can speed up traversal
- * by pruning (not descending) them
- */
- const PRUNE_KEYS = new Set([
- 'advertiser_account_service_levels',
- 'card_platform',
- 'clientEventInfo',
- 'ext',
- 'ext_media_color',
- 'features',
- 'feedbackInfo',
- 'hashtags',
- 'original_info',
- 'player_image_color',
- 'profile_banner_extensions',
- 'profile_banner_extensions_media_color',
- 'profile_image_extensions',
- 'profile_image_extensions_media_color',
- 'responseObjects',
- 'sizes',
- 'user_mentions',
- 'video_info',
- ])
- /*
- * a map from URI paths (strings) to the replacement count for each path. used
- * to keep a running total of the number of replacements in each document type
- */
- const STATS = {}
- /*
- * a pattern which matches the domain(s) we expect data (JSON) to come from.
- * responses which don't come from a matching domain are ignored.
- */
- const TWITTER_API = /^(?:(?:api|mobile)\.)?twitter\.com$/
- /*
- * a list of document URIs (paths) which are known to not contain t.co URLs and
- * which therefore don't need to be processed
- */
- const URL_BLACKLIST = new Set([
- '/i/api/1.1/hashflags.json',
- '/i/api/2/badge_count/badge_count.json',
- '/i/api/graphql/articleNudgeDomains',
- '/i/api/graphql/TopicToFollowSidebar',
- ])
- /*
- * object keys whose corresponding values may be t.co URLs
- */
- const URL_KEYS = new Set(['url', 'string_value'])
- /*
- * return a truthy value (the URL itself) if the supplied value is a valid URL
- * (string), falsey otherwise
- */
- const checkUrl = (function () {
- // this is faster than using the URL constructor (in v8), which incurs
- // the overhead of using a try/catch block
- const urlPattern = /^https?:\/\/\w/i
- // no need to coerce the value to a string as RegExp#test does that
- // automatically
- //
- // https://tc39.es/ecma262/#sec-regexp.prototype.test
- return value => urlPattern.test(value) && value
- })()
- /*
- * replace the built-in XHR#send method with a custom version which swaps in our
- * custom response handler. once done, we delegate to the original handler
- * (this.onreadystatechange)
- */
- const hookXHRSend = oldSend => {
- return /** @this {XMLHttpRequest} */ function send (body = null) {
- const oldOnReadyStateChange = this.onreadystatechange
- this.onreadystatechange = function (event) {
- if (this.readyState === this.DONE && this.responseURL && this.status === 200) {
- onResponse(this, this.responseURL)
- }
- if (oldOnReadyStateChange) {
- oldOnReadyStateChange.call(this, event)
- }
- }
- oldSend.call(this, body)
- }
- }
- /*
- * return true if the supplied value is an array or plain object, false otherwise
- */
- const isObject = value => value && (typeof value === 'object')
- /*
- * return true if the supplied value is a plain object, false otherwise
- *
- * only used with JSON data, so doesn't need to be foolproof
- */
- const isPlainObject = (function () {
- const toString = {}.toString
- return value => toString.call(value) === '[object Object]'
- })()
- /*
- * return true if the supplied value is a t.co URL (string), false otherwise
- */
- const isTrackedUrl = (function () {
- // this is faster (in v8) than using the URL constructor (and a try/catch
- // block)
- const urlPattern = /^https?:\/\/t\.co\/\w+$/
- // no need to coerce the value to a string as RegExp#test does that
- // automatically
- return value => urlPattern.test(value)
- })()
- /*
- * replacement for Twitter's default handler for XHR requests. we transform the
- * response if it's a) JSON and b) contains URL data; otherwise, we leave it
- * unchanged
- */
- const onResponse = (xhr, uri) => {
- const contentType = xhr.getResponseHeader('Content-Type')
- if (!CONTENT_TYPE.test(contentType)) {
- return
- }
- const url = new URL(uri)
- // exclude e.g. the config-<date>.json file from pbs.twimg.com, which is the
- // second biggest document (~500K) after home_latest.json (~700K)
- if (!TWITTER_API.test(url.hostname)) {
- return
- }
- const json = xhr.responseText
- const size = json.length
- // fold paths which differ only in the user or query ID, e.g.:
- //
- // /2/timeline/profile/1234.json -> /2/timeline/profile.json
- // /i/api/graphql/abc123/UserTweets -> /i/api/graphql/UserTweets
- //
- const path = url.pathname
- .replace(/\/\d+\.json$/, '.json')
- .replace(/^(.+?\/graphql\/)[^\/]+\/(.+)$/, '$1$2')
- if (URL_BLACKLIST.has(path)) {
- return
- }
- let data
- try {
- data = JSON.parse(json)
- } catch (e) {
- console.error(`Can't parse JSON for ${uri}:`, e)
- return
- }
- if (!isObject(data)) {
- return
- }
- const newPath = !(path in STATS)
- const count = transform(data, path)
- STATS[path] = (STATS[path] || 0) + count
- if (!count) {
- if (!STATS[path] && size > LOG_THRESHOLD) {
- console.debug(`no replacements in ${path} (${size} B)`)
- }
- return
- }
- const descriptor = { value: JSON.stringify(data) }
- const clone = GMCompat.export(descriptor)
- GMCompat.unsafeWindow.Object.defineProperty(xhr, 'responseText', clone)
- const replacements = 'replacement' + (count === 1 ? '' : 's')
- console.debug(`${count} ${replacements} in ${path} (${size} B)`)
- if (newPath) {
- console.log(STATS)
- }
- }
- /*
- * JSON.stringify +replace+ function used by +transform+ to traverse documents
- * and update their URL nodes in place.
- */
- const replacerFor = state => /** @this {any} */ function replacer (key, value) {
- // exclude subtrees which never contain t.co URLs
- if (PRUNE_KEYS.has(key)) {
- return 0 // a terminal value to stop traversal
- }
- // we only care about the "card_url" property in binding_values
- // objects/arrays. exclude the other 24 properties
- if (key === 'binding_values') {
- if (Array.isArray(value)) {
- const found = value.find(it => it?.key === 'card_url')
- return found ? [found] : 0
- } else if (isPlainObject(value)) {
- return { card_url: (value.card_url || 0) }
- } else {
- return 0
- }
- }
- // reduce the keys under this.legacy (typically around 30) to the handful we
- // care about
- if (key === 'legacy' && isPlainObject(value)) {
- // XXX don't expand legacy.url: leaving it unexpanded results in media
- // URLs (e.g. YouTube URLs) appearing as clickable links in the tweet
- // (which we want)
- // we could use an array, but it doesn't appear to be faster (in v8)
- const filtered = {}
- for (let i = 0; i < LEGACY_KEYS.length; ++i) {
- const key = LEGACY_KEYS[i]
- if (key in value) {
- filtered[key] = value[key]
- }
- }
- return filtered
- }
- // expand t.co URL nodes in place
- if (URL_KEYS.has(key) && isTrackedUrl(value)) {
- const { seen, unresolved } = state
- let expandedUrl
- if ((expandedUrl = seen.get(value))) {
- this[key] = expandedUrl
- ++state.count
- } else if ((expandedUrl = checkUrl(this.expanded_url || this.expanded))) {
- seen.set(value, expandedUrl)
- this[key] = expandedUrl
- ++state.count
- } else {
- let targets = unresolved.get(value)
- if (!targets) {
- unresolved.set(value, targets = [])
- }
- targets.push({ target: this, key })
- }
- return 0
- }
- // shrink terminals (don't waste space/memory in the (discarded) JSON)
- return isObject(value) ? value : 0
- }
- /*
- * replace t.co URLs with the original URL in all locations in the document
- * which may contain them
- *
- * returns the number of substituted URLs
- */
- const transform = (data, path) => {
- const seen = new Map()
- const unresolved = new Map()
- const state = { count: 0, seen, unresolved }
- const replacer = replacerFor(state)
- // [1] top-level tweet or user data (e.g. /favorites/create.json)
- if (Array.isArray(data) || ('id_str' in data) /* [1] */) {
- JSON.stringify(data, replacer)
- } else {
- for (const key of DOCUMENT_ROOTS) {
- if (key in data) {
- JSON.stringify(data[key], replacer)
- }
- }
- }
- for (const [url, targets] of unresolved) {
- const expandedUrl = seen.get(url)
- if (expandedUrl) {
- for (const { target, key } of targets) {
- target[key] = expandedUrl
- ++state.count
- }
- unresolved.delete(url)
- }
- }
- if (unresolved.size) {
- console.warn(`unresolved URIs (${path}):`, Object.fromEntries(state.unresolved))
- }
- return state.count
- }
- /*
- * replace the default XHR#send with our custom version, which scans responses
- * for tweets and expands their URLs
- */
- const xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype
- xhrProto.send = GMCompat.export(hookXHRSend(xhrProto.send))