Last active
September 22, 2022 07:09
-
-
Save luavixen/a2ac804ef1caec84fb7a125cb028f5c0 to your computer and use it in GitHub Desktop.
Twitter userscript to display a "friends" (accounts that you follow that also follow a given account) count next to usernames.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Twitter Friend Counter | |
// @namespace http://tampermonkey.net/ | |
// @version 0.2 | |
// @description Twitter userscript to display a "friends" (accounts that you follow that also follow a given account) count next to usernames. | |
// @author [email protected] | |
// @match https://twitter.com/* | |
// @grant unsafeWindow | |
// ==/UserScript== | |
(function () { | |
'use strict'; | |
let headers = null; | |
proxy((request) => { | |
if (request.url.startsWith('https://api.twitter.com/1.1/jot/client_event.json')) { | |
headers = new Headers(); | |
headers.append('authorization', request.headers['authorization']); | |
headers.append('x-csrf-token', request.headers['x-csrf-token']); | |
headers.append('x-twitter-active-user', 'yes'); | |
headers.append('x-twitter-auth-type', 'OAuth2Session'); | |
headers.append('x-twitter-client-language', 'en'); | |
} | |
}); | |
const lookupCache = new Map(); | |
const friendsCache = new Map(); | |
async function lookup(name) { | |
if (name == null) return null; | |
name = String(name); | |
const cached = lookupCache.get(name); | |
if (cached != null) return cached; | |
if (headers == null) { | |
return null; | |
} | |
const url = new URL('https://twitter.com/i/api/graphql/vG3rchZtwqiwlKgUYCrTRA/UserByScreenName'); | |
url.searchParams.set('variables', JSON.stringify({ 'screen_name': name, 'withSafetyModeUserFields': true, 'withSuperFollowsUserFields': true })); | |
url.searchParams.set('features', JSON.stringify({ 'responsive_web_graphql_timeline_navigation_enabled': false })); | |
const response = await fetch(url, { headers }); | |
const payload = await response.json(); | |
if ( | |
typeof payload === 'object' && | |
typeof payload['data'] === 'object' && | |
typeof payload['data']['user'] === 'object' && | |
typeof payload['data']['user']['result'] === 'object' && | |
typeof payload['data']['user']['result']['rest_id'] === 'string' | |
) { | |
const result = payload['data']['user']['result']['rest_id']; | |
lookupCache.set(name, result); return result; | |
} else { | |
throw new TypeError('Invalid response from /i/api/graphql/vG3rchZtwqiwlKgUYCrTRA/UserByScreenName'); | |
} | |
} | |
async function friends(id) { | |
if (id == null) return null; | |
id = String(id); | |
const cached = friendsCache.get(id); | |
if (cached != null) return cached; | |
if (headers == null) { | |
return null; | |
} | |
const url = new URL('https://twitter.com/i/api/1.1/friends/following/list.json'); | |
url.searchParams.set('cursor', '-1'); | |
url.searchParams.set('count', '1'); | |
url.searchParams.set('with_total_count', 'true'); | |
url.searchParams.set('user_id', id); | |
const response = await fetch(url, { headers }); | |
const payload = await response.json(); | |
if ( | |
typeof payload === 'object' && | |
typeof payload['total_count'] === 'number' | |
) { | |
const result = payload['total_count']; | |
friendsCache.set(id, result); return result; | |
} else { | |
throw new TypeError('Invalid response from /i/api/1.1/friends/following/list.json'); | |
} | |
} | |
const INDICATOR_CLASS = 'friends-indicator'; | |
const COLOR_OK = 'rgb(29, 155, 240)'; | |
const COLOR_WARN = 'rgb(220, 53, 69)'; | |
const COLOR_FAIL = 'rgb(255, 193, 7)'; | |
async function update() { | |
if (headers == null) return; | |
const tasks = []; | |
for (const $link of document.getElementsByTagName('a')) { | |
if ($link.getAttribute('role') !== 'link') continue; | |
const result = /^(?:https\:\/\/twitter\.com)?\/([0-9A-Za-z_]+)$/.exec($link.href); | |
if (result == null) continue; | |
const $content = $link.querySelector('.css-901oao.css-16my406.css-1hf3ou5.r-poiln3.r-bcqeeo.r-qvutc0'); | |
if ($content == null || !$content.hasChildNodes()) continue; | |
if (Array.prototype.find.call($content.children, ($el) => $el.classList.contains(INDICATOR_CLASS)) != null) continue; | |
const $indicator = document.createElement('span'); | |
$indicator.className = INDICATOR_CLASS; | |
$content.insertBefore($indicator, $content.firstElementChild); | |
const name = result[1]; | |
tasks.push(async () => { | |
try { | |
const id = await lookup(name); | |
const count = await friends(id); | |
$indicator.style.color = count > 0 ? COLOR_OK : COLOR_WARN; | |
$indicator.appendChild(document.createTextNode(`(${count}) `)); | |
} catch (err) { | |
$indicator.style.color = COLOR_FAIL; | |
$indicator.appendChild(document.createTextNode('(?) ')); | |
console.warn(`Failed to get friend count for '${name}'`, err); | |
} | |
}); | |
} | |
for (const task of tasks) { | |
await task(); | |
} | |
} | |
const TIMEOUT_INITIAL = 5_000; | |
const TIMEOUT_NEXT = 3_000; | |
function next() { | |
update() | |
.catch(console.error) | |
.finally(() => setTimeout(next, TIMEOUT_NEXT)); | |
} | |
setTimeout(next, TIMEOUT_INITIAL); | |
function proxy(callback) { | |
const hint = Symbol(); | |
const xhr = window.XMLHttpRequest; | |
const xhrPrototype = xhr.prototype; | |
const xhrOpen = xhrPrototype.open; | |
const xhrSetRequestHeader = xhrPrototype.setRequestHeader; | |
const xhrSend = xhrPrototype.send; | |
xhrPrototype.open = function (method, url) { | |
try { | |
this[hint] = { method, url, headers: {} }; | |
} finally { | |
return xhrOpen.apply(this, arguments); | |
} | |
}; | |
xhrPrototype.setRequestHeader = function (header, value) { | |
try { | |
const { headers } = this[hint]; | |
const k = String(header).toLowerCase() | |
, v = String(value); | |
(headers[k] ?? (headers[k] = [])).push(v); | |
} finally { | |
return xhrSetRequestHeader.apply(this, arguments); | |
} | |
}; | |
xhrPrototype.send = function () { | |
try { | |
callback(this[hint]); | |
} finally { | |
return xhrSend.apply(this, arguments); | |
} | |
}; | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment