成人交流 · text · 0 分 · 评论 0 · 阅读 1

油猴插件:x 平台关注列表导出

本帖最後由 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)

登录 后参与讨论。

还没有评论,来抢沙发吧。