Twitter Direct

Remove t.co tracking links from Twitter

目前為 2020-07-25 提交的版本,檢視 最新版本

// ==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
)