抖音复制文案

在日期旁边添加复制按钮,用于复制指定区域内所有span的文本内容。对抖音网站类名变化适应性更强,并尝试处理多个目标区域 (videoSideBar, video-info-wrap)。

目前為 2025-05-21 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         抖音复制文案
// @namespace    http://tampermonkey.net/
// @version      0.7
// @description  在日期旁边添加复制按钮,用于复制指定区域内所有span的文本内容。对抖音网站类名变化适应性更强,并尝试处理多个目标区域 (videoSideBar, video-info-wrap)。
// @author       cores
// @match        https://www.douyin.com/*
// @license MIT
// @grant        GM_setClipboard
// @grant        GM_addStyle
// ==/UserScript==

(function() {
    'use_strict';

    // --- 配置项 ---
    const TARGET_CONTAINER_IDS = ['videoSideBar', 'video-info-wrap']; // 目标总容器的ID列表
    const BR_MARKER = '%%BR_MARKER%%'; // 用于临时替换<br>的标记

    const COPY_ICON_SVG = `
        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle;">
            <path d="M0 0h24v24H0z" fill="none"/>
            <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
        </svg>
    `; // 复制图标的SVG代码

    // --- 辅助函数 ---

    function createCopyIconElement() {
        const iconContainer = document.createElement('span');
        iconContainer.innerHTML = COPY_ICON_SVG;
        iconContainer.style.cursor = 'pointer';
        iconContainer.style.marginLeft = '8px';
        iconContainer.style.display = 'inline-flex';
        iconContainer.style.alignItems = 'center';
        iconContainer.setAttribute('title', '复制内容到剪贴板');
        iconContainer.classList.add('gm-copy-icon-container');
        return iconContainer;
    }

    function showNotification(message) {
        const existingNotification = document.querySelector('.gm-copy-notification');
        if (existingNotification) {
            existingNotification.remove();
        }
        const notification = document.createElement('div');
        notification.textContent = message;
        GM_addStyle(`
            .gm-copy-notification {
                position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
                background-color: #323232; color: white; padding: 10px 20px;
                border-radius: 5px; z-index: 2147483647; font-size: 14px;
                box-shadow: 0 2px 10px rgba(0,0,0,0.2); opacity: 0;
                transition: opacity 0.3s ease-in-out;
            }
            .gm-copy-notification.show { opacity: 1; }
        `);
        notification.className = 'gm-copy-notification';
        document.body.appendChild(notification);
        setTimeout(() => notification.classList.add('show'), 10);
        setTimeout(() => {
            notification.classList.remove('show');
            setTimeout(() => {
                if (document.body.contains(notification)) {
                    document.body.removeChild(notification);
                }
            }, 300);
        }, 2500);
    }

    function extractTextWithLineBreaks(element) {
        if (!element) return '';
        const clonedElement = element.cloneNode(true);
        const spansInClone = clonedElement.querySelectorAll('span');
        spansInClone.forEach(span => {
            const brTags = span.querySelectorAll('br');
            brTags.forEach(br => {
                if (br.parentNode) {
                    br.parentNode.replaceChild(document.createTextNode(BR_MARKER), br);
                }
            });
        });
        let rawContentFromSpans = '';
        const walker = document.createTreeWalker(clonedElement, NodeFilter.SHOW_TEXT, null, false);
        let node;
        while (node = walker.nextNode()) {
            let parent = node.parentNode;
            let isInsideSpan = false;
            while (parent && parent !== clonedElement.parentNode && parent !== clonedElement) {
                if (parent.nodeName === 'SPAN') {
                    isInsideSpan = true;
                    break;
                }
                parent = parent.parentNode;
            }
            if (isInsideSpan) {
                rawContentFromSpans += node.nodeValue;
            }
        }
        let textWithNewlines = rawContentFromSpans.replace(new RegExp(BR_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '\n');
        const lines = textWithNewlines.split('\n');
        const processedLines = lines.map(line => line.trim().replace(/\s+/g, ' '));
        let finalText = processedLines.join('\n');
        finalText = finalText.replace(/\n{3,}/g, '\n\n');
        return finalText.trim();
    }

    /**
     * Checks if a given element is likely a date span.
     * @param {HTMLElement} element - The element to check.
     * @returns {boolean} True if it's likely a date span, false otherwise.
     */
    function isLikelyDate(element) {
        if (!element || element.tagName !== 'SPAN') return false;

        const textContent = element.textContent ? element.textContent.trim() : '';
        if (textContent.length >= 60 || textContent.length < 2) return false; // e.g. "刚刚" is 2 chars
        if (textContent.startsWith('@')) return false;

        // Heuristic: Prefer spans that directly hold the date text or have minimal simple children.
        if (element.children.length > 1) {
            let simpleChildren = true;
            for (const child of element.children) {
                if (child.tagName !== 'SPAN' || child.children.length > 0) { // Allow only simple span children
                    simpleChildren = false;
                    break;
                }
            }
            if (!simpleChildren) return false;
        }

        const hasDot = textContent.includes('·');
        const looksLikeTimeAgo = /(\d+\s*(分钟|小时|天|周|月|年)前|刚刚)/.test(textContent);
        const looksLikeSpecificDate = /\d{2,4}-\d{1,2}-\d{1,2}|\d+月\d+日/.test(textContent);
        const isGenerallyDateLike = textContent.includes('发布') || textContent.includes('投稿');

        if (hasDot && (looksLikeTimeAgo || looksLikeSpecificDate || textContent.includes("小时") || textContent.includes("发布"))) return true;
        if (looksLikeTimeAgo) return true;
        if (looksLikeSpecificDate && !textContent.includes('@') && textContent.length < 20) return true;
        if (isGenerallyDateLike && textContent.length < 20) return true;

        return false;
    }

    // --- 主要逻辑 ---
    function initializeScript() {
        for (const targetId of TARGET_CONTAINER_IDS) {
            const targetElements = document.querySelectorAll('#' + targetId);
            if (targetElements.length === 0) {
                // console.log(`Tampermonkey: No elements found for ID: ${targetId}`);
                continue;
            }

            for (const currentTargetContainer of targetElements) {
                let dateSpanElement = null;
                // Search for spans only within the currentTargetContainer
                const allSpansInThisContainer = Array.from(currentTargetContainer.querySelectorAll('span'));

                for (const potentialDateSpan of allSpansInThisContainer) {
                    if (potentialDateSpan.querySelector('.gm-copy-icon-container')) {
                        continue; // Already has an icon, skip
                    }

                    if (isLikelyDate(potentialDateSpan)) {
                        let commonAncestor = potentialDateSpan.parentElement;
                        let searchLevels = 0;

                        // Search upwards for a common ancestor that also contains a username
                        // Stop if commonAncestor becomes the parent of currentTargetContainer or body
                        while (commonAncestor && commonAncestor !== currentTargetContainer.parentElement && commonAncestor !== document.body && searchLevels < 5) {
                            let userNameNode = null;
                            // Look for username candidates within this common ancestor
                            const candidates = Array.from(commonAncestor.querySelectorAll('span, a, div'));
                            for (const el of candidates) {
                                const elTextContent = el.textContent ? el.textContent.trim() : '';
                                if (elTextContent.startsWith('@') && elTextContent.length > 1) { // Username must have text after @
                                    // Heuristic: username elements are usually not huge containers and text is not excessively long
                                    if (el.children.length < 3 && elTextContent.length < 100) {
                                         // Ensure this username is actually within the *currentTargetContainer* context
                                        if (currentTargetContainer.contains(el)) {
                                            userNameNode = el;
                                            break; // Found a plausible username node
                                        }
                                    }
                                }
                            }

                            if (userNameNode) {
                                // Ensure userNameNode is distinct from potentialDateSpan and they don't contain each other
                                if (userNameNode !== potentialDateSpan && !userNameNode.contains(potentialDateSpan) && !potentialDateSpan.contains(userNameNode)) {
                                    // Check if both potentialDateSpan and userNameNode are relatively "close" descendants
                                    // of the current commonAncestor.
                                    let pDate = potentialDateSpan, pUser = userNameNode;
                                    let depthDate = 0, depthUser = 0;

                                    while (pDate && pDate !== commonAncestor && depthDate < 4) { // Max depth 3 from commonAncestor
                                        pDate = pDate.parentElement;
                                        depthDate++;
                                    }
                                    while (pUser && pUser !== commonAncestor && depthUser < 4) { // Max depth 3 from commonAncestor
                                        pUser = pUser.parentElement;
                                        depthUser++;
                                    }

                                    // If both are valid descendants and relatively shallow under commonAncestor
                                    if (pDate === commonAncestor && pUser === commonAncestor) {
                                        dateSpanElement = potentialDateSpan;
                                        break; // Break from while (commonAncestor loop)
                                    }
                                }
                            }
                            if (dateSpanElement) break; // Found date for this common ancestor search
                            commonAncestor = commonAncestor.parentElement;
                            searchLevels++;
                        } // End while commonAncestor

                        if (dateSpanElement) {
                            break; // Break from for (potentialDateSpan loop) as we found our target for this container
                        }
                    } // End if isLikelyDate
                } // End for potentialDateSpan in this container

                if (!dateSpanElement) {
                    // console.log(`Tampermonkey: Suitable date span with username context not found in a container with ID ${targetId}:`, currentTargetContainer);
                    continue; // Move to the next targetElement if multiple share the same ID
                }

                // Double check: ensure no icon was somehow added by another mutation pass
                if (dateSpanElement.querySelector('.gm-copy-icon-container')) {
                    // console.log('Tampermonkey: Icon already exists on target, aborting add for this instance.');
                    continue; // Move to the next targetElement
                }

                const copyIconElement = createCopyIconElement();
                // Capture the specific currentTargetContainer for this event listener
                const containerForEvent = currentTargetContainer;
                copyIconElement.addEventListener('click', (event) => {
                    event.stopPropagation();
                    event.preventDefault();
                    const textToCopy = extractTextWithLineBreaks(containerForEvent); // Use the correct container instance
                    if (textToCopy) {
                        GM_setClipboard(textToCopy, 'text');
                        showNotification('内容已复制到剪贴板!');
                    } else {
                        showNotification('未找到可复制的文字内容。');
                    }
                });

                dateSpanElement.style.display = 'inline-flex';
                dateSpanElement.style.alignItems = 'center';
                dateSpanElement.appendChild(copyIconElement);
                // console.log('Tampermonkey: Copy icon added to:', dateSpanElement.textContent, 'in container:', containerForEvent);
            } // End for each currentTargetContainer
        } // End for each targetId
    }

    // --- 脚本执行 ---
    const observer = new MutationObserver((mutationsList, observerInstance) => {
        // On any DOM change, re-initialize the script to find all target containers.
        // initializeScript itself will iterate through all found containers and IDs.
        initializeScript();
    });

    function observeTarget() {
        // console.log('Tampermonkey: Initializing and observing document body.');
        initializeScript(); // Initial attempt
        // Observe the entire document body for any changes.
        observer.observe(document.body, { childList: true, subtree: true });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', observeTarget);
    } else {
        observeTarget();
    }

})();