本帖最後由 block 於 2026-4-21 01:19 編輯
x 账号经常被封,所以写了一个关注列表导出插件,导出关注列表为json文件。
在油猴浏览器插件,新增插件,粘贴下方代码。
v3
// ==UserScript==
// @name tw followed user download
// @namespace http://tampermonkey.net/
// @version 2026-04-21
// @description Export followed users from an X following page with a better in-page experience.
// @author You
// @match https://x.com/*/following
// @icon https://www.google.com/s2/favicons?sz=64&domain=x.com
// @grant GM_registerMenuCommand
// ==/UserScript==
(function () {
'use strict';
const SELECTORS = {
timeline: 'div[aria-label="Timeline: Following"]',
list: 'div[aria-label="Timeline: Following"] > div',
items: 'div[aria-label="Timeline: Following"] > div > div',
};
const PANEL_ID = 'tm-x-following-exporter-panel';
const TOAST_ID = 'tm-x-following-exporter-toast';
const LAUNCHER_ID = 'tm-x-following-exporter-launcher';
const STATUS_TEXT = {
idle: '待开始',
running: '采集中',
paused: '已暂停',
completed: '已完成',
error: '异常',
};
const RESERVED_ACCOUNTS = new Set([
'home',
'explore',
'notifications',
'messages',
'search',
'settings',
'compose',
'tos',
'privacy',
'i',
]);
const state = {
status: 'idle',
targetAccount: getTargetAccount(),
records: new Map(),
scanCount: 0,
newCount: 0,
stagnantRounds: 0,
lastVisibleAccount: '',
observer: null,
timer: null,
listNode: null,
lastMutationAt: 0,
lastScrollAt: 0,
lastMessage: '准备开始',
toastTimer: null,
};
cleanupExistingPanel();
const ui = createPanel();
registerMenuCommands();
render();
function waitForNode(selector, attempts = 15, interval = 1000) {
return new Promise((resolve, reject) => {
const immediate = document.querySelector(selector);
if (immediate) {
resolve(immediate);
return;
}
let count = 0;
const timer = window.setInterval(() => {
const node = document.querySelector(selector);
if (node) {
window.clearInterval(timer);
resolve(node);
return;
}
count += 1;
if (count >= attempts) {
window.clearInterval(timer);
reject(new Error(`Node not found: ${selector}`));
}
}, interval);
});
}
function getTargetAccount() {
const segments = window.location.pathname.split('/').filter(Boolean);
return segments[0] || 'unknown';
}
function cleanupExistingPanel() {
const existingPanel = document.getElementById(PANEL_ID);
if (existingPanel) {
existingPanel.remove();
}
const existingToast = document.getElementById(TOAST_ID);
if (existingToast) {
existingToast.remove();
}
const existingLauncher = document.getElementById(LAUNCHER_ID);
if (existingLauncher) {
existingLauncher.remove();
}
}
function registerMenuCommands() {
if (typeof GM_registerMenuCommand !== 'function') {
return;
}
GM_registerMenuCommand('显示导出面板', showPanel);
GM_registerMenuCommand('隐藏导出面板', hidePanel);
GM_registerMenuCommand('开始采集 following', startCollection);
}
function getFollowingItems() {
return Array.from(document.querySelectorAll(SELECTORS.items));
}
function extractRecord(item) {
const anchors = Array.from(item.querySelectorAll('a[href]'));
const handleText = Array.from(item.querySelectorAll('span'))
.map((node) => (node.textContent || '').trim())
.find((text) => /^@[A-Za-z0-9_]{1,15}$/.test(text));
const handleAccount = handleText ? handleText.slice(1) : '';
const profileCandidates = anchors
.map((anchor) => {
try {
const url = new URL(anchor.href, window.location.origin);
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length !== 1) {
return null;
}
const [account] = parts;
if (!account || RESERVED_ACCOUNTS.has(account.toLowerCase())) {
return null;
}
return { anchor, account, href: url.href };
} catch (error) {
return null;
}
})
.filter(Boolean);
if (profileCandidates.length === 0) {
return null;
}
const matchedCandidate = handleAccount
? profileCandidates.find((candidate) => candidate.account.toLowerCase() === handleAccount.toLowerCase())
: null;
const profileCandidate = matchedCandidate || profileCandidates[0];
const account = profileCandidate.account;
const textCandidates = Array.from(item.querySelectorAll('span'))
.map((node) => (node.textContent || '').trim())
.filter(Boolean)
.filter((text) => text !== `@${account}` && text !== 'Following' && text !== '已关注' && text !== 'Followed');
const userName = textCandidates[0] || account;
return {
account,
userName,
profileUrl: profileCandidate.href,
};
}
function scanVisibleItems() {
const items = getFollowingItems();
state.scanCount = items.length;
let added = 0;
for (const item of items) {
const record = extractRecord(item);
if (!record) {
continue;
}
if (!state.records.has(record.account)) {
state.records.set(record.account, record);
added += 1;
}
}
state.newCount = added;
return added;
}
function getLastVisibleAccount() {
const items = getFollowingItems();
for (let index = items.length - 1; index >= 0; index -= 1) {
const record = extractRecord(items[index]);
if (record) {
return record.account;
}
}
return '';
}
function setStatus(status, message) {
state.status = status;
if (message) {
state.lastMessage = message;
}
render();
}
function clearObserver() {
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
}
function clearTimer() {
if (state.timer) {
window.clearTimeout(state.timer);
state.timer = null;
}
}
function queueNextStep(delay) {
clearTimer();
state.timer = window.setTimeout(() => {
window.requestAnimationFrame(stepScroll);
}, delay);
}
function resetCollection() {
clearObserver();
clearTimer();
state.records = new Map();
state.scanCount = 0;
state.newCount = 0;
state.stagnantRounds = 0;
state.lastVisibleAccount = '';
state.listNode = null;
state.lastMutationAt = 0;
state.lastScrollAt = 0;
state.targetAccount = getTargetAccount();
state.lastMessage = '数据已清空';
setStatus('idle');
}
function attachObserver(listNode) {
clearObserver();
state.observer = new MutationObserver(() => {
state.lastMutationAt = Date.now();
const added = scanVisibleItems();
if (added > 0) {
state.lastMessage = `新增 ${added} 条`;
render();
}
});
state.observer.observe(listNode, { childList: true, subtree: true });
}
function showToast(message, isError = false) {
ui.toast.textContent = message;
ui.toast.style.display = 'block';
ui.toast.style.background = isError ? '#9f1239' : '#111827';
if (state.toastTimer) {
window.clearTimeout(state.toastTimer);
}
state.toastTimer = window.setTimeout(() => {
ui.toast.style.display = 'none';
}, 2600);
}
function completeCollection(message) {
clearObserver();
clearTimer();
setStatus('completed', message || `采集完成,共 ${state.records.size} 个账号`);
showToast(state.lastMessage);
window.twFollowedUsersExport = getRecords();
}
function failCollection(message) {
clearObserver();
clearTimer();
setStatus('error', message);
showToast(message, true);
}
function hidePanel() {
if (ui.panel) {
ui.panel.style.display = 'none';
}
if (ui.toast) {
ui.toast.style.display = 'none';
}
if (ui.launcher) {
ui.launcher.style.display = 'flex';
}
}
function showPanel() {
if (ui.panel) {
ui.panel.style.display = 'block';
}
if (ui.launcher) {
ui.launcher.style.display = 'none';
}
render();
}
function stepScroll() {
if (state.status !== 'running') {
return;
}
const now = Date.now();
const minScrollInterval = 720;
const quietWindow = 420;
const sinceLastScroll = state.lastScrollAt ? now - state.lastScrollAt : Infinity;
const sinceLastMutation = state.lastMutationAt ? now - state.lastMutationAt : Infinity;
if (sinceLastScroll < minScrollInterval) {
state.lastMessage = '等待列表渲染完成';
render();
queueNextStep(minScrollInterval - sinceLastScroll + 80);
return;
}
if (sinceLastMutation < quietWindow) {
state.lastMessage = '列表还在加载,等待稳定后再继续';
render();
queueNextStep(quietWindow - sinceLastMutation + 120);
return;
}
const currentLastAccount = getLastVisibleAccount();
const added = scanVisibleItems();
const doc = document.documentElement;
const scrollStep = Math.max(Math.floor(window.innerHeight * 0.85), 320);
const nearBottom = doc.scrollHeight - (doc.scrollTop + doc.clientHeight) < window.innerHeight * 1.5;
let nextScrollTop = doc.scrollTop + scrollStep;
if (added > 0) {
state.stagnantRounds = 0;
state.lastMessage = `新增 ${added} 条,累计 ${state.records.size} 条`;
} else if (nearBottom && currentLastAccount && currentLastAccount === state.lastVisibleAccount) {
state.stagnantRounds += 1;
if (state.stagnantRounds % 2 === 1) {
nextScrollTop = Math.max(0, doc.scrollTop - Math.floor(scrollStep * 0.65));
state.lastMessage = `底部重试中,第 ${state.stagnantRounds} 次无新增`;
} else {
nextScrollTop = doc.scrollTop + scrollStep;
state.lastMessage = `继续触发加载,第 ${state.stagnantRounds} 次无新增`;
}
} else {
state.lastMessage = '继续滚动采集';
}
state.lastVisibleAccount = currentLastAccount || state.lastVisibleAccount;
render();
if (state.stagnantRounds >= 10) {
scanVisibleItems();
completeCollection(`采集完成,共 ${state.records.size} 个账号`);
return;
}
window.scrollTo(0, nextScrollTop);
state.lastScrollAt = Date.now();
queueNextStep(minScrollInterval);
}
async function startCollection() {
if (state.status === 'running') {
return;
}
resetCollection();
setStatus('running', '正在定位 following 列表');
window.scrollTo(0, 0);
try {
const listNode = await waitForNode(SELECTORS.list, 15, 1000);
state.listNode = listNode;
attachObserver(listNode);
state.lastMutationAt = Date.now();
const added = scanVisibleItems();
state.lastVisibleAccount = getLastVisibleAccount();
state.lastMessage = added > 0
? `首屏采集到 ${added} 条`
: '首屏无新增,开始滚动';
render();
queueNextStep(700);
} catch (error) {
failCollection('未检测到 following 列表,请确认页面已加载完成');
}
}
function pauseCollection() {
if (state.status !== 'running') {
return;
}
clearTimer();
clearObserver();
scanVisibleItems();
setStatus('paused', `已暂停,当前 ${state.records.size} 个账号`);
showToast('采集已暂停');
}
function resumeCollection() {
if (state.status !== 'paused') {
return;
}
if (state.listNode) {
attachObserver(state.listNode);
}
state.lastMutationAt = Date.now();
setStatus('running', '继续采集中');
queueNextStep(700);
}
function stopCollection() {
if (!['running', 'paused'].includes(state.status)) {
return;
}
scanVisibleItems();
completeCollection(`已手动停止,共 ${state.records.size} 个账号`);
}
function getRecords() {
return Array.from(state.records.values());
}
function toJson(pretty = true) {
return JSON.stringify(getRecords(), null, pretty ? 2 : 0);
}
function buildFileName() {
const date = new Date();
const pad = (value) => String(value).padStart(2, '0');
const stamp = [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
].join('-') + '_' + [
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('-');
return `tw-${getTargetAccount()}-following-${stamp}.json`;
}
function downloadJson() {
if (state.records.size === 0) {
showToast('当前没有可导出的数据', true);
return;
}
const blob = new Blob([toJson(true)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = buildFileName();
link.click();
URL.revokeObjectURL(url);
showToast(`JSON 已下载,共 ${state.records.size} 个账号`);
}
async function copyJson() {
if (state.records.size === 0) {
showToast('当前没有可复制的数据', true);
return;
}
try {
await navigator.clipboard.writeText(toJson(true));
showToast('JSON 已复制到剪贴板');
} catch (error) {
showToast('复制失败,浏览器可能拦截了剪贴板权限', true);
}
}
function destroyPanel() {
clearObserver();
clearTimer();
if (state.toastTimer) {
window.clearTimeout(state.toastTimer);
state.toastTimer = null;
}
if (state.status === 'running' || state.status === 'paused') {
state.status = 'idle';
state.lastMessage = '面板已关闭,可点击悬浮按钮重新打开';
}
hidePanel();
}
function createButton(label, onClick, variant = 'default') {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.addEventListener('click', onClick);
button.style.cssText = [
'border: 0',
'border-radius: 10px',
'padding: 8px 12px',
'font-size: 12px',
'font-weight: 600',
'cursor: pointer',
'transition: opacity .2s ease',
variant === 'primary' ? 'background: #2563eb; color: #fff;' : '',
variant === 'danger' ? 'background: #dc2626; color: #fff;' : '',
variant === 'default' ? 'background: #e5e7eb; color: #111827;' : '',
].join(';');
return button;
}
function createPanel() {
const launcher = createButton('导出', showPanel, 'primary');
launcher.id = LAUNCHER_ID;
launcher.style.cssText += ';position: fixed; right: 16px; bottom: 16px; z-index: 99998; border-radius: 999px; padding: 10px 14px; display: none;';
const panel = document.createElement('section');
panel.id = PANEL_ID;
panel.style.cssText = [
'position: fixed',
'right: 16px',
'bottom: 16px',
'z-index: 99999',
'width: 320px',
'padding: 14px',
'border-radius: 16px',
'background: rgba(255,255,255,.96)',
'box-shadow: 0 16px 40px rgba(15,23,42,.18)',
'border: 1px solid rgba(15,23,42,.08)',
'color: #0f172a',
'font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
'font-size: 12px',
'line-height: 1.5',
].join(';');
const header = document.createElement('div');
header.style.cssText = 'display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px;';
const title = document.createElement('div');
title.textContent = 'X 关注导出';
title.style.cssText = 'font-size: 14px; font-weight: 700;';
const closeButton = createButton('关闭', destroyPanel);
closeButton.style.padding = '6px 10px';
closeButton.style.fontSize = '11px';
header.append(title, closeButton);
const account = document.createElement('div');
account.style.cssText = 'margin-bottom: 4px;';
const status = document.createElement('div');
status.style.cssText = 'margin-bottom: 4px;';
const stats = document.createElement('div');
stats.style.cssText = 'margin-bottom: 4px;';
const message = document.createElement('div');
message.style.cssText = 'margin-bottom: 10px; color: #475569; min-height: 18px;';
const actions = document.createElement('div');
actions.style.cssText = 'display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px;';
const preview = document.createElement('pre');
preview.style.cssText = [
'margin: 0',
'padding: 10px',
'border-radius: 12px',
'background: #f8fafc',
'border: 1px solid #e2e8f0',
'max-height: 160px',
'overflow: auto',
'white-space: pre-wrap',
'word-break: break-word',
'font-size: 11px',
].join(';');
const toast = document.createElement('div');
toast.id = TOAST_ID;
toast.style.cssText = [
'display: none',
'position: fixed',
'right: 16px',
'bottom: 360px',
'z-index: 100000',
'padding: 10px 14px',
'border-radius: 12px',
'color: #fff',
'font-size: 12px',
'box-shadow: 0 10px 24px rgba(15,23,42,.24)',
].join(';');
const startButton = createButton('开始', startCollection, 'primary');
const pauseButton = createButton('暂停', pauseCollection);
const resumeButton = createButton('继续', resumeCollection);
const stopButton = createButton('停止', stopCollection, 'danger');
const downloadButton = createButton('下载 JSON', downloadJson);
const copyButton = createButton('复制 JSON', copyJson);
const clearButton = createButton('清空', resetCollection);
actions.append(
startButton,
pauseButton,
resumeButton,
stopButton,
downloadButton,
copyButton,
clearButton
);
panel.append(header, account, status, stats, message, actions, preview);
document.body.append(panel, toast, launcher);
return {
panel,
launcher,
account,
status,
stats,
message,
preview,
toast,
buttons: {
closeButton,
startButton,
pauseButton,
resumeButton,
stopButton,
downloadButton,
copyButton,
clearButton,
},
};
}
function setButtonState(button, enabled) {
button.disabled = !enabled;
button.style.opacity = enabled ? '1' : '0.45';
button.style.cursor = enabled ? 'pointer' : 'not-allowed';
}
function render() {
state.targetAccount = getTargetAccount();
ui.account.textContent = `目标账号: @${state.targetAccount}`;
ui.status.textContent = `当前状态: ${STATUS_TEXT[state.status] || state.status}`;
ui.stats.textContent = `已采集: ${state.records.size} | 当前页条目: ${state.scanCount} | 最近新增: ${state.newCount}`;
ui.message.textContent = state.lastMessage;
const previewList = getRecords().slice(0, 5);
ui.preview.textContent = previewList.length > 0
? previewList.map((item, index) => `${index + 1}. ${item.userName} (@${item.account})`).join('\n')
: '暂无数据';
const isIdle = state.status === 'idle';
const isRunning = state.status === 'running';
const isPaused = state.status === 'paused';
const canExport = state.records.size > 0;
setButtonState(ui.buttons.startButton, isIdle || state.status === 'completed' || state.status === 'error');
setButtonState(ui.buttons.pauseButton, isRunning);
setButtonState(ui.buttons.resumeButton, isPaused);
setButtonState(ui.buttons.stopButton, isRunning || isPaused);
setButtonState(ui.buttons.downloadButton, canExport);
setButtonState(ui.buttons.copyButton, canExport);
setButtonState(ui.buttons.clearButton, !isRunning);
}
})();
複製代碼
用ai 重构了
进入你的关注页面:https://x.com/{你的账号}/following
如果右下角没有显示插件导出面板,尝试刷新一下页面或者点击油猴插件下方:显示导出面板 按钮。
评论 (0)
登录 后参与讨论。
还没有评论,来抢沙发吧。