-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathtwitter-direct.user.ts
More file actions
169 lines (137 loc) · 4.98 KB
/
Copy pathtwitter-direct.user.ts
File metadata and controls
169 lines (137 loc) · 4.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
// ==UserScript==
// @name Twitter Direct
// @description Remove t.co tracking links from Twitter
// @author chocolateboy
// @copyright chocolateboy
// @version 3.2.1
// @namespace https://github.com/chocolateboy/userscripts
// @license GPL
// @include https://mobile.twitter.com/
// @include https://mobile.twitter.com/*
// @include https://twitter.com/
// @include https://twitter.com/*
// @include https://x.com/
// @include https://x.com/*
// @require https://unpkg.com/gm-compat@1.1.0/dist/index.iife.min.js
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
/// <reference path="../types/gm-compat.d.ts" />
import Replacer from './twitter-direct/replacer'
import { isObject } from './twitter-direct/util'
/*
* a list of document URIs (paths) which are known to not contain t.co URLs and
* which therefore don't need to be transformed
*/
const URL_BLACKLIST = new Set([
'/hashflags.json',
'/badge_count/badge_count.json',
'/graphql/articleNudgeDomains',
'/graphql/TopicToFollowSidebar',
])
/*
* 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/
/*
* 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
/*
* 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: Record<string, number> = {}
/*
* 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|x)\.com$/
/*
* replacement for the 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: XMLHttpRequest, uri: string): void => {
const contentType = xhr.getResponseHeader('Content-Type')
if (!contentType || !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 API version, user ID or query
// ID, e.g.:
//
// /2/timeline/profile/1234.json -> /timeline/profile.json
// /i/api/graphql/abc123/UserTweets -> /graphql/UserTweets
//
const path = url.pathname
.replace(/^\/i\/api\//, '/')
.replace(/^\/\d+(\.\d+)*\//, '/')
.replace(/(\/graphql\/)[^\/]+\/(.+)$/, '$1$2')
.replace(/\/\d+\.json$/, '.json')
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 = Replacer.transform(data, path)
STATS[path] = (STATS[path] || 0) + count
if (!count) {
if (STATS[path] === 0 && 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)
}
}
/*
* 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: XMLHttpRequest['send']): XMLHttpRequest['send'] => {
return function send (this: XMLHttpRequest, 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)
}
}
// replace the default XHR#send method with our custom version, which scans
// responses for t.co URLs and expands them
const xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype
const send = hookXHRSend(xhrProto.send)
xhrProto.send = GMCompat.export(send)