// ==UserScript==
// @name Export Twitter Following List
// @namespace https://github.com/prinsss/
// @version 1.0.0
// @description Export your Twitter/X's following/followers list to a CSV/JSON/HTML file.
// @author prin
// @match *://twitter.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com
// @grant unsafeWindow
// @run-at document-start
// @supportURL https://github.com/prinsss/export-twitter-following-list/issues
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/*
|--------------------------------------------------------------------------
| Global Variables
|--------------------------------------------------------------------------
*/
const SCRIPT_NAME = 'export-twitter-following-list';
/** @type {Element} */
let panelDom = null;
/** @type {Element} */
let listContainerDom = null;
/** @type {IDBDatabase} */
let db = null;
let isList = false;
let savedCount = 0;
let targetUser = '';
let currentType = '';
let previousPathname = '';
const infoLogs = [];
const errorLogs = [];
const buffer = new Set();
const currentList = new Map();
const currentListSwapped = new Map();
const currentListUniqueSet = new Set();
/*
|--------------------------------------------------------------------------
| Script Bootstraper
|--------------------------------------------------------------------------
*/
initDatabase();
hookIntoXHR();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onPageLoaded);
} else {
onPageLoaded();
}
// Determine wether the script should be run.
function bootstrap() {
const pathname = location.pathname;
if (pathname === previousPathname) {
return;
}
previousPathname = pathname;
// Show the script UI on these pages:
// - User's following list
// - User's followers list
// - List's member list
// - List's followers list
const listRegex = /^\/i\/lists\/(.+)\/(followers|members)/;
const userRegex = /^\/(.+)\/(following|followers_you_follow|followers|verified_followers)/;
isList = listRegex.test(pathname);
const isUser = userRegex.test(pathname);
if (!isList && !isUser) {
destroyControlPanel();
return;
}
const regex = isList ? listRegex : userRegex;
const parsed = regex.exec(pathname) || [];
const [match, target, type] = parsed;
initControlPanel();
updateControlPanel({ type, username: isList ? `list_${target}` : target });
}
// Listen to URL changes.
function onPageLoaded() {
new MutationObserver(bootstrap).observe(document.head, {
childList: true,
});
info('Script ready.');
}
/*
|--------------------------------------------------------------------------
| Page Scroll Listener
|--------------------------------------------------------------------------
*/
// When the content of the list changes, we extract some information from the DOM.
// Note that Twitter is using Virtual List so DOM nodes are always recycled.
function onListChange() {
listContainerDom.childNodes.forEach((child) => {
// NOTE: This may vary as Twitter upgrades.
const link = child.querySelector(
'div[role=button] > div:first-child > div:nth-child(2) > div:first-child ' +
'> div:first-child > div:first-child > div:nth-child(2) a'
);
if (!link) {
debug('No link element found in list child', child);
return;
}
const span = link.querySelector('span');
const parsed = /@(\w+)/.exec(span.textContent) || [];
const [match, username] = parsed;
if (!username) {
debug('No username found in the link', span.textContent, child);
return;
}
// We use a emoji to mark that a user was added into current exporting list.
const mark = ' ✅';
if (currentListUniqueSet.has(username)) {
// When you scroll back, the DOM was reset so we need to mark it again.
if (!span.textContent.includes(mark)) {
const index = currentListSwapped.get(username);
span.innerHTML += `${mark}😸 (${index})`;
}
return;
}
savedCount += 1;
updateControlPanel({ count: savedCount });
// Add the username extracted to the exporting list.
const index = savedCount;
currentListUniqueSet.add(username);
currentList.set(index, username);
currentListSwapped.set(username, index);
span.innerHTML += `${mark} (${index})`;
});
}
function attachToListContainer() {
// NOTE: This may vary as Twitter upgrades.
if (isList) {
listContainerDom = document.querySelector(
'div[role="group"] div[role="dialog"] section[role="region"] > div > div'
);
} else {
listContainerDom = document.querySelector(
'main[role="main"] div[data-testid="primaryColumn"] section[role="region"] > div > div'
);
}
if (!listContainerDom) {
error(
'No list container found. ' +
'This may be a problem caused by Twitter updates. Please file an issue on GitHub: ' +
'https://github.com/prinsss/export-twitter-following-list/issues'
);
return;
}
// Add a border to the attached list container as an indicator.
listContainerDom.style.border = '2px dashed #1d9bf0';
// Listen to the change of the list.
onListChange();
new MutationObserver(onListChange).observe(listContainerDom, {
childList: true,
});
}
/*
|--------------------------------------------------------------------------
| User Interfaces
|--------------------------------------------------------------------------
*/
// Hide the script UI and clear all the cache.
function destroyControlPanel() {
document.getElementById(`${SCRIPT_NAME}-panel`)?.remove();
document.getElementById(`${SCRIPT_NAME}-panel-style`)?.remove();
panelDom = null;
listContainerDom = null;
currentType = '';
targetUser = '';
savedCount = 0;
currentList.clear();
currentListUniqueSet.clear();
currentListSwapped.clear();
}
// Update the script UI.
function updateControlPanel({ type, username, count = 0 }) {
if (!panelDom) {
error('Monitor panel is not initialized');
return;
}
if (type) {
currentType = type;
panelDom.querySelector('#list-type').textContent = type;
}
if (count) {
panelDom.querySelector('#saved-count').textContent = count;
}
if (username) {
targetUser = username;
panelDom.querySelector('#target-user').textContent = username;
}
}
// Show the script UI.
function initControlPanel() {
destroyControlPanel();
const panel = document.createElement('div');
panelDom = panel;
panel.id = `${SCRIPT_NAME}-panel`;
panel.innerHTML = `
<div class="status">
<p>List type: "<span id="list-type">following</span>"</p>
<p>Target user/list: @<span id="target-user">???</span></p>
<p>Saved count: <span id="saved-count">0</span></p>
</div>
<div class="btn-group">
<button id="export-start">START</button>
<button id="export-preview">PREVIEW</button>
<button id="export-dismiss">DISMISS</button>
<button id="export-csv">Export as CSV</button>
<button id="export-json">Export as JSON</button>
<button id="export-html">Export as HTML</button>
<button id="dump-database">Dump Database</button>
</div>
<pre id="export-logs" class="logs"></pre>
<pre id="export-errors" class="logs"></pre>
`;
const style = document.createElement('style');
style.id = `${SCRIPT_NAME}-panel-style`;
style.innerHTML = `
#${SCRIPT_NAME}-panel {
position: fixed;
top: 30px;
left: 30px;
padding: 10px;
background-color: #f7f9f9;
border: 1px solid #bfbfbf;
border-radius: 16px;
box-shadow: rgba(0, 0, 0, 0.08) 0px 8px 28px;
width: 300px;
line-height: 2;
}
#${SCRIPT_NAME}-panel .logs {
text-wrap: wrap;
line-height: 1;
font-size: 12px;
max-height: 300px;
overflow-y: scroll;
}
#${SCRIPT_NAME}-panel p { margin: 0; }
#${SCRIPT_NAME}-panel .btn-group { display: flex; flex-direction: row; flex-wrap: wrap; }
#${SCRIPT_NAME}-panel button { margin-top: 3px; margin-right: 3px; }
#${SCRIPT_NAME}-panel #export-errors { color: #f4212e; }
`;
document.body.appendChild(panel);
document.head.appendChild(style);
panel.querySelector('#export-start').addEventListener('click', onExportStart);
panel.querySelector('#export-preview').addEventListener('click', onExportPreview);
panel.querySelector('#export-dismiss').addEventListener('click', onExportDismiss);
panel.querySelector('#export-csv').addEventListener('click', onExportCSV);
panel.querySelector('#export-json').addEventListener('click', onExportJSON);
panel.querySelector('#export-html').addEventListener('click', onExportHTML);
panel.querySelector('#dump-database').addEventListener('click', onDumpDatabase);
}
// The preview modal.
function openPreviewModal() {
const modal = document.createElement('div');
modal.id = `${SCRIPT_NAME}-modal`;
modal.innerHTML = `
<div class="modal-content">
<button id="modal-dismiss">X</button>
<div id="preview-table-wrapper">
<p>Loading...</p>
</div>
</div>
`;
const style = document.createElement('style');
style.id = `${SCRIPT_NAME}-modal-style`;
style.innerHTML = `
#${SCRIPT_NAME}-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
#${SCRIPT_NAME}-modal .modal-content {
position: relative;
width: 800px;
height: 600px;
background-color: #f7f9f9;
border-radius: 16px;
padding: 16px;
}
#${SCRIPT_NAME}-modal #modal-dismiss {
position: absolute;
top: 10px;
right: 10px;
}
#${SCRIPT_NAME}-modal #preview-table-wrapper {
width: 100%;
height: 100%;
overflow: scroll;
}
#${SCRIPT_NAME}-modal table {
border-collapse: collapse;
border: 2px solid #c8c8c8;
}
#${SCRIPT_NAME}-modal td,
#${SCRIPT_NAME}-modal th {
border: 1px solid #bebebe;
padding: 5px 10px;
}
`;
document.body.appendChild(modal);
document.head.appendChild(style);
modal.querySelector('#modal-dismiss').addEventListener('click', () => {
document.body.removeChild(modal);
document.head.removeChild(style);
});
const wrapper = modal.querySelector('#preview-table-wrapper');
exportToHTMLFormat().then((html) => {
wrapper.innerHTML = html;
});
}
/*
|--------------------------------------------------------------------------
| Exporters
|--------------------------------------------------------------------------
*/
async function exportToCSVFormat() {
const list = await getDetailedCurrentList();
const array = [...list.values()];
const header = 'number,name,screen_name,profile_image,following,followed_by,description,extra';
const rows = array
.map((value, key) => [
String(key),
value?.legacy?.name,
value?.legacy?.screen_name,
value?.legacy?.profile_image_url_https,
value?.legacy?.following ? 'true' : 'false',
value?.legacy?.followed_by ? 'true' : 'false',
sanitizeProfileDescription(
value?.legacy?.description,
value?.legacy?.entities?.description?.urls
),
JSON.stringify(value),
])
.map((item) => item.map((cell) => csvEscapeStr(cell)).join(','));
const body = rows.join('\n');
return header + '\n' + body;
}
async function exportToJSONFormat() {
const list = await getDetailedCurrentList();
const array = [...list.values()];
return JSON.stringify(array, undefined, ' ');
}
async function exportToHTMLFormat() {
const list = await getDetailedCurrentList();
const table = document.createElement('table');
table.innerHTML = `
<thead>
<tr>
<th>#</th>
<th>name</th>
<th>screen_name</th>
<th>profile_image</th>
<th>following</th>
<th>followed_by</th>
<th>description</th>
<th>extra</th>
</tr>
</thead>
`;
const tableBody = document.createElement('tbody');
table.appendChild(tableBody);
list.forEach((value, key) => {
const column = document.createElement('tr');
column.innerHTML = `
<td>${key}</td>
<td>${value?.legacy?.name}</td>
<td>
<a href="https://twitter.com/${value?.legacy?.screen_name}" target="_blank">
${value?.legacy?.screen_name}
</a>
</td>
<td><img src="${value?.legacy?.profile_image_url_https}"></td>
<td>${value?.legacy?.following ? 'true' : 'false'}</td>
<td>${value?.legacy?.followed_by ? 'true' : 'false'}</td>
<td>${sanitizeProfileDescription(
value?.legacy?.description,
value?.legacy?.entities?.description?.urls
)}</td>
<td>
<details>
<summary>Expand</summary>
<pre>${JSON.stringify(value)}</pre>
</details>
</td>
`;
tableBody.appendChild(column);
});
return table.outerHTML;
}
/*
|--------------------------------------------------------------------------
| Button Events
|--------------------------------------------------------------------------
*/
function onExportStart() {
info('Start listening on page scroll...');
attachToListContainer();
info('Scroll down the page and the list content will be saved automatically as you scroll.');
info('Tips: Do not scroll too fast since the list is lazy-loaded.');
}
function onExportDismiss() {
destroyControlPanel();
}
function onExportPreview() {
openPreviewModal();
}
async function onExportCSV() {
try {
const filename = `twitter-${targetUser}-${currentType}-${Date.now()}.csv`;
info('Exporting to CSV file: ' + filename);
const content = await exportToCSVFormat();
saveFile(filename, content);
} catch (err) {
error(err.message, err);
}
}
async function onExportJSON() {
try {
const filename = `twitter-${targetUser}-${currentType}-${Date.now()}.json`;
info('Exporting to JSON file: ' + filename);
const content = await exportToJSONFormat();
saveFile(filename, content);
} catch (err) {
error(err.message, err);
}
}
async function onExportHTML() {
try {
const filename = `twitter-${targetUser}-${currentType}-${Date.now()}.html`;
info('Exporting to HTML file: ' + filename);
const content = await exportToHTMLFormat();
saveFile(filename, content);
} catch (err) {
error(err.message, err);
}
}
async function onDumpDatabase() {
try {
const filename = `${SCRIPT_NAME}-database-dump-${Date.now()}.json`;
info('Exporting IndexedDB to file: ' + filename);
const obj = await dumpDatabase();
saveFile(filename, JSON.stringify(obj, undefined, ' '));
} catch (err) {
error(err.message, err);
}
}
/*
|--------------------------------------------------------------------------
| Database Management
|--------------------------------------------------------------------------
*/
function initDatabase() {
const request = indexedDB.open(SCRIPT_NAME, 1);
request.onerror = (event) => {
error('Failed to open database.', event);
};
request.onsuccess = () => {
db = request.result;
info('New connection to IndexedDB opened.');
// Flush buffer if there is any incoming data received before the DB is ready.
if (buffer.size) {
insertUserDataIntoDatabase([]);
}
};
request.onupgradeneeded = (event) => {
db = event.target.result;
info('New IndexedDB initialized.');
// Use the numeric user ID as primary key and the username as index for lookup.
const objectStore = db.createObjectStore('users', { keyPath: 'rest_id' });
objectStore.createIndex('screen_name', 'legacy.screen_name', { unique: false });
if (buffer.size) {
insertUserDataIntoDatabase([]);
}
};
}
function insertUserDataIntoDatabase(users) {
// Add incoming data to a buffer queue.
users.forEach((user) => buffer.add(user));
// If the DB is not ready yet at this point, queue the data and wait for it.
if (!db) {
info(`Added ${users.length} users to buffer`);
if (buffer.size > 100) {
error('The database is not initialized.');
error('Maximum buffer size exceeded. Current: ' + buffer.size);
}
return;
}
const toBeInserted = [...buffer.values()];
const insertLength = toBeInserted.length;
const transaction = db.transaction('users', 'readwrite');
const objectStore = transaction.objectStore('users');
transaction.oncomplete = () => {
info(`Added ${insertLength} users to database.`);
for (const item of toBeInserted) {
buffer.delete(item);
}
};
transaction.onerror = (event) => {
error(`Failed to add ${insertLength} users to database.`, event);
};
// Insert or update the user data.
toBeInserted.forEach((user) => {
const request = objectStore.put(user);
request.onerror = function (event) {
error(`Failed to write database. User ID: ${user.id}`, event, user);
};
});
}
// Get a user's record from database by his username.
async function queryDatabaseByUsername(username) {
if (!db) {
error('The database is not initialized.');
return;
}
const transaction = db.transaction('users', 'readonly');
const objectStore = transaction.objectStore('users');
// Use the defined index to look up.
const index = objectStore.index('screen_name');
const request = index.get(username);
return new Promise((resolve) => {
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = (event) => {
error(`Failed to query user ${username} from database.`, event);
resolve(null);
};
});
}
// Takes a list of usernames and returns a list of user data, with original order preserved.
async function getDetailedCurrentList() {
const keys = currentList.keys();
const sortedKeys = [...keys].sort((a, b) => a - b);
const sortedDetailedList = new Map();
const promises = sortedKeys.map(async (key) => {
const username = currentList.get(key);
const res = await queryDatabaseByUsername(username);
sortedDetailedList.set(key, res);
});
await Promise.all(promises);
return sortedDetailedList;
}
// Get a user's record from database by his username.
async function dumpDatabase() {
if (!db) {
error('The database is not initialized.');
return;
}
const transaction = db.transaction('users', 'readonly');
const objectStore = transaction.objectStore('users');
const request = objectStore.openCursor();
const records = new Map();
return new Promise((resolve) => {
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
records.set(cursor.value.rest_id, cursor.value);
cursor.continue();
} else {
// No more results.
resolve(Object.fromEntries(records.entries()));
}
};
request.onerror = (event) => {
error(`Failed to query user ${username} from database.`, event);
resolve(null);
};
});
}
/*
|--------------------------------------------------------------------------
| Twitter API Hooks
|--------------------------------------------------------------------------
*/
// Here we hooks the browser's XHR method to intercept Twitter's Web API calls.
// This need to be done before any XHR request is made.
function hookIntoXHR() {
const originalOpen = unsafeWindow.XMLHttpRequest.prototype.open;
unsafeWindow.XMLHttpRequest.prototype.open = function () {
const url = arguments[1];
// NOTE: This may vary as Twitter upgrades.
// https://twitter.com/i/api/graphql/rRXFSG5vR6drKr5M37YOTw/Followers
if (/api\/graphql\/.+\/Followers/.test(url)) {
this.addEventListener('load', function () {
parseTwitterAPIResponse(
this.responseText,
(json) => json.data.user.result.timeline.timeline.instructions
);
});
}
// https://twitter.com/i/api/graphql/kXi37EbqWokFUNypPHhQDQ/BlueVerifiedFollowers
if (/api\/graphql\/.+\/BlueVerifiedFollowers/.test(url)) {
this.addEventListener('load', function () {
parseTwitterAPIResponse(
this.responseText,
(json) => json.data.user.result.timeline.timeline.instructions
);
});
}
// https://twitter.com/i/api/graphql/iSicc7LrzWGBgDPL0tM_TQ/Following
if (/api\/graphql\/.+\/Following/.test(url)) {
this.addEventListener('load', function () {
parseTwitterAPIResponse(
this.responseText,
(json) => json.data.user.result.timeline.timeline.instructions
);
});
}
// https://twitter.com/i/api/graphql/-5VwQkb7axZIxFkFS44iWw/ListMembers
if (/api\/graphql\/.+\/ListMembers/.test(url)) {
this.addEventListener('load', function () {
parseTwitterAPIResponse(
this.responseText,
(json) => json.data.list.members_timeline.timeline.instructions
);
});
}
// https://twitter.com/i/api/graphql/B9F2680qyuI6keStbcgv6w/ListSubscribers
if (/api\/graphql\/.+\/ListSubscribers/.test(url)) {
this.addEventListener('load', function () {
parseTwitterAPIResponse(
this.responseText,
(json) => json.data.list.subscribers_timeline.timeline.instructions
);
});
}
originalOpen.apply(this, arguments);
};
info('Hooked into XMLHttpRequest.');
}
// We parse the users' information in the API response and write them to the local database.
// The browser's IndexedDB is used to store the data persistently.
function parseTwitterAPIResponse(text, extractor) {
try {
const json = JSON.parse(text);
// NOTE: This may vary as Twitter upgrades.
const instructions = extractor(json);
const entries = instructions.find((item) => item.type === 'TimelineAddEntries').entries;
const users = entries
.filter((item) => item.content.itemContent)
.map((item) => ({
...item.content.itemContent.user_results.result,
entryId: item.entryId,
sortIndex: item.sortIndex,
}));
insertUserDataIntoDatabase(users);
} catch (err) {
error(
`Failed to parse API response. (Message: ${err.message}) ` +
'This may be a problem caused by Twitter updates. Please file an issue on GitHub: ' +
'https://github.com/prinsss/export-twitter-following-list/issues'
);
}
}
/*
|--------------------------------------------------------------------------
| Utility Functions
|--------------------------------------------------------------------------
*/
// Escape characters for CSV file.
function csvEscapeStr(s) {
return `"${s.replace(/\"/g, '""').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
}
// Save a text file to disk.
function saveFile(filename, content) {
const link = document.createElement('a');
link.style = 'display: none';
document.body.appendChild(link);
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
// Replace any https://t.co/ link in the string with its corresponding real URL.
function sanitizeProfileDescription(description, urls) {
let str = description;
if (urls?.length) {
for (const { url, expanded_url } of urls) {
str = str.replace(url, expanded_url);
}
}
return str;
}
// Show info logs on both screen and console.
function info(line, ...args) {
console.info('[Export Twitter Following List]', line, ...args);
infoLogs.push(line);
const dom = panelDom ? panelDom.querySelector('#export-logs') : null;
if (dom) {
dom.innerHTML = infoLogs.map((content) => '> ' + String(content)).join('\n');
}
}
// Show error logs on both screen and console.
function error(line, ...args) {
console.error('[Export Twitter Following List]', line, ...args);
errorLogs.push(line);
const dom = panelDom ? panelDom.querySelector('#export-errors') : null;
if (dom) {
dom.innerHTML = errorLogs.map((content) => '> ' + String(content)).join('\n');
}
}
// Show debug logs on console.
function debug(...args) {
console.debug('[Export Twitter Following List]', ...args);
}
})();