- // ==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);
- }
- })();