您需要先安装一个扩展,例如 篡改猴、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 0.2.0 // @namespace https://github.com/chocolateboy/userscripts // @license GPL: https://www.gnu.org/copyleft/gpl.html // @include https://twitter.com/ // @include https://twitter.com/* // @include https://mobile.twitter.com/ // @include https://mobile.twitter.com/* // @require https://unpkg.com/@chocolateboy/[email protected] // @require https://cdn.jsdelivr.net/npm/[email protected] // @run-at document-start // @inject-into auto // ==/UserScript== const { set } = module.exports // grab the default export from just-safe-set /* * the domain we expect metadata (JSON) to come from. if responses come from * this domain, we strip it before passing the document's URI to the transformer. */ const TWITTER_API = 'api.twitter.com' /* * default locations to search for URL metadata (arrays of objects) within tweet * nodes */ const TWEET_PATHS = [ 'entities.media', 'entities.urls', 'extended_entities.media', 'extended_entities.urls', ] /* * default locations to search for URL metadata (arrays of objects) within * user/profile nodes */ const USER_PATHS = [ 'entities.description.urls', 'entities.url.urls', ] /* * an immutable array used in various places as a default value. reused to avoid * unnecessary allocations. */ const EMPTY_ARRAY = [] /* * paths into the JSON data in which we can find context objects, i.e. objects * which have an `entities` (and/or `extended_entities`) property which contains * URL metadata * * options: * * - uri: optional URI filter: string (equality) or regex (match) * * - root: a path (string or array) into the document under which to begin * searching (required) * * - collect: a function which takes a root node and turns it into an array of * context nodes to scan for URL data (default: Object.values) * * - scan: an array of paths to probe for arrays of { url, expanded_url } * pairs in a context node (default: USER_PATHS) * * - assign: an array of paths to string nodes in the context containing * unexpanded URLs (e.g. for cards inside tweets). these are replaced with * expanded URLs gathered during the scan (default: EMPTY_ARRAY) */ const QUERIES = [ { uri: /\/users\/lookup\.json$/, root: [], // returns self }, { uri: /\/Following$/, root: 'data.user.following_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy', }, { uri: /\/Followers$/, root: 'data.user.followers_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy', }, { // found in /graphql/<query-id>/UserByScreenName // used for hovercard data root: 'data.user.legacy', collect: Array.of, }, { root: 'globalObjects.tweets', scan: TWEET_PATHS, assign: [ 'card.binding_values.card_url.string_value', 'card.url', ], }, { // spotted in list.json and all.json (used for hovercard data). // may exist in other documents root: 'globalObjects.tweets.*.card.users.*', }, { root: 'globalObjects.users', }, ] /* * a pattern which matches the content-type header of responses we scan for * tweets: "application/json" or "application/json; charset=utf-8" */ const CONTENT_TYPE = /^application\/json\b/ /* * compatibility shim needed for Violentmonkey: * https://github.com/violentmonkey/violentmonkey/issues/997#issuecomment-637700732 */ const Compat = { unsafeWindow } /* * a function which takes an object and a path into that object (a string of * dot-separated property names or an array of property names) and returns the * value at that position within the object, or the (optional) default value if * it can't be reached. * * based on just-safe-get by Angus Croll [1] (which in turn is an implementation * of Lodash's function of the same name), but with added support for * wildcard props, e.g.: * * foo.*.bar.baz.*.quux * * is roughly equivalent to: * * obj.foo * |> Object.values(#) * |> #.flatMap(value => get(value, "bar.baz", [])) * |> Object.values(#) * |> #.flatMap(value => get(value, "quux", [])) * * [1] https://www.npmjs.com/package/just-safe-get */ // TODO release as an NPM module (just-safe-get is ES5 only, but this // requires ES6 for Array#flatMap and Object.values, though both could be // polyfilled function get (obj, path, $default) { if (!obj) { return $default } let props, prop if (Array.isArray(path)) { props = Array.from(path) // clone } else if (typeof path === 'string') { props = path.split('.') } else { throw new Error('path must be an array or string') } while (props.length) { if (!obj) { return $default } prop = props.shift() if (prop === '*') { // Object.values is very forgiving and works with anything that // can be turned into an object via Object(...), i.e. everything // but undefined and null, which we've guarded against above. return Object.values(obj).flatMap(value => { return get(value, Array.from(props), EMPTY_ARRAY) }) } obj = obj[prop] if (obj === undefined) { return $default } } return obj } /* * replace t.co URLs with the original URL in all locations within the document * which contain URLs */ function transformLinks (data, uri) { const stats = new Map() for (const query of QUERIES) { const wantUri = query.uri if (wantUri) { const match = (typeof wantUri === 'string') ? uri === wantUri : wantUri.test(uri) if (!match) { continue } } const root = get(data, query.root) // may be an array (e.g. lookup.json) if (!(root && (typeof root === 'object'))) { continue } const { collect = Object.values, scan = USER_PATHS, assign = EMPTY_ARRAY, } = query const contexts = collect(root) for (const context of contexts) { const cache = new Map() // scan the context nodes for { url, expanded_url } pairs, replace // each t.co URL with its expansion, and cache the mappings for (const path of scan) { const items = get(context, path, []) for (const item of items) { cache.set(item.url, item.expanded_url) item.url = item.expanded_url stats.set(query.root, (stats.get(query.root) || 0) + 1) } } // now pinpoint isolated URLs in the context which don't have a // corresponding expansion, and replace them using the mappings we // created during the scan for (const path of assign) { const url = get(context, path) if (typeof url === 'string') { const expandedUrl = cache.get(url) if (expandedUrl) { set(context, path, expandedUrl) stats.set(query.root, (stats.get(query.root) || 0) + 1) } } } } } if (stats.size) { // format: "expanded 1 URL in "a.b" and 2 URLs in "c.d" in /2/example.json" const summary = Array.from(stats).map(([path, count]) => { const quantity = count === 1 ? '1 URL' : `${count} URLs` return `${quantity} in ${JSON.stringify(path)}` }).join(' and ') console.debug(`expanded ${summary} in ${uri}`) } return data } /* * parse and transform the JSON response, handling (catching and logging) any * errors */ function transformResponse (json, path) { let parsed try { parsed = JSON.parse(json) } catch (e) { console.error("Can't parse response:", e) return } let transformed try { transformed = transformLinks(parsed, path) } catch (e) { console.error('Error transforming JSON:', e) return } return transformed } /* * replacement for Twitter's default response handler. we transform the response * if it's a) JSON and b) contains URL data; otherwise, we leave it unchanged */ function onReadyStateChange (xhr, url) { const contentType = xhr.getResponseHeader('Content-Type') if (!CONTENT_TYPE.test(contentType)) { return } const parsed = new URL(url) const path = parsed.hostname === TWITTER_API ? parsed.pathname : parsed.origin + parsed.pathname const transformed = transformResponse(xhr.responseText, path) if (transformed) { const descriptor = { value: JSON.stringify(transformed) } const clone = Compat.cloneInto(descriptor, Compat.unsafeWindow) Compat.unsafeWindow.Object.defineProperty(xhr, 'responseText', clone) } } /* * replace the built-in XHR#send method with our custom version which swaps in * our custom response handler. once done, we delegate to the original handler * (this.onreadystatechange) */ function hookXHRSend (oldSend) { return function send () { const oldOnReadyStateChange = this.onreadystatechange this.onreadystatechange = function () { if (this.readyState === this.DONE && this.responseURL && this.status === 200) { onReadyStateChange(this, this.responseURL) } oldOnReadyStateChange.apply(this, arguments) } return oldSend.apply(this, arguments) } } /* * set up a cross-engine API to shield us from differences between engines so we * don't have to clutter the code with conditionals. * * XXX the functions are only needed by Violentmonkey for Firefox, and are * no-ops in other engines */ if ((typeof cloneInto === 'function') && (typeof exportFunction === 'function')) { // Greasemonkey 4 (Firefox) and Violentmonkey (Firefox + Chrome) Object.assign(Compat, { cloneInto, exportFunction }) // Violentmonkey for Firefox if (unsafeWindow.wrappedJSObject) { Compat.unsafeWindow = unsafeWindow.wrappedJSObject } } else { Compat.cloneInto = Compat.exportFunction = value => value } /* * replace the default XHR#send with our custom version, which scans responses * for tweets and expands their URLs */ console.debug('hooking XHR#send:', Compat.unsafeWindow.XMLHttpRequest.prototype.send) Compat.unsafeWindow.XMLHttpRequest.prototype.send = Compat.exportFunction( hookXHRSend(window.XMLHttpRequest.prototype.send), Compat.unsafeWindow )