Skip to content

Instantly share code, notes, and snippets.

@luavixen
Last active September 22, 2022 07:09
Show Gist options
  • Save luavixen/a2ac804ef1caec84fb7a125cb028f5c0 to your computer and use it in GitHub Desktop.
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.
// ==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