// ==UserScript== // @name Twitter/X Emoji Fix // @icon https://twitter.com/favicon.ico // @namespace https://github.com/1zero224 // @version 1.0 // @description Replace Twitter web's image emoji with native emoji for easier tweet content copying // @description:zh-CN 将 Twitter 网页端的图片 emoji 替换为原生 emoji,方便复制推文内容 // @author 1zero // @include https://x.com/* // @include https://twitter.com/* // @include https://mobile.twitter.com/* // @license MIT // @grant none // @run-at document-end // @downloadURL none // ==/UserScript== (function() { 'use strict'; /** * 从 Twitter emoji SVG URL 中提取 Unicode 码点并转换为 emoji 字符 * @param {string} src - SVG 图片的 URL * @returns {string|null} - 转换后的 emoji 字符,失败返回 null */ function extractEmojiFromUrl(src) { // 匹配类似 "/emoji/v2/svg/1f3ac.svg" 的 URL const match = src.match(/\/emoji\/v2\/svg\/([0-9a-f-]+)\.svg/i); if (!match) return null; const codePoints = match[1].split('-'); const emojiChar = String.fromCodePoint( ...codePoints.map(cp => parseInt(cp, 16)) ); return emojiChar; } /** * 替换单个 emoji 图片元素 * @param {HTMLImageElement} img - emoji 图片元素 */ function replaceEmojiImage(img) { // 检查是否已经处理过 if (img.dataset.emojiReplaced) return; const src = img.src || img.getAttribute('src'); if (!src) return; const emoji = extractEmojiFromUrl(src); if (!emoji) return; // 创建 span 元素替代 img const span = document.createElement('span'); span.textContent = emoji; span.className = img.className; span.style.fontSize = 'inherit'; span.style.lineHeight = 'inherit'; span.style.verticalAlign = 'baseline'; span.title = img.title || img.alt; span.dataset.originalEmoji = emoji; // 替换元素 img.replaceWith(span); } /** * 扫描并替换页面中所有的 emoji 图片 */ function replaceAllEmojis() { // 查找所有 Twitter emoji 图片 const emojiImages = document.querySelectorAll('img[src*="/emoji/v2/svg/"]'); emojiImages.forEach(img => replaceEmojiImage(img)); } /** * 使用 MutationObserver 监听 DOM 变化 * Twitter 使用 SPA 架构,内容动态加载 */ function observeDOMChanges() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // 检查新增节点本身 if (node.tagName === 'IMG' && node.src && node.src.includes('/emoji/v2/svg/')) { replaceEmojiImage(node); } // 检查新增节点内的 emoji 图片 const imgs = node.querySelectorAll ? node.querySelectorAll('img[src*="/emoji/v2/svg/"]') : []; imgs.forEach(img => replaceEmojiImage(img)); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); return observer; } // 初始化:替换当前页面的 emoji replaceAllEmojis(); // 启动监听器 observeDOMChanges(); // 使用防抖处理滚动和其他事件 let debounceTimer; function debouncedReplace() { clearTimeout(debounceTimer); debounceTimer = setTimeout(replaceAllEmojis, 500); } // 监听滚动事件(懒加载内容) window.addEventListener('scroll', debouncedReplace, { passive: true }); })();