Export Twitter Following List

Export your Twitter/X's following/followers list to a CSV/JSON/HTML file.

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