Copy Tweets

一键复制 Twitter/X 推文(Thread)信息

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name         Copy Tweets
// @namespace    kai.scripts
// @description  一键复制 Twitter/X 推文(Thread)信息
// @version      1.0
// @match        https://x.com/*/status/*
// @run-at       document-idle
// @grant        none
// @license MIT
// ==/UserScript==

(() => {
  'use strict';

  const EX_ATTR = 'data-xcopy-exclude'; // 为 Discover more 区域的 tweetText 打上的标记

  // 标记“Discover more”区域内的 tweetText
  const markDiscoverMore = () => {
      let foundMoreHeader = false;
      const cells = document.querySelectorAll('[data-testid="cellInnerDiv"]');

      cells.forEach(cell => {
          // 检查当前 cell 是否包含 "discover more" 标题
          if (!foundMoreHeader) {
              const headers = cell.querySelectorAll('h2, [role="heading"]');
              const hasDiscoverMore = [...headers].some(h => /discover(?:y)?\s*more/i.test(h.textContent.trim()));
              if (hasDiscoverMore) foundMoreHeader = true;
          }
          // 如果已经找到标题,标记当前及后续 cell 中的 tweets
          if (foundMoreHeader) {
              cell.querySelectorAll('article[data-testid="tweet"]')
                  .forEach(tweet => tweet.setAttribute(EX_ATTR, '1'));
          }
      });
  };
  const clickShowMoreLinks = () => {
      const buttons = document.querySelectorAll('button[data-testid="tweet-text-show-more-link"]');
      buttons.forEach(button => button.click());
  };
  const scrollToBottomAndBack = async () => {
      // 平滑滚动到底部
      window.scrollTo({
          top: document.body.scrollHeight,
          behavior: 'smooth'
      });

      // 等待 300ms
      await new Promise(resolve => setTimeout(resolve, 300));

      // 平滑滚动到顶部
      window.scrollTo({
          top: 0,
          behavior: 'smooth'
      });
  };

  const getRootAuthorHandle = () => {
      try {
          const match = window.location.pathname.match(/^\/([^/]+)\/status\//i);
          if (!match) return '';
          const raw = decodeURIComponent(match[1] || '').trim();
          if (!raw) return '';
          return raw.startsWith('@') ? raw : `@${raw}`;
      } catch (err) {
          console.warn('[Copy Tweets] Failed to detect root author handle', err);
          return '';
      }
  };

  const filterThreadTweets = (tweets, authorHandle) => {
      if (!authorHandle) return [];
      const targetHandle = authorHandle.toLowerCase();
      const threadTweets = [];
      for (const tweet of tweets) {
          if ((tweet.handle || '').toLowerCase() !== targetHandle) break;
          threadTweets.push(tweet);
      }
      return threadTweets;
  };

  const getTweets = () => {
      scrollToBottomAndBack();
      markDiscoverMore(); // 先标记,再收集
      clickShowMoreLinks();
      return [...document.querySelectorAll('article[data-testid="tweet"]')]
          .map(t => {
          if (t.getAttribute(EX_ATTR) === '1') return null; // 用标记排除
          const u = t.querySelector('[data-testid="User-Name"]');
          const parts = u ? [...u.querySelectorAll('span')].map(s => s.textContent.trim()).filter(Boolean) : [];
          const handle = parts.find(x => x.startsWith('@')) || '';
          const name   = parts.find(x => x !== handle && !x.includes('·')) || '';
          const timeEl = t.querySelector('a[href*="/status/"] time');
          const a      = timeEl?.closest('a');
          const ts     = timeEl?.getAttribute('datetime') || '';
          const link   = a ? `x.com${a.getAttribute('href') || a.pathname || ''}` : '';
          const content = [...t.querySelectorAll('[data-testid="tweetText"]')]
          .map(n => n.textContent).join('\n').replace(/\s+\n/g, '\n').trim();
          return { author: `${handle} (${name})`, handle, link, timestamp: ts, content };
      })
          .filter(Boolean);
  };

  const format = arr => arr.map(o =>
    `author: ${o.author}\nlink: ${o.link}\ntimestamp: ${o.timestamp}\ncontent: ${o.content}\n---`
  ).join('\n');

  const applyButtonFeedback = (btn, label) => {
      btn.textContent = label;
      setTimeout(() => { btn.textContent = btn.dataset.label || ''; }, 1200);
  };

  const copyTweetsToClipboard = async (btn, tweets, emptyLabel) => {
      if (!tweets.length) {
          applyButtonFeedback(btn, emptyLabel);
          return;
      }
      const txt = format(tweets);
      if (!txt) {
          applyButtonFeedback(btn, emptyLabel);
          return;
      }
      await navigator.clipboard.writeText(txt);
      applyButtonFeedback(btn, 'Copied ✓');
  };

  const BUTTON_STYLE = 'font-weight:700;margin-left:8px;padding:4px 10px;border:1px solid #ccd;'
      + 'border-radius:14px;background:transparent;cursor:pointer;';

  const createCopyButton = (id, label, handler) => {
      const btn = document.createElement('button');
      btn.id = id;
      btn.type = 'button';
      btn.dataset.label = label;
      btn.textContent = label;
      btn.style.cssText = BUTTON_STYLE;
      btn.onclick = () => handler(btn);
      return btn;
  };

  const install = () => {
      const reply = document.querySelector('[aria-label="Reply"]');
      if (!reply) return;
      let btnAll = document.getElementById('copy-tweets-btn-all');
      if (!btnAll) {
          btnAll = createCopyButton('copy-tweets-btn-all', 'Copy All', async button => {
              const tweets = getTweets();
              await copyTweetsToClipboard(button, tweets, 'No Tweets');
          });
      }

      let btnThread = document.getElementById('copy-tweets-btn-thread');
      if (!btnThread) {
          btnThread = createCopyButton('copy-tweets-btn-thread', 'Copy Thread', async button => {
              const tweets = getTweets();
              const authorHandle = getRootAuthorHandle();
              const threadTweets = filterThreadTweets(tweets, authorHandle);
              await copyTweetsToClipboard(button, threadTweets, 'No Thread');
          });
      }

      if (!btnAll.isConnected) reply.insertAdjacentElement('afterend', btnAll);
      if (!btnThread.isConnected) btnAll.insertAdjacentElement('afterend', btnThread);
  };

  install();
  new MutationObserver(() => { markDiscoverMore(); install(); })
      .observe(document.body, { childList: true, subtree: true });
})();