Export Twitter Following List

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

  1. // ==UserScript==
  2. // @name Export Twitter Following List
  3. // @namespace https://github.com/prinsss/
  4. // @version 1.0.0
  5. // @description Export your Twitter/X's following/followers list to a CSV/JSON/HTML file.
  6. // @author prin
  7. // @match *://twitter.com/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com
  9. // @grant unsafeWindow
  10. // @run-at document-start
  11. // @supportURL https://github.com/prinsss/export-twitter-following-list/issues
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. (function () {
  16. 'use strict';
  17.  
  18. /*
  19. |--------------------------------------------------------------------------
  20. | Global Variables
  21. |--------------------------------------------------------------------------
  22. */
  23.  
  24. const SCRIPT_NAME = 'export-twitter-following-list';
  25.  
  26. /** @type {Element} */
  27. let panelDom = null;
  28.  
  29. /** @type {Element} */
  30. let listContainerDom = null;
  31.  
  32. /** @type {IDBDatabase} */
  33. let db = null;
  34.  
  35. let isList = false;
  36. let savedCount = 0;
  37. let targetUser = '';
  38. let currentType = '';
  39. let previousPathname = '';
  40.  
  41. const infoLogs = [];
  42. const errorLogs = [];
  43.  
  44. const buffer = new Set();
  45. const currentList = new Map();
  46. const currentListSwapped = new Map();
  47. const currentListUniqueSet = new Set();
  48.  
  49. /*
  50. |--------------------------------------------------------------------------
  51. | Script Bootstraper
  52. |--------------------------------------------------------------------------
  53. */
  54.  
  55. initDatabase();
  56. hookIntoXHR();
  57.  
  58. if (document.readyState === 'loading') {
  59. document.addEventListener('DOMContentLoaded', onPageLoaded);
  60. } else {
  61. onPageLoaded();
  62. }
  63.  
  64. // Determine wether the script should be run.
  65. function bootstrap() {
  66. const pathname = location.pathname;
  67.  
  68. if (pathname === previousPathname) {
  69. return;
  70. }
  71.  
  72. previousPathname = pathname;
  73.  
  74. // Show the script UI on these pages:
  75. // - User's following list
  76. // - User's followers list
  77. // - List's member list
  78. // - List's followers list
  79.  
  80. const listRegex = /^\/i\/lists\/(.+)\/(followers|members)/;
  81. const userRegex = /^\/(.+)\/(following|followers_you_follow|followers|verified_followers)/;
  82.  
  83. isList = listRegex.test(pathname);
  84. const isUser = userRegex.test(pathname);
  85.  
  86. if (!isList && !isUser) {
  87. destroyControlPanel();
  88. return;
  89. }
  90.  
  91. const regex = isList ? listRegex : userRegex;
  92. const parsed = regex.exec(pathname) || [];
  93. const [match, target, type] = parsed;
  94.  
  95. initControlPanel();
  96. updateControlPanel({ type, username: isList ? `list_${target}` : target });
  97. }
  98.  
  99. // Listen to URL changes.
  100. function onPageLoaded() {
  101. new MutationObserver(bootstrap).observe(document.head, {
  102. childList: true,
  103. });
  104. info('Script ready.');
  105. }
  106.  
  107. /*
  108. |--------------------------------------------------------------------------
  109. | Page Scroll Listener
  110. |--------------------------------------------------------------------------
  111. */
  112.  
  113. // When the content of the list changes, we extract some information from the DOM.
  114. // Note that Twitter is using Virtual List so DOM nodes are always recycled.
  115. function onListChange() {
  116. listContainerDom.childNodes.forEach((child) => {
  117. // NOTE: This may vary as Twitter upgrades.
  118. const link = child.querySelector(
  119. 'div[role=button] > div:first-child > div:nth-child(2) > div:first-child ' +
  120. '> div:first-child > div:first-child > div:nth-child(2) a'
  121. );
  122.  
  123. if (!link) {
  124. debug('No link element found in list child', child);
  125. return;
  126. }
  127.  
  128. const span = link.querySelector('span');
  129. const parsed = /@(\w+)/.exec(span.textContent) || [];
  130. const [match, username] = parsed;
  131.  
  132. if (!username) {
  133. debug('No username found in the link', span.textContent, child);
  134. return;
  135. }
  136.  
  137. // We use a emoji to mark that a user was added into current exporting list.
  138. const mark = ' ✅';
  139.  
  140. if (currentListUniqueSet.has(username)) {
  141. // When you scroll back, the DOM was reset so we need to mark it again.
  142. if (!span.textContent.includes(mark)) {
  143. const index = currentListSwapped.get(username);
  144. span.innerHTML += `${mark}😸 (${index})`;
  145. }
  146. return;
  147. }
  148.  
  149. savedCount += 1;
  150. updateControlPanel({ count: savedCount });
  151.  
  152. // Add the username extracted to the exporting list.
  153. const index = savedCount;
  154. currentListUniqueSet.add(username);
  155. currentList.set(index, username);
  156. currentListSwapped.set(username, index);
  157.  
  158. span.innerHTML += `${mark} (${index})`;
  159. });
  160. }
  161.  
  162. function attachToListContainer() {
  163. // NOTE: This may vary as Twitter upgrades.
  164. if (isList) {
  165. listContainerDom = document.querySelector(
  166. 'div[role="group"] div[role="dialog"] section[role="region"] > div > div'
  167. );
  168. } else {
  169. listContainerDom = document.querySelector(
  170. 'main[role="main"] div[data-testid="primaryColumn"] section[role="region"] > div > div'
  171. );
  172. }
  173.  
  174. if (!listContainerDom) {
  175. error(
  176. 'No list container found. ' +
  177. 'This may be a problem caused by Twitter updates. Please file an issue on GitHub: ' +
  178. 'https://github.com/prinsss/export-twitter-following-list/issues'
  179. );
  180. return;
  181. }
  182.  
  183. // Add a border to the attached list container as an indicator.
  184. listContainerDom.style.border = '2px dashed #1d9bf0';
  185.  
  186. // Listen to the change of the list.
  187. onListChange();
  188. new MutationObserver(onListChange).observe(listContainerDom, {
  189. childList: true,
  190. });
  191. }
  192.  
  193. /*
  194. |--------------------------------------------------------------------------
  195. | User Interfaces
  196. |--------------------------------------------------------------------------
  197. */
  198.  
  199. // Hide the script UI and clear all the cache.
  200. function destroyControlPanel() {
  201. document.getElementById(`${SCRIPT_NAME}-panel`)?.remove();
  202. document.getElementById(`${SCRIPT_NAME}-panel-style`)?.remove();
  203. panelDom = null;
  204. listContainerDom = null;
  205. currentType = '';
  206. targetUser = '';
  207. savedCount = 0;
  208. currentList.clear();
  209. currentListUniqueSet.clear();
  210. currentListSwapped.clear();
  211. }
  212.  
  213. // Update the script UI.
  214. function updateControlPanel({ type, username, count = 0 }) {
  215. if (!panelDom) {
  216. error('Monitor panel is not initialized');
  217. return;
  218. }
  219.  
  220. if (type) {
  221. currentType = type;
  222. panelDom.querySelector('#list-type').textContent = type;
  223. }
  224.  
  225. if (count) {
  226. panelDom.querySelector('#saved-count').textContent = count;
  227. }
  228.  
  229. if (username) {
  230. targetUser = username;
  231. panelDom.querySelector('#target-user').textContent = username;
  232. }
  233. }
  234.  
  235. // Show the script UI.
  236. function initControlPanel() {
  237. destroyControlPanel();
  238.  
  239. const panel = document.createElement('div');
  240. panelDom = panel;
  241. panel.id = `${SCRIPT_NAME}-panel`;
  242. panel.innerHTML = `
  243. <div class="status">
  244. <p>List type: "<span id="list-type">following</span>"</p>
  245. <p>Target user/list: @<span id="target-user">???</span></p>
  246. <p>Saved count: <span id="saved-count">0</span></p>
  247. </div>
  248. <div class="btn-group">
  249. <button id="export-start">START</button>
  250. <button id="export-preview">PREVIEW</button>
  251. <button id="export-dismiss">DISMISS</button>
  252. <button id="export-csv">Export as CSV</button>
  253. <button id="export-json">Export as JSON</button>
  254. <button id="export-html">Export as HTML</button>
  255. <button id="dump-database">Dump Database</button>
  256. </div>
  257. <pre id="export-logs" class="logs"></pre>
  258. <pre id="export-errors" class="logs"></pre>
  259. `;
  260.  
  261. const style = document.createElement('style');
  262. style.id = `${SCRIPT_NAME}-panel-style`;
  263. style.innerHTML = `
  264. #${SCRIPT_NAME}-panel {
  265. position: fixed;
  266. top: 30px;
  267. left: 30px;
  268. padding: 10px;
  269. background-color: #f7f9f9;
  270. border: 1px solid #bfbfbf;
  271. border-radius: 16px;
  272. box-shadow: rgba(0, 0, 0, 0.08) 0px 8px 28px;
  273. width: 300px;
  274. line-height: 2;
  275. }
  276.  
  277.  
  278. #${SCRIPT_NAME}-panel .logs {
  279. text-wrap: wrap;
  280. line-height: 1;
  281. font-size: 12px;
  282. max-height: 300px;
  283. overflow-y: scroll;
  284. }
  285.  
  286. #${SCRIPT_NAME}-panel p { margin: 0; }
  287. #${SCRIPT_NAME}-panel .btn-group { display: flex; flex-direction: row; flex-wrap: wrap; }
  288. #${SCRIPT_NAME}-panel button { margin-top: 3px; margin-right: 3px; }
  289. #${SCRIPT_NAME}-panel #export-errors { color: #f4212e; }
  290. `;
  291.  
  292. document.body.appendChild(panel);
  293. document.head.appendChild(style);
  294.  
  295. panel.querySelector('#export-start').addEventListener('click', onExportStart);
  296. panel.querySelector('#export-preview').addEventListener('click', onExportPreview);
  297. panel.querySelector('#export-dismiss').addEventListener('click', onExportDismiss);
  298. panel.querySelector('#export-csv').addEventListener('click', onExportCSV);
  299. panel.querySelector('#export-json').addEventListener('click', onExportJSON);
  300. panel.querySelector('#export-html').addEventListener('click', onExportHTML);
  301. panel.querySelector('#dump-database').addEventListener('click', onDumpDatabase);
  302. }
  303.  
  304. // The preview modal.
  305. function openPreviewModal() {
  306. const modal = document.createElement('div');
  307. modal.id = `${SCRIPT_NAME}-modal`;
  308. modal.innerHTML = `
  309. <div class="modal-content">
  310. <button id="modal-dismiss">X</button>
  311. <div id="preview-table-wrapper">
  312. <p>Loading...</p>
  313. </div>
  314. </div>
  315. `;
  316.  
  317. const style = document.createElement('style');
  318. style.id = `${SCRIPT_NAME}-modal-style`;
  319. style.innerHTML = `
  320. #${SCRIPT_NAME}-modal {
  321. position: fixed;
  322. top: 0;
  323. left: 0;
  324. width: 100vw;
  325. height: 100vh;
  326. background: rgba(0, 0, 0, 0.4);
  327. display: flex;
  328. align-items: center;
  329. justify-content: center;
  330. }
  331.  
  332. #${SCRIPT_NAME}-modal .modal-content {
  333. position: relative;
  334. width: 800px;
  335. height: 600px;
  336. background-color: #f7f9f9;
  337. border-radius: 16px;
  338. padding: 16px;
  339. }
  340.  
  341. #${SCRIPT_NAME}-modal #modal-dismiss {
  342. position: absolute;
  343. top: 10px;
  344. right: 10px;
  345. }
  346.  
  347. #${SCRIPT_NAME}-modal #preview-table-wrapper {
  348. width: 100%;
  349. height: 100%;
  350. overflow: scroll;
  351. }
  352.  
  353. #${SCRIPT_NAME}-modal table {
  354. border-collapse: collapse;
  355. border: 2px solid #c8c8c8;
  356. }
  357.  
  358. #${SCRIPT_NAME}-modal td,
  359. #${SCRIPT_NAME}-modal th {
  360. border: 1px solid #bebebe;
  361. padding: 5px 10px;
  362. }
  363. `;
  364.  
  365. document.body.appendChild(modal);
  366. document.head.appendChild(style);
  367.  
  368. modal.querySelector('#modal-dismiss').addEventListener('click', () => {
  369. document.body.removeChild(modal);
  370. document.head.removeChild(style);
  371. });
  372.  
  373. const wrapper = modal.querySelector('#preview-table-wrapper');
  374. exportToHTMLFormat().then((html) => {
  375. wrapper.innerHTML = html;
  376. });
  377. }
  378.  
  379. /*
  380. |--------------------------------------------------------------------------
  381. | Exporters
  382. |--------------------------------------------------------------------------
  383. */
  384.  
  385. async function exportToCSVFormat() {
  386. const list = await getDetailedCurrentList();
  387. const array = [...list.values()];
  388.  
  389. const header = 'number,name,screen_name,profile_image,following,followed_by,description,extra';
  390. const rows = array
  391. .map((value, key) => [
  392. String(key),
  393. value?.legacy?.name,
  394. value?.legacy?.screen_name,
  395. value?.legacy?.profile_image_url_https,
  396. value?.legacy?.following ? 'true' : 'false',
  397. value?.legacy?.followed_by ? 'true' : 'false',
  398. sanitizeProfileDescription(
  399. value?.legacy?.description,
  400. value?.legacy?.entities?.description?.urls
  401. ),
  402. JSON.stringify(value),
  403. ])
  404. .map((item) => item.map((cell) => csvEscapeStr(cell)).join(','));
  405. const body = rows.join('\n');
  406.  
  407. return header + '\n' + body;
  408. }
  409.  
  410. async function exportToJSONFormat() {
  411. const list = await getDetailedCurrentList();
  412. const array = [...list.values()];
  413. return JSON.stringify(array, undefined, ' ');
  414. }
  415.  
  416. async function exportToHTMLFormat() {
  417. const list = await getDetailedCurrentList();
  418.  
  419. const table = document.createElement('table');
  420. table.innerHTML = `
  421. <thead>
  422. <tr>
  423. <th>#</th>
  424. <th>name</th>
  425. <th>screen_name</th>
  426. <th>profile_image</th>
  427. <th>following</th>
  428. <th>followed_by</th>
  429. <th>description</th>
  430. <th>extra</th>
  431. </tr>
  432. </thead>
  433. `;
  434.  
  435. const tableBody = document.createElement('tbody');
  436. table.appendChild(tableBody);
  437.  
  438. list.forEach((value, key) => {
  439. const column = document.createElement('tr');
  440. column.innerHTML = `
  441. <td>${key}</td>
  442. <td>${value?.legacy?.name}</td>
  443. <td>
  444. <a href="https://twitter.com/${value?.legacy?.screen_name}" target="_blank">
  445. ${value?.legacy?.screen_name}
  446. </a>
  447. </td>
  448. <td><img src="${value?.legacy?.profile_image_url_https}"></td>
  449. <td>${value?.legacy?.following ? 'true' : 'false'}</td>
  450. <td>${value?.legacy?.followed_by ? 'true' : 'false'}</td>
  451. <td>${sanitizeProfileDescription(
  452. value?.legacy?.description,
  453. value?.legacy?.entities?.description?.urls
  454. )}</td>
  455. <td>
  456. <details>
  457. <summary>Expand</summary>
  458. <pre>${JSON.stringify(value)}</pre>
  459. </details>
  460. </td>
  461. `;
  462. tableBody.appendChild(column);
  463. });
  464.  
  465. return table.outerHTML;
  466. }
  467.  
  468. /*
  469. |--------------------------------------------------------------------------
  470. | Button Events
  471. |--------------------------------------------------------------------------
  472. */
  473.  
  474. function onExportStart() {
  475. info('Start listening on page scroll...');
  476. attachToListContainer();
  477. info('Scroll down the page and the list content will be saved automatically as you scroll.');
  478. info('Tips: Do not scroll too fast since the list is lazy-loaded.');
  479. }
  480.  
  481. function onExportDismiss() {
  482. destroyControlPanel();
  483. }
  484.  
  485. function onExportPreview() {
  486. openPreviewModal();
  487. }
  488.  
  489. async function onExportCSV() {
  490. try {
  491. const filename = `twitter-${targetUser}-${currentType}-${Date.now()}.csv`;
  492. info('Exporting to CSV file: ' + filename);
  493. const content = await exportToCSVFormat();
  494. saveFile(filename, content);
  495. } catch (err) {
  496. error(err.message, err);
  497. }
  498. }
  499.  
  500. async function onExportJSON() {
  501. try {
  502. const filename = `twitter-${targetUser}-${currentType}-${Date.now()}.json`;
  503. info('Exporting to JSON file: ' + filename);
  504. const content = await exportToJSONFormat();
  505. saveFile(filename, content);
  506. } catch (err) {
  507. error(err.message, err);
  508. }
  509. }
  510.  
  511. async function onExportHTML() {
  512. try {
  513. const filename = `twitter-${targetUser}-${currentType}-${Date.now()}.html`;
  514. info('Exporting to HTML file: ' + filename);
  515. const content = await exportToHTMLFormat();
  516. saveFile(filename, content);
  517. } catch (err) {
  518. error(err.message, err);
  519. }
  520. }
  521.  
  522. async function onDumpDatabase() {
  523. try {
  524. const filename = `${SCRIPT_NAME}-database-dump-${Date.now()}.json`;
  525. info('Exporting IndexedDB to file: ' + filename);
  526. const obj = await dumpDatabase();
  527. saveFile(filename, JSON.stringify(obj, undefined, ' '));
  528. } catch (err) {
  529. error(err.message, err);
  530. }
  531. }
  532.  
  533. /*
  534. |--------------------------------------------------------------------------
  535. | Database Management
  536. |--------------------------------------------------------------------------
  537. */
  538.  
  539. function initDatabase() {
  540. const request = indexedDB.open(SCRIPT_NAME, 1);
  541.  
  542. request.onerror = (event) => {
  543. error('Failed to open database.', event);
  544. };
  545.  
  546. request.onsuccess = () => {
  547. db = request.result;
  548. info('New connection to IndexedDB opened.');
  549.  
  550. // Flush buffer if there is any incoming data received before the DB is ready.
  551. if (buffer.size) {
  552. insertUserDataIntoDatabase([]);
  553. }
  554. };
  555.  
  556. request.onupgradeneeded = (event) => {
  557. db = event.target.result;
  558. info('New IndexedDB initialized.');
  559.  
  560. // Use the numeric user ID as primary key and the username as index for lookup.
  561. const objectStore = db.createObjectStore('users', { keyPath: 'rest_id' });
  562. objectStore.createIndex('screen_name', 'legacy.screen_name', { unique: false });
  563.  
  564. if (buffer.size) {
  565. insertUserDataIntoDatabase([]);
  566. }
  567. };
  568. }
  569.  
  570. function insertUserDataIntoDatabase(users) {
  571. // Add incoming data to a buffer queue.
  572. users.forEach((user) => buffer.add(user));
  573.  
  574. // If the DB is not ready yet at this point, queue the data and wait for it.
  575. if (!db) {
  576. info(`Added ${users.length} users to buffer`);
  577.  
  578. if (buffer.size > 100) {
  579. error('The database is not initialized.');
  580. error('Maximum buffer size exceeded. Current: ' + buffer.size);
  581. }
  582.  
  583. return;
  584. }
  585.  
  586. const toBeInserted = [...buffer.values()];
  587. const insertLength = toBeInserted.length;
  588.  
  589. const transaction = db.transaction('users', 'readwrite');
  590. const objectStore = transaction.objectStore('users');
  591.  
  592. transaction.oncomplete = () => {
  593. info(`Added ${insertLength} users to database.`);
  594. for (const item of toBeInserted) {
  595. buffer.delete(item);
  596. }
  597. };
  598.  
  599. transaction.onerror = (event) => {
  600. error(`Failed to add ${insertLength} users to database.`, event);
  601. };
  602.  
  603. // Insert or update the user data.
  604. toBeInserted.forEach((user) => {
  605. const request = objectStore.put(user);
  606.  
  607. request.onerror = function (event) {
  608. error(`Failed to write database. User ID: ${user.id}`, event, user);
  609. };
  610. });
  611. }
  612.  
  613. // Get a user's record from database by his username.
  614. async function queryDatabaseByUsername(username) {
  615. if (!db) {
  616. error('The database is not initialized.');
  617. return;
  618. }
  619.  
  620. const transaction = db.transaction('users', 'readonly');
  621. const objectStore = transaction.objectStore('users');
  622.  
  623. // Use the defined index to look up.
  624. const index = objectStore.index('screen_name');
  625. const request = index.get(username);
  626.  
  627. return new Promise((resolve) => {
  628. request.onsuccess = () => {
  629. resolve(request.result);
  630. };
  631.  
  632. request.onerror = (event) => {
  633. error(`Failed to query user ${username} from database.`, event);
  634. resolve(null);
  635. };
  636. });
  637. }
  638.  
  639. // Takes a list of usernames and returns a list of user data, with original order preserved.
  640. async function getDetailedCurrentList() {
  641. const keys = currentList.keys();
  642. const sortedKeys = [...keys].sort((a, b) => a - b);
  643. const sortedDetailedList = new Map();
  644.  
  645. const promises = sortedKeys.map(async (key) => {
  646. const username = currentList.get(key);
  647. const res = await queryDatabaseByUsername(username);
  648. sortedDetailedList.set(key, res);
  649. });
  650.  
  651. await Promise.all(promises);
  652. return sortedDetailedList;
  653. }
  654.  
  655. // Get a user's record from database by his username.
  656. async function dumpDatabase() {
  657. if (!db) {
  658. error('The database is not initialized.');
  659. return;
  660. }
  661.  
  662. const transaction = db.transaction('users', 'readonly');
  663. const objectStore = transaction.objectStore('users');
  664.  
  665. const request = objectStore.openCursor();
  666. const records = new Map();
  667.  
  668. return new Promise((resolve) => {
  669. request.onsuccess = (event) => {
  670. const cursor = event.target.result;
  671.  
  672. if (cursor) {
  673. records.set(cursor.value.rest_id, cursor.value);
  674. cursor.continue();
  675. } else {
  676. // No more results.
  677. resolve(Object.fromEntries(records.entries()));
  678. }
  679. };
  680.  
  681. request.onerror = (event) => {
  682. error(`Failed to query user ${username} from database.`, event);
  683. resolve(null);
  684. };
  685. });
  686. }
  687.  
  688. /*
  689. |--------------------------------------------------------------------------
  690. | Twitter API Hooks
  691. |--------------------------------------------------------------------------
  692. */
  693.  
  694. // Here we hooks the browser's XHR method to intercept Twitter's Web API calls.
  695. // This need to be done before any XHR request is made.
  696. function hookIntoXHR() {
  697. const originalOpen = unsafeWindow.XMLHttpRequest.prototype.open;
  698.  
  699. unsafeWindow.XMLHttpRequest.prototype.open = function () {
  700. const url = arguments[1];
  701.  
  702. // NOTE: This may vary as Twitter upgrades.
  703. // https://twitter.com/i/api/graphql/rRXFSG5vR6drKr5M37YOTw/Followers
  704. if (/api\/graphql\/.+\/Followers/.test(url)) {
  705. this.addEventListener('load', function () {
  706. parseTwitterAPIResponse(
  707. this.responseText,
  708. (json) => json.data.user.result.timeline.timeline.instructions
  709. );
  710. });
  711. }
  712.  
  713. // https://twitter.com/i/api/graphql/kXi37EbqWokFUNypPHhQDQ/BlueVerifiedFollowers
  714. if (/api\/graphql\/.+\/BlueVerifiedFollowers/.test(url)) {
  715. this.addEventListener('load', function () {
  716. parseTwitterAPIResponse(
  717. this.responseText,
  718. (json) => json.data.user.result.timeline.timeline.instructions
  719. );
  720. });
  721. }
  722.  
  723. // https://twitter.com/i/api/graphql/iSicc7LrzWGBgDPL0tM_TQ/Following
  724. if (/api\/graphql\/.+\/Following/.test(url)) {
  725. this.addEventListener('load', function () {
  726. parseTwitterAPIResponse(
  727. this.responseText,
  728. (json) => json.data.user.result.timeline.timeline.instructions
  729. );
  730. });
  731. }
  732.  
  733. // https://twitter.com/i/api/graphql/-5VwQkb7axZIxFkFS44iWw/ListMembers
  734. if (/api\/graphql\/.+\/ListMembers/.test(url)) {
  735. this.addEventListener('load', function () {
  736. parseTwitterAPIResponse(
  737. this.responseText,
  738. (json) => json.data.list.members_timeline.timeline.instructions
  739. );
  740. });
  741. }
  742.  
  743. // https://twitter.com/i/api/graphql/B9F2680qyuI6keStbcgv6w/ListSubscribers
  744. if (/api\/graphql\/.+\/ListSubscribers/.test(url)) {
  745. this.addEventListener('load', function () {
  746. parseTwitterAPIResponse(
  747. this.responseText,
  748. (json) => json.data.list.subscribers_timeline.timeline.instructions
  749. );
  750. });
  751. }
  752.  
  753. originalOpen.apply(this, arguments);
  754. };
  755.  
  756. info('Hooked into XMLHttpRequest.');
  757. }
  758.  
  759. // We parse the users' information in the API response and write them to the local database.
  760. // The browser's IndexedDB is used to store the data persistently.
  761. function parseTwitterAPIResponse(text, extractor) {
  762. try {
  763. const json = JSON.parse(text);
  764.  
  765. // NOTE: This may vary as Twitter upgrades.
  766. const instructions = extractor(json);
  767. const entries = instructions.find((item) => item.type === 'TimelineAddEntries').entries;
  768.  
  769. const users = entries
  770. .filter((item) => item.content.itemContent)
  771. .map((item) => ({
  772. ...item.content.itemContent.user_results.result,
  773. entryId: item.entryId,
  774. sortIndex: item.sortIndex,
  775. }));
  776.  
  777. insertUserDataIntoDatabase(users);
  778. } catch (err) {
  779. error(
  780. `Failed to parse API response. (Message: ${err.message}) ` +
  781. 'This may be a problem caused by Twitter updates. Please file an issue on GitHub: ' +
  782. 'https://github.com/prinsss/export-twitter-following-list/issues'
  783. );
  784. }
  785. }
  786.  
  787. /*
  788. |--------------------------------------------------------------------------
  789. | Utility Functions
  790. |--------------------------------------------------------------------------
  791. */
  792.  
  793. // Escape characters for CSV file.
  794. function csvEscapeStr(s) {
  795. return `"${s.replace(/\"/g, '""').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
  796. }
  797.  
  798. // Save a text file to disk.
  799. function saveFile(filename, content) {
  800. const link = document.createElement('a');
  801. link.style = 'display: none';
  802. document.body.appendChild(link);
  803.  
  804. const blob = new Blob([content], { type: 'text/plain' });
  805. const url = URL.createObjectURL(blob);
  806. link.href = url;
  807. link.download = filename;
  808.  
  809. link.click();
  810. URL.revokeObjectURL(url);
  811. }
  812.  
  813. // Replace any https://t.co/ link in the string with its corresponding real URL.
  814. function sanitizeProfileDescription(description, urls) {
  815. let str = description;
  816. if (urls?.length) {
  817. for (const { url, expanded_url } of urls) {
  818. str = str.replace(url, expanded_url);
  819. }
  820. }
  821. return str;
  822. }
  823.  
  824. // Show info logs on both screen and console.
  825. function info(line, ...args) {
  826. console.info('[Export Twitter Following List]', line, ...args);
  827. infoLogs.push(line);
  828.  
  829. const dom = panelDom ? panelDom.querySelector('#export-logs') : null;
  830. if (dom) {
  831. dom.innerHTML = infoLogs.map((content) => '> ' + String(content)).join('\n');
  832. }
  833. }
  834.  
  835. // Show error logs on both screen and console.
  836. function error(line, ...args) {
  837. console.error('[Export Twitter Following List]', line, ...args);
  838. errorLogs.push(line);
  839.  
  840. const dom = panelDom ? panelDom.querySelector('#export-errors') : null;
  841. if (dom) {
  842. dom.innerHTML = errorLogs.map((content) => '> ' + String(content)).join('\n');
  843. }
  844. }
  845.  
  846. // Show debug logs on console.
  847. function debug(...args) {
  848. console.debug('[Export Twitter Following List]', ...args);
  849. }
  850. })();