├── .prettierrc
├── LICENSE
├── README.md
├── export-twitter-following-list.user.js
└── screenshots
├── 01-user-interface.png
├── 02-start-listening.png
└── 03-preview-modal.png
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "tabWidth": 2,
4 | "printWidth": 100
5 | }
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 prin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # export-twitter-following-list
2 |
3 | [](https://www.tampermonkey.net/)
4 |
5 | **Export your Twitter/X's following/followers list like a breeze.**
6 |
7 | The script supports exporting:
8 |
9 | 轻松导出你的 Twitter/X 关注列表与关注者列表。本脚本支持导出:
10 |
11 | - User's following list (用户的正在关注)
12 | - User's followers list (用户的关注者/粉丝)
13 | - List's members list (列表包含的用户)
14 | - List's followers list (关注此列表的用户)
15 |
16 | > Introducing my new project [twitter-web-exporter](https://github.com/prinsss/twitter-web-exporter), a completely rewritten version of this script, which is a more powerful tool for exporting Twitter data, including tweets, replies, likes, bookmarks, following, followers and more.
17 | >
18 | > 广告:全新脚本 [twitter-web-exporter](https://github.com/prinsss/twitter-web-exporter) 现已发布,不仅支持本脚本的全部功能,还可以导出推文、回复、点赞、书签等更多数据。
19 |
20 | ## Installation / 安装
21 |
22 | 1. Install the browser extension [Tampermonkey](https://www.tampermonkey.net/) (安装浏览器扩展 [Tampermonkey](https://www.tampermonkey.net/))
23 | 2. Click [here](https://raw.githubusercontent.com/prinsss/export-twitter-following-list/master/export-twitter-following-list.user.js) to install the user script (点击 [安装用户脚本](https://raw.githubusercontent.com/prinsss/export-twitter-following-list/master/export-twitter-following-list.user.js))
24 |
25 | ## Usage / 使用
26 |
27 | Once the user script is installed, navigate to your following list and you will see a floating panel on the left side of the page:
28 |
29 | 脚本安装完成后,进入你的用户关注页面,可以看到左侧的悬浮面板:
30 |
31 | 
32 |
33 | The control panel will automatically show on supported pages and will hide otherwise. You can not only export following/followers list of yourself but also export for other users.
34 |
35 | 当你进入支持导出的页面时,控制面板会自动打开,在其他页面则会自动隐藏。你不仅可以导出自己的关注/关注者列表,还可以导出其他人的关注/关注者列表。
36 |
37 | Click "Start" button to start extracting data from current list. The active list container will be decorated with a blue border.
38 |
39 | 点击「Start」按钮开始从当前列表中提取信息,当前正在操作的列表会有蓝色边框提示。
40 |
41 | 
42 |
43 | Since we are using an different approach by leveraging the Web API of Twitter itself, instead of the official programmatic API, we need to make sure that Twitter loaded enough data for the list to be exported completely.
44 |
45 | 我们与传统类似工具不同,使用的是 Twitter 自身的 Web API 来获取关注列表,而非其官方的开发者 API。因此,你需要保证 Twitter 自身加载完了列表的全部内容,这样最终导出的列表才是完整的。
46 |
47 | When you scroll down the page, Twitter will lazy-load the list data and the script will intercept it and save the API responses to a local database. The items saved to the memory is marked as "✅" on the list, with a number indicating its sorting index.
48 |
49 | 当你往下滚动页面时,Twitter 会以瀑布流的形式不断加载剩余的列表。此脚本会监听相关的 API 调用,并将 API 响应保存至浏览器的本地数据库。已经保存的列表内容会使用「✅」标记出来,后面的数字代表了其在这个列表中的排列顺序。
50 |
51 | Keep scrolling down until the end of the list, and "Saved count" number on the control panel should matches the list length.
52 |
53 | 随后,持续向下滚动页面,直到到达列表底端。此时控制面板中显示的「Saved count」已保存数量应该与列表长度相同。
54 |
55 | 
56 |
57 | Click "Preview" button will show a table of currently saved list content in memory. If you are okay with it, click "Export as CSV/JSON/HTML" to download an archive file.
58 |
59 | 点击「Preview」按钮即可以表格形式预览当前已经暂存的列表数据。如果没问题,点击「Export as」按钮即可导出 CSV/JSON/HTML 格式的列表归档数据。
60 |
61 | > Tips: The user data retrieved from API responses is persisted in browser's IndexedDB. Open your browser's DevTools or use the "Dump Database" button to inspect it.
62 | >
63 | > 小提示:从 API 响应中保存的用户数据都持久化存储在浏览器的 IndexedDB 中。你可以使用浏览器的开发者工具,或者控制面板的「Dump Database」按钮来查看数据库的内容。
64 |
65 | ## FAQ / 常见问题
66 |
67 | Q: What about privacy?
68 | A: Everything is processed on your local browser. No data is sent to the cloud.
69 |
70 | Q: Why do you build this?
71 | A: For archival usage. Twitter's archive only contains the numeric user ID of your following/followers which is not human-readable.
72 |
73 | Q: What's the difference between this and other alternatives?
74 | A: You don't need a developer account for accessing the Twitter API. You don't need to send your personal data to someone's server. The script is completely free and open-source.
75 |
76 | Q: The script does not work!
77 | A: A platform upgrade will possibly breaks the script's functionality. Please file an [issue](https://github.com/prinsss/export-twitter-following-list/issues) if you encountered any problem.
78 |
79 | Q: 这个脚本如何处理隐私数据?
80 | A: 所有数据都在你的本地浏览器中处理完成,不会被发送到云端。
81 |
82 | Q: 你开发这个脚本的原因是?
83 | A: 为了个人存档使用。Twitter 官方的归档功能只包含了关注列表和关注者的数字用户 ID,人类根本不可读。
84 |
85 | Q: 这个脚本与其他类似工具有什么区别?
86 | A: 无需注册 Twitter 开发者账号;无需将个人数据上传至第三方服务器;完全免费开源。
87 |
88 | Q: 脚本无法正常工作!
89 | A: 平台的升级改动可能会导致此脚本的功能失效。如果你遇到任何问题,请提交 [issue](https://github.com/prinsss/export-twitter-following-list/issues) 反馈。
90 |
91 | ## License / 开源许可
92 |
93 | MIT
94 |
--------------------------------------------------------------------------------
/export-twitter-following-list.user.js:
--------------------------------------------------------------------------------
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 | // @updateURL https://raw.githubusercontent.com/prinsss/export-twitter-following-list/master/export-twitter-following-list.user.js
13 | // @downloadURL https://raw.githubusercontent.com/prinsss/export-twitter-following-list/master/export-twitter-following-list.user.js
14 | // @license MIT
15 | // ==/UserScript==
16 |
17 | (function () {
18 | 'use strict';
19 |
20 | /*
21 | |--------------------------------------------------------------------------
22 | | Global Variables
23 | |--------------------------------------------------------------------------
24 | */
25 |
26 | const SCRIPT_NAME = 'export-twitter-following-list';
27 |
28 | /** @type {Element} */
29 | let panelDom = null;
30 |
31 | /** @type {Element} */
32 | let listContainerDom = null;
33 |
34 | /** @type {IDBDatabase} */
35 | let db = null;
36 |
37 | let isList = false;
38 | let savedCount = 0;
39 | let targetUser = '';
40 | let currentType = '';
41 | let previousPathname = '';
42 |
43 | const infoLogs = [];
44 | const errorLogs = [];
45 |
46 | const buffer = new Set();
47 | const currentList = new Map();
48 | const currentListSwapped = new Map();
49 | const currentListUniqueSet = new Set();
50 |
51 | /*
52 | |--------------------------------------------------------------------------
53 | | Script Bootstraper
54 | |--------------------------------------------------------------------------
55 | */
56 |
57 | initDatabase();
58 | hookIntoXHR();
59 |
60 | if (document.readyState === 'loading') {
61 | document.addEventListener('DOMContentLoaded', onPageLoaded);
62 | } else {
63 | onPageLoaded();
64 | }
65 |
66 | // Determine wether the script should be run.
67 | function bootstrap() {
68 | const pathname = location.pathname;
69 |
70 | if (pathname === previousPathname) {
71 | return;
72 | }
73 |
74 | previousPathname = pathname;
75 |
76 | // Show the script UI on these pages:
77 | // - User's following list
78 | // - User's followers list
79 | // - List's member list
80 | // - List's followers list
81 |
82 | const listRegex = /^\/i\/lists\/(.+)\/(followers|members)/;
83 | const userRegex = /^\/(.+)\/(following|followers_you_follow|followers|verified_followers)/;
84 |
85 | isList = listRegex.test(pathname);
86 | const isUser = userRegex.test(pathname);
87 |
88 | if (!isList && !isUser) {
89 | destroyControlPanel();
90 | return;
91 | }
92 |
93 | const regex = isList ? listRegex : userRegex;
94 | const parsed = regex.exec(pathname) || [];
95 | const [match, target, type] = parsed;
96 |
97 | initControlPanel();
98 | updateControlPanel({ type, username: isList ? `list_${target}` : target });
99 | }
100 |
101 | // Listen to URL changes.
102 | function onPageLoaded() {
103 | new MutationObserver(bootstrap).observe(document.head, {
104 | childList: true,
105 | });
106 | info('Script ready.');
107 | }
108 |
109 | /*
110 | |--------------------------------------------------------------------------
111 | | Page Scroll Listener
112 | |--------------------------------------------------------------------------
113 | */
114 |
115 | // When the content of the list changes, we extract some information from the DOM.
116 | // Note that Twitter is using Virtual List so DOM nodes are always recycled.
117 | function onListChange() {
118 | listContainerDom.childNodes.forEach((child) => {
119 | // NOTE: This may vary as Twitter upgrades.
120 | const link = child.querySelector(
121 | 'div[role=button] > div:first-child > div:nth-child(2) > div:first-child ' +
122 | '> div:first-child > div:first-child > div:nth-child(2) a'
123 | );
124 |
125 | if (!link) {
126 | debug('No link element found in list child', child);
127 | return;
128 | }
129 |
130 | const span = link.querySelector('span');
131 | const parsed = /@(\w+)/.exec(span.textContent) || [];
132 | const [match, username] = parsed;
133 |
134 | if (!username) {
135 | debug('No username found in the link', span.textContent, child);
136 | return;
137 | }
138 |
139 | // We use a emoji to mark that a user was added into current exporting list.
140 | const mark = ' ✅';
141 |
142 | if (currentListUniqueSet.has(username)) {
143 | // When you scroll back, the DOM was reset so we need to mark it again.
144 | if (!span.textContent.includes(mark)) {
145 | const index = currentListSwapped.get(username);
146 | span.innerHTML += `${mark}😸 (${index})`;
147 | }
148 | return;
149 | }
150 |
151 | savedCount += 1;
152 | updateControlPanel({ count: savedCount });
153 |
154 | // Add the username extracted to the exporting list.
155 | const index = savedCount;
156 | currentListUniqueSet.add(username);
157 | currentList.set(index, username);
158 | currentListSwapped.set(username, index);
159 |
160 | span.innerHTML += `${mark} (${index})`;
161 | });
162 | }
163 |
164 | function attachToListContainer() {
165 | // NOTE: This may vary as Twitter upgrades.
166 | if (isList) {
167 | listContainerDom = document.querySelector(
168 | 'div[role="group"] div[role="dialog"] section[role="region"] > div > div'
169 | );
170 | } else {
171 | listContainerDom = document.querySelector(
172 | 'main[role="main"] div[data-testid="primaryColumn"] section[role="region"] > div > div'
173 | );
174 | }
175 |
176 | if (!listContainerDom) {
177 | error(
178 | 'No list container found. ' +
179 | 'This may be a problem caused by Twitter updates. Please file an issue on GitHub: ' +
180 | 'https://github.com/prinsss/export-twitter-following-list/issues'
181 | );
182 | return;
183 | }
184 |
185 | // Add a border to the attached list container as an indicator.
186 | listContainerDom.style.border = '2px dashed #1d9bf0';
187 |
188 | // Listen to the change of the list.
189 | onListChange();
190 | new MutationObserver(onListChange).observe(listContainerDom, {
191 | childList: true,
192 | });
193 | }
194 |
195 | /*
196 | |--------------------------------------------------------------------------
197 | | User Interfaces
198 | |--------------------------------------------------------------------------
199 | */
200 |
201 | // Hide the script UI and clear all the cache.
202 | function destroyControlPanel() {
203 | document.getElementById(`${SCRIPT_NAME}-panel`)?.remove();
204 | document.getElementById(`${SCRIPT_NAME}-panel-style`)?.remove();
205 | panelDom = null;
206 | listContainerDom = null;
207 | currentType = '';
208 | targetUser = '';
209 | savedCount = 0;
210 | currentList.clear();
211 | currentListUniqueSet.clear();
212 | currentListSwapped.clear();
213 | }
214 |
215 | // Update the script UI.
216 | function updateControlPanel({ type, username, count = 0 }) {
217 | if (!panelDom) {
218 | error('Monitor panel is not initialized');
219 | return;
220 | }
221 |
222 | if (type) {
223 | currentType = type;
224 | panelDom.querySelector('#list-type').textContent = type;
225 | }
226 |
227 | if (count) {
228 | panelDom.querySelector('#saved-count').textContent = count;
229 | }
230 |
231 | if (username) {
232 | targetUser = username;
233 | panelDom.querySelector('#target-user').textContent = username;
234 | }
235 | }
236 |
237 | // Show the script UI.
238 | function initControlPanel() {
239 | destroyControlPanel();
240 |
241 | const panel = document.createElement('div');
242 | panelDom = panel;
243 | panel.id = `${SCRIPT_NAME}-panel`;
244 | panel.innerHTML = `
245 |