您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
support twitter to copy, easy to share.
当前为
// ==UserScript== // @name share-tweet-copy // @namespace https://screw-hand.com/ // @version 0.4.1 // @description support twitter to copy, easy to share. // @author screw-hand // @match https://twitter.com/* // @icon https://abs.twimg.com/favicons/twitter.3.ico // @grant GM_addStyle // @grant GM_getValue // @homepage https://github.com/screw-hand/tampermonkey-user.js // @supportURL https://github.com/screw-hand/tampermonkey-user.js/issues/new // ==/UserScript== (function () { 'use strict'; /** * Change Log * * Version 0.4.1(2024-04-24) * - dev.user.js (dev mode) support set up USER_TEMPLATE environment variable. * * Version 0.4.0 (2024-04-23) * - Tweets are measured by character count, and if the character count exceeds the limit, it is replaced with an ellipsis; * - If the number of line breaks in a tweet exceeds the limit, it is replaced with an ellipsis. * - support environment variable * * Version 0.3.16 (2024-04-17) * - Fix username's emoji cannot be copied * * Version 0.3.15 (2024-01-24) * - Fix margin for copy-tweet-button * * Version 0.3.14 (2024-01-24) * - Fix button style on status page. * * Version 0.3.13 (2023-01-11) * - Fix add newline between original and translated text in Immersive Translate. * * Version 0.3.12 (2023-12-29) * - Fix media static error result count, add card link count. * * Version 0.3.11 (2023-12-29) * - Fix media static error result count. * * Version 0.3.10 (2023-12-29) * - temp update status page style * - update failedmark icon svg * * Version 0.3.9 (2023-12-29) * - fix copy result is undefined * * Version 0.3.8 (2023-12-29) * - notify about copy failed * * Version 0.3.7 (2023-12-29) * - recover homepage and supportURL config. * * Version 0.3.6 (2023-12-29) * - Media count support static git, video, and more media card. * * Version 0.3.5 (2023-12-25) * - Rename script path of github repo. * * Version 0.3.4 (2023-12-25) * - Update media count default template. * * Version 0.3.3 (2023-12-25) * - Chore indent use 2 spaces. * - Feat remove multiple blank lines. * * Version 0.3.2 (2023-12-24) * - Chore indent use 2 spaces. * * Version 0.3.1 (2023-12-23) * - Fixed issue with copying reposted tweets: now correctly retrieves user name. * - Optimized the teewText format, when copy the context with `@usernmae` not line feed. * - Fxied copy emoji. Pre-condition: The user's system supports displaying that emoji. * - Feature: Statistics the pic total count, for base feature. * * * Version 0.3.0 (2023-12-23) * - Public the script to Greasy Fork. * - Delete updateURL, donwupdateURL. * * Version 0.2 (2023-12-13) * - Added styles for the copy-tweet button, including support for dark mode. * - Implemented a tooltip to show the result of the copy action. * - Enhanced dark mode support for better user experience in various lighting conditions. * * Version 0.1 (2023-12-13) * - Initial release. * - Basic functionality to copy tweet text to clipboard with a simple template. */ /** * Defines the styles for the copy button. * This includes support for dark mode and styling for various states like hover and focus. */ const copyBtnStyle = /*css*/` .copy-tweet-button { --button-bg: #e5e6eb; --button-hover-bg: #d7dbe2; --button-text-color: #4e5969; --button-hover-text-color: #164de5; --button-border-radius: 6px; --button-diameter: 24px; --button-outline-width: 2px; --button-outline-color: #9f9f9f; --tooltip-bg: #1d2129; --toolptip-border-radius: 4px; --tooltip-font-family: JetBrains Mono, Consolas, Menlo, Roboto Mono, monospace; --tooltip-font-size: 12px; --tootip-text-color: #fff; --tooltip-padding-x: 7px; --tooltip-padding-y: 7px; --tooltip-offset: 8px; /* --tooltip-transition-duration: 0.3s; */ } @media (prefers-color-scheme: dark) { .copy-tweet-button { --button-bg: #353434; --button-hover-bg: #464646; --button-text-color: #ccc; --button-outline-color: #999; --button-hover-text-color: #8bb9fe; --tooltip-bg: #f4f3f3; --tootip-text-color: #111; } } .copy-tweet-button { box-sizing: border-box; width: var(--button-diameter); height: var(--button-diameter); border-radius: var(--button-border-radius); background-color: var(--button-bg); color: var(--button-text-color); border: none; cursor: pointer; position: relative; outline: var(--button-outline-width) solid transparent; transition: all 0.2s ease; } *:has(+ .copy-tweet-button) { margin-right: 8px !important; } .tooltip { position: absolute; opacity: 0; left: calc(100% + var(--tooltip-offset)); top: 50%; transform: translateY(-50%); white-space: nowrap; font: var(--tooltip-font-size) var(--tooltip-font-family); color: var(--tootip-text-color); background: var(--tooltip-bg); padding: var(--tooltip-padding-y) var(--tooltip-padding-x); border-radius: var(--toolptip-border-radius); pointer-events: none; transition: all var(--tooltip-transition-duration) cubic-bezier(0.68, -0.55, 0.265, 1.55); } .tooltip::before { content: attr(data-text-initial); } .tooltip::after { content: ""; width: var(--tooltip-padding-y); height: var(--tooltip-padding-y); background: inherit; position: absolute; top: 50%; left: calc(var(--tooltip-padding-y) / 2 * -1); transform: translateY(-50%) rotate(45deg); z-index: -999; pointer-events: none; } .copy-tweet-button svg { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } .checkmark, .failedmark { display: none; } .copy-tweet-button:hover .tooltip, .copy-tweet-button:focus:not(:focus-visible) .tooltip { opacity: 1; visibility: visible; } .copy-tweet-button:focus:not(:focus-visible) .tooltip::before { content: attr(data-text-end); } .copy-tweet-button.copy-failed:focus:not(:focus-visible) .tooltip::before { content: attr(data-text-failed); } .copy-tweet-button:focus:not(:focus-visible) .clipboard { display: none; } .copy-tweet-button:focus:not(:focus-visible) .checkmark { display: block; } .copy-tweet-button.copy-failed:focus:not(:focus-visible) .checkmark { display: none; } .copy-tweet-button.copy-failed:focus:not(:focus-visible) .failedmark { display: block; } .copy-tweet-button:hover, .copy-tweet-button:focus { background-color: var(--button-hover-bg); } .copy-tweet-button:active { outline: var(--button-outline-width) solid var(--button-outline-color); } .copy-tweet-button:hover svg { color: var(--button-hover-text-color); } `; /** * Adds styles using the DOM method. * @param {string} cssText - The CSS style text to be added. */ function addStyleWithDOM(cssText) { const styleNode = document.createElement('style') styleNode.appendChild(document.createTextNode(cssText)); (document.querySelector('head') || document.documentElement).appendChild(styleNode) } /** * Adds styles using GM_addStyle and returns whether it was successful. * @param {string} cssText - The CSS style text to be added. * @returns {boolean} Whether the style was successfully added. */ function addStyleWithGM(cssText) { const isGMAddStyleAvailable = typeof GM_addStyle !== 'undefined'; if (isGMAddStyleAvailable) { GM_addStyle(cssText); } return isGMAddStyleAvailable; } // Execute style addition and check if it was successful const resultsOfEnforcement = addStyleWithGM(copyBtnStyle) // If unsuccessful, fallback to adding styles using the DOM method if (!resultsOfEnforcement) { addStyleWithDOM(copyBtnStyle) } /** * environment variable * @type {Object} * @property {string} MODE 'DEV' || 'PROD' * @property {string} USER_TEMPLATE */ const ENV = { MODE: GM_getValue('ENV_MODE', 'PROD'), USER_TEMPLATE: GM_getValue('ENV_USER_TEMPLATE') } if (ENV.MODE !== 'PROD') { console.log(`share-tweet-copy: ${ENV.MODE}`) } /** * Contains functions to extract various pieces of data from a tweet element. */ const tweetDataExtractors = { username: ({ tweetElement }) => findUserName({ tweetElement }), userId: ({ tweetElement }) => findUserID({ tweetElement }), tweetText: ({ tweetElement }) => findTweetText({ tweetElement }), mediaCount: ({ tweetElement }) => findMediaCount({ tweetElement }), link: ({ tweetElement }) => 'https://twitter.com' + tweetElement.querySelector('a[href*="/status/"]').getAttribute('href') }; /** * User-defined template for formatting tweet data. */ const UserTemplate = ENV.USER_TEMPLATE || [ `{{username}} ({{userId}})`, ``, `{{tweetText}}`, ``, `{{mediaCount}}`, ``, `{{link}}` ].join('\n') /** * Adds a copy button to a tweet element. * @param {Object} param - Object containing the tweet element. * @param {Element} param.tweetElement - The tweet element. */ function addCopyButtonToTweet({ tweetElement }) { if (tweetElement.querySelector('.copy-tweet-button')) { return; } let copyButton = document.createElement('button'); copyButton.className = 'copy-tweet-button'; handleTempStyleStatusPage({ copyButton }); copyButton.innerHTML = /*html*/` <span data-text-initial="Copy to clipboard" data-text-end="Copied" data-text-failed="Copy failed, open the console for details!" class="tooltip"></span> <span> <svg xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 6.35 6.35" y="0" x="0" height="14" width="14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg" class="clipboard"> <g> <path fill="currentColor" d="M2.43.265c-.3 0-.548.236-.573.53h-.328a.74.74 0 0 0-.735.734v3.822a.74.74 0 0 0 .735.734H4.82a.74.74 0 0 0 .735-.734V1.529a.74.74 0 0 0-.735-.735h-.328a.58.58 0 0 0-.573-.53zm0 .529h1.49c.032 0 .049.017.049.049v.431c0 .032-.017.049-.049.049H2.43c-.032 0-.05-.017-.05-.049V.843c0-.032.018-.05.05-.05zm-.901.53h.328c.026.292.274.528.573.528h1.49a.58.58 0 0 0 .573-.529h.328a.2.2 0 0 1 .206.206v3.822a.2.2 0 0 1-.206.205H1.53a.2.2 0 0 1-.206-.205V1.529a.2.2 0 0 1 .206-.206z"> </path> </g> </svg> <svg xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 24 24" y="0" x="0" height="14" width="14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg" class="checkmark"> <g> <path data-original="#000000" fill="currentColor" d="M9.707 19.121a.997.997 0 0 1-1.414 0l-5.646-5.647a1.5 1.5 0 0 1 0-2.121l.707-.707a1.5 1.5 0 0 1 2.121 0L9 14.171l9.525-9.525a1.5 1.5 0 0 1 2.121 0l.707.707a1.5 1.5 0 0 1 0 2.121z"> </path> </g> </svg> <svg class="failedmark" xmlns="http://www.w3.org/2000/svg" height="14" width="14" viewBox="0 0 512 512"> <path fill="#FF473E" d="m330.443 256l136.765-136.765c14.058-14.058 14.058-36.85 0-50.908l-23.535-23.535c-14.058-14.058-36.85-14.058-50.908 0L256 181.557L119.235 44.792c-14.058-14.058-36.85-14.058-50.908 0L44.792 68.327c-14.058 14.058-14.058 36.85 0 50.908L181.557 256L44.792 392.765c-14.058 14.058-14.058 36.85 0 50.908l23.535 23.535c14.058 14.058 36.85 14.058 50.908 0L256 330.443l136.765 136.765c14.058 14.058 36.85 14.058 50.908 0l23.535-23.535c14.058-14.058 14.058-36.85 0-50.908z" /> </svg> </span> ` let nameElement = tweetElement.querySelector('[data-testid="User-Name"]'); if (nameElement) { nameElement.appendChild(copyButton); } copyButton.addEventListener('click', e => handleTweetCopyClick({ e, tweetElement })); } /** * Handles the copy button click event. * @param {Object} param - Object containing the event and tweet element. * @param {Event} param.e - The click event. * @param {Element} param.tweetElement - The tweet element. */ function handleTweetCopyClick({ e, tweetElement }) { e.stopPropagation(); try { let text = formatTweet({ tweetElement }); copyTextToClipboard({ tweetElement, text }); } catch (error) { handleCopyError({ tweetElement, error }) } } /** * handles copy error * @param {Element} param.tweetElement - The tweet element. * @param {Error} param.error - The error object. */ function handleCopyError({ tweetElement, error = new Error() }) { const copyTweetButton = tweetElement.querySelector('.copy-tweet-button'); copyTweetButton.classList.add('copy-failed') console.error('Could not copy tweet: ', error); } /** * Finds the user name from a tweet element. * @param {Object} param - Object containing the tweet element. * @param {Element} param.tweetElement - The tweet element. * @returns {string} User Name. */ function findUserName({ tweetElement }) { let username = tweetElement.querySelector('div[data-testid="User-Name"]').firstChild; if (!username) { return ''; } let clone = username.cloneNode(true); clone.querySelectorAll('img').forEach(img => { let altText = img.alt || ''; let textNode = document.createTextNode(altText); img.parentNode.replaceChild(textNode, img); }); return clone.textContent; } /** * Finds the user ID from a tweet element. * @param {Object} param - Object containing the tweet element. * @param {Element} param.tweetElement - The tweet element. * @returns {string} User ID. */ function findUserID({ tweetElement }) { let links = tweetElement.querySelectorAll('a[href^="/"][role="link"][tabindex="-1"]'); let userIDElement = links[links.length - 1]; return userIDElement ? userIDElement.textContent : ''; } /** * @param {string} tweetText * @param {number} count max line breaks * @returns {string} tweetText */ function limitLineBreaks(tweetText, count) { const lineCount = (tweetText.match(/\n/g) || []).length; if (lineCount > count) { const limitedText = tweetText.split('\n').slice(0, count).join('\n'); return limitedText + '...\n'; } return tweetText } /** * Calculate the length of the tweet character, and use an ellipsis to represent the overflow content. * @param {string} tweetText * @returns {string} tweetText */ function handleTweetText(tweetText) { /** * Counting characters Rule reference the following link, this is just a simple implementation * Counting characters | Docs | Twitter Developer Platform * https://developer.twitter.com/en/docs/counting-characters */ const MAX_TWEET_CHARACTERS = 280; const CharacterConfig = { urls: { pattern: /https?:\/\/[^\s]+/g, charActerLength: 23 }, emojis: { pattern: /[\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu, charActerLength: 2 }, cjk: { pattern: /[\u{4E00}-\u{9FFF}\u{3400}-\u{4DBF}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B73F}\u{2B740}-\u{2B81F}\u{2B820}-\u{2CEAF}\u{F900}-\u{FAFF}\u{2F800}-\u{2FA1F}\u{AC00}-\u{D7AF}\u{1100}-\u{11FF}]/gu, charActerLength: 2 }, mentions: { pattern: /@\w+/g, charActerLength: 0 } }; const MAX_LINE_BREAK = 10; let characterCounts = {} Object.keys(CharacterConfig).forEach(key => { characterCounts[key] = 0 }) // Normalize the text tweetText = tweetText.normalize('NFC'); let characterLength = 0; let tweetIndex = 0; let mask = {}; while (tweetIndex < tweetText.length) { let matched = false; // Check each character pattern for (const [key, config] of Object.entries(CharacterConfig)) { const regex = new RegExp(config.pattern); regex.lastIndex = tweetIndex; // Start matching from current index const match = regex.exec(tweetText); if (match && match.index === tweetIndex) { // Match must start at the current index characterCounts[key] += 1; matched = true; const matchLength = match[0].length; if (key === 'urls') { // For URLs, add the entire URL's length once characterLength += config.charActerLength; tweetIndex += matchLength - 1; // Move index to the end of the URL } else { // For other patterns, add length per matched character characterLength += matchLength * config.charActerLength; } break; // Stop checking other patterns once matched } } if (!matched) { // If no special characters matched, count the current character as one characterLength += 1; } tweetIndex++; // Move to the next character } if (ENV.MODE !== 'PROD') { console.log({ characterLength, tweetText_length: tweetText.length, characterCounts }); } // Check if the length exceeds the limit and trim if necessary if (characterLength > MAX_TWEET_CHARACTERS) { tweetText = tweetText.substring(0, MAX_TWEET_CHARACTERS) + '...'; // Truncate and add ellipsis } else { tweetText = limitLineBreaks(tweetText, MAX_LINE_BREAK) } return tweetText; } /** * Finds the tweetText from a tweet element. * @param {Object} param - Object containing the tweet element. * @param {Element} param.tweetElement - The tweet element. * @returns {string} TweetText */ function findTweetText({ tweetElement }) { const tweetTextDOM = tweetElement.querySelector('div[data-testid="tweetText"]'); if (!tweetTextDOM) { return ''; } let clone = tweetTextDOM.cloneNode(true); clone.innerHTML = tweetTextDOM.innerHTML.replace('><br><font', '>\n\n<font'); // Support copy the emoji, There are system compatibility issues that cannot be fully resolved. // Consider using a third-party emoji library to solve this problem. clone.querySelectorAll('img').forEach(img => { let altText = img.alt || ''; let textNode = document.createTextNode(altText); img.parentNode.replaceChild(textNode, img); }); let tweetText = handleTweetText(clone.textContent); return tweetText; } /** * Finds the media count from a tweet element. * @param {Object} param - Object containing the tweet element. * @param {Element} param.tweetElement - The tweet element. * @returns {string} media count. */ function findMediaCount({ tweetElement }) { let cameraEmoji = '\u{1F4F7}'; // FIXME: bad design, this a build-in template, but need to number of judgments. // === const tweetPhoto = tweetElement.querySelectorAll('div[data-testid="tweetPhoto"]')?.length || 0; const cardLink = tweetElement.querySelectorAll('div[data-testid^="card"] a[href*="https://t.co"]')?.length || 0; const mediaCount = tweetPhoto + cardLink; let mediaWord = 'media' if (!mediaCount) { return ''; } if (mediaCount > 1) { mediaWord += 's' } return `${cameraEmoji} ${mediaCount} ${mediaWord}`; // === } /** * Formats the tweet data according to the user-defined template. * @param {Object} param - Object containing the tweet element. * @param {Element} param.tweetElement - The tweet element. * @returns {string} Formatted tweet data. */ function formatTweet({ tweetElement }) { let formatted = UserTemplate.replace(/\\{{/g, '{').replace(/\\}}/g, '}'); formatted = formatted.replace(/{{(\w+)}}/g, (match, key) => { if (tweetDataExtractors[key]) { return tweetDataExtractors[key]({ tweetElement }); } return match; }); formatted = formatted.replaceAll(/\n\n\n/gi, '\n') return formatted; } /** * Copies text to the clipboard. * @param {Object} param - Object containing the tweet element. * @param {Element} param.tweetElement - The tweet element. * @param {string} param.text - Text to be copied. */ function copyTextToClipboard({ tweetElement, text }) { navigator.clipboard.writeText(text).then(function () { console.log('Tweet copied to clipboard'); console.log(text); console.log('==='); }).catch(function (error) { handleCopyError({ tweetElement, error }) }); } // FIXME Temporary solution to solve the problem of /status/ web style misalignment const handleTempStyleStatusPage = ({ copyButton }) => { let executed = false; if (window.location.href.indexOf('/status/') > 0 && !executed) { executed = true; const tempStyle = /*css*/` position: absolute; top: -2px; right: calc(-1 * (var(--button-diameter) + 8px)); `; copyButton.setAttribute('style', tempStyle); } } /** * Observes DOM mutations to add a copy button to new tweets. */ let observer = new MutationObserver(function (mutations) { let articles = document.querySelectorAll('article[role="article"]'); articles.forEach(function (tweetElement) { if (!tweetElement.querySelector('.copy-tweet-button')) { addCopyButtonToTweet({ tweetElement }); } }); }); // Start observing observer.observe(document.body, { childList: true, subtree: true }); })();