// ==UserScript== // @name X/Twitter Copy Tweet Link Helper // @name:zh-TW X/Twitter 複製推文連結助手 // @name:zh-CN X/Twitter 复制推文连结助手 // @namespace http://tampermonkey.net/ // @version 2.4 // @description Copy tweet links via right-click, like button, or dedicated button. Supports Fixupx mode, allows toggling specific features directly in the Tampermonkey interface, and offers Chinese/English language switching. // @description:zh-TW 透過右鍵、喜歡或按鈕複製推文鏈接,並支援fixupx模式,可在油猴介面中直接開關指定功能,中英語言顯示切換。 // @description:zh-CN 透过右键、喜欢或按钮复制推文链接,并支援fixupx模式,可在油猴介面中直接开关指定功能,中英语言显示切换。 // @author ChatGPT // @match https://x.com/* // @match https://twitter.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; const defaultSettings = { rightClickCopy: true, likeCopy: true, showCopyButton: true, useFixupx: false, language: 'EN' }; const settings = { get(key) { return GM_getValue(key, defaultSettings[key]); }, set(key, value) { GM_setValue(key, value); } }; const lang = { EN: { copySuccess: "Link copied!", copyButton: "🔗", rightClickCopy: 'Right-click Copy', likeCopy: 'Like Copy', showCopyButton: 'Show Copy Button', useFixupx: 'Use Fixupx', language: 'Language' }, ZH: { copySuccess: "已複製鏈結!", copyButton: "🔗", rightClickCopy: '右鍵複製', likeCopy: '喜歡時複製', showCopyButton: '顯示複製按鈕', useFixupx: '使用 Fixupx', language: '語言' } }; const getText = (key) => lang[settings.get('language')][key]; function cleanTweetUrl(rawUrl) { try { const url = new URL(rawUrl); url.search = ''; url.pathname = url.pathname.replace(/\/photo\/\d+$/, ''); if (settings.get('useFixupx')) { url.hostname = 'fixupx.com'; } return url.toString(); } catch { return rawUrl; } } function copyTweetLink(tweet) { const anchor = tweet.querySelector('a[href*="/status/"]'); if (!anchor) return; const cleanUrl = cleanTweetUrl(anchor.href); navigator.clipboard.writeText(cleanUrl).then(() => { showToast(getText('copySuccess')); }); } let toastTimer = null; function showToast(msg) { let toast = document.getElementById('x-copy-tweet-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'x-copy-tweet-toast'; Object.assign(toast.style, { position: 'fixed', bottom: '20px', left: '50%', transform: 'translateX(-50%)', background: '#1da1f2', color: '#fff', padding: '8px 16px', borderRadius: '20px', zIndex: 9999, fontSize: '14px', pointerEvents: 'none' }); document.body.appendChild(toast); } toast.innerText = msg; toast.style.display = 'block'; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toast.style.display = 'none'; }, 1500); } function insertCopyButton(tweet) { // 檢查是否已經存在我們的按鈕 if (tweet.querySelector('.x-copy-btn')) return; const actionGroup = tweet.querySelector('[role="group"]'); if (!actionGroup) return; // 找到所有操作按鈕 const actionButtons = Array.from(actionGroup.children); if (actionButtons.length === 0) return; // 找到書籤按鈕(通常是倒數第二個,因為最後一個是分享按鈕) const bookmarkContainer = actionButtons[actionButtons.length - 2]; if (!bookmarkContainer) return; // 創建按鈕容器,模仿 Twitter 的按鈕容器結構 const btnContainer = document.createElement('div'); btnContainer.className = 'x-copy-btn-container'; Object.assign(btnContainer.style, { display: 'flex', flexDirection: 'row', alignItems: 'center', minHeight: '20px', maxWidth: '100%', marginRight: '8px', flex: '1' }); // 創建內部容器 const innerDiv = document.createElement('div'); Object.assign(innerDiv.style, { display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'center', minHeight: '20px' }); // 創建按鈕 const btn = document.createElement('div'); btn.className = 'x-copy-btn'; Object.assign(btn.style, { cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', minWidth: '20px', minHeight: '20px', color: 'rgb(83, 100, 113)', transition: 'all 0.2s', borderRadius: '9999px' }); // 創建按鈕內容容器 const btnContent = document.createElement('div'); Object.assign(btnContent.style, { display: 'flex', alignItems: 'center', justifyContent: 'center', minWidth: '20px', minHeight: '20px' }); // 創建按鈕文字 const textSpan = document.createElement('span'); textSpan.innerText = getText('copyButton'); Object.assign(textSpan.style, { fontSize: '16px', lineHeight: '1' }); // 添加懸停效果 btn.addEventListener('mouseover', () => { btn.style.backgroundColor = 'rgba(29, 155, 240, 0.1)'; btn.style.color = 'rgb(29, 155, 240)'; }); btn.addEventListener('mouseout', () => { btn.style.backgroundColor = 'transparent'; btn.style.color = 'rgb(83, 100, 113)'; }); // 添加點擊事件 btn.onclick = (e) => { e.stopPropagation(); copyTweetLink(tweet); }; // 組裝按鈕 btnContent.appendChild(textSpan); btn.appendChild(btnContent); innerDiv.appendChild(btn); btnContainer.appendChild(innerDiv); // 將按鈕插入到書籤按鈕前面 actionGroup.insertBefore(btnContainer, bookmarkContainer); // 複製書籤按鈕容器的樣式 const computedStyle = window.getComputedStyle(bookmarkContainer); btnContainer.style.flex = computedStyle.flex; btnContainer.style.justifyContent = computedStyle.justifyContent; } // 防止重複綁定like事件 function bindLikeCopy(tweet) { if (tweet.hasAttribute('data-likecopy')) return; tweet.setAttribute('data-likecopy', 'true'); const likeBtn = tweet.querySelector('[data-testid="like"]'); if (likeBtn && !likeBtn.hasAttribute('data-likecopy-listener')) { likeBtn.setAttribute('data-likecopy-listener', 'true'); likeBtn.addEventListener('click', () => { copyTweetLink(tweet); }); } } // 防止重複綁定右鍵事件 function bindRightClickCopy(tweet) { if (tweet.hasAttribute('data-rightclick')) return; tweet.setAttribute('data-rightclick', 'true'); tweet.addEventListener('contextmenu', (e) => { if (tweet.querySelector('img, video')) { copyTweetLink(tweet); } }); } // 只處理新增的article節點 function processTweetNode(node) { if (!(node instanceof HTMLElement)) return; if (node.tagName === 'ARTICLE') { if (settings.get('showCopyButton')) insertCopyButton(node); if (settings.get('rightClickCopy')) bindRightClickCopy(node); if (settings.get('likeCopy')) bindLikeCopy(node); } else { node.querySelectorAll && node.querySelectorAll('article').forEach(article => { if (settings.get('showCopyButton')) insertCopyButton(article); if (settings.get('rightClickCopy')) bindRightClickCopy(article); if (settings.get('likeCopy')) bindLikeCopy(article); }); } } // 初始處理現有推文 document.querySelectorAll('article').forEach(processTweetNode); // 只處理新增節點 const tweetObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { mutation.addedNodes.forEach(processTweetNode); } }); tweetObserver.observe(document.body, { childList: true, subtree: true }); // MenuCommand 註冊與註銷 let menuIds = []; function updateMenuCommands() { // 註銷舊的 menuIds.forEach(id => { try { GM_unregisterMenuCommand(id); } catch {} }); menuIds = []; menuIds.push(GM_registerMenuCommand(`${getText('rightClickCopy')} ( ${settings.get('rightClickCopy') ? '✅' : '❌'} )`, toggleRightClickCopy)); menuIds.push(GM_registerMenuCommand(`${getText('likeCopy')} ( ${settings.get('likeCopy') ? '✅' : '❌'} )`, toggleLikeCopy)); menuIds.push(GM_registerMenuCommand(`${getText('showCopyButton')} ( ${settings.get('showCopyButton') ? '✅' : '❌'} )`, toggleShowCopyButton)); menuIds.push(GM_registerMenuCommand(`${getText('useFixupx')} ( ${settings.get('useFixupx') ? '✅' : '❌'} )`, toggleUseFixupx)); // 支援多語言自動切換 const langs = Object.keys(lang); const currentLangIdx = langs.indexOf(settings.get('language')); const nextLang = langs[(currentLangIdx + 1) % langs.length]; // 這裡根據目前語言顯示「語言 中文」或「語言 EN」 let langDisplay = settings.get('language'); if (langDisplay === 'ZH') langDisplay = '中文'; menuIds.push(GM_registerMenuCommand(`${getText('language')} ( ${langDisplay} )`, () => toggleLanguage(nextLang))); } updateMenuCommands(); function toggleRightClickCopy() { settings.set('rightClickCopy', !settings.get('rightClickCopy')); reloadPage(); } function toggleLikeCopy() { settings.set('likeCopy', !settings.get('likeCopy')); reloadPage(); } function toggleShowCopyButton() { settings.set('showCopyButton', !settings.get('showCopyButton')); reloadPage(); } function toggleUseFixupx() { settings.set('useFixupx', !settings.get('useFixupx')); reloadPage(); } function toggleLanguage(nextLang) { settings.set('language', nextLang); reloadPage(); } function reloadPage() { location.reload(); } })();