V2EX Used Code Striker++

在 V2EX 送码帖中,根据被评论区用户领取的激活码/邀请码,自动划掉主楼/附言中被提及的 Code。

目前為 2025-04-23 提交的版本,檢視 最新版本

// ==UserScript==
// @name         V2EX Used Code Striker++
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  在 V2EX 送码帖中,根据被评论区用户领取的激活码/邀请码,自动划掉主楼/附言中被提及的 Code。
// @author       与Gemini协作完成
// @match        https://www.v2ex.com/t/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=v2ex.com
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const STORAGE_KEY_KEYWORDS = 'v2ex_used_code_striker_keywords';
    const STORAGE_KEY_SHOW_USER = 'v2ex_used_code_striker_show_user';
    const defaultUsedKeywords = ['用', 'used', 'taken', '领', 'redeem', 'thx', '感谢'];

    // --- Load Settings ---
    const savedKeywordsString = GM_getValue(STORAGE_KEY_KEYWORDS, defaultUsedKeywords.join(','));
    const showUserInfoEnabled = GM_getValue(STORAGE_KEY_SHOW_USER, true); // Default to true (show user)

    let activeUsedKeywords = [];
    if (savedKeywordsString && savedKeywordsString.trim() !== '') {
        activeUsedKeywords = savedKeywordsString.split(',').map(kw => kw.trim()).filter(Boolean);
    }

    console.log('V2EX Used Code Striker: Active keywords:', activeUsedKeywords.length > 0 ? activeUsedKeywords : '(None - All comment codes considered used)');
    console.log('V2EX Used Code Striker: Show Username:', showUserInfoEnabled);

    // --- Regex & Style Setup ---
    const codeRegex = /(?:[A-Z0-9][-_]?){6,}/gi;
    const usedStyle = 'text-decoration: line-through; color: grey;';
    const userInfoStyle = 'font-size: smaller; margin-left: 5px; color: #999; text-decoration: none;'; // Style for the user link
    const markedClass = 'v2ex-used-code-marked'; // Class for the strikethrough span
    const userInfoClass = 'v2ex-code-claimant'; // Class for the user link anchor

    let keywordRegexCombinedTest = (text) => false; // Default test function

    // Build keyword regex only if there are active keywords
    if (activeUsedKeywords.length > 0) {
        const wordCharRegex = /^[a-zA-Z0-9_]+$/;
        const englishKeywords = activeUsedKeywords.filter(kw => wordCharRegex.test(kw));
        const nonWordBoundaryKeywords = activeUsedKeywords.filter(kw => !wordCharRegex.test(kw));
        const regexParts = [];

        if (englishKeywords.length > 0) {
            const englishPattern = `\\b(${englishKeywords.join('|')})\\b`;
            const englishRegex = new RegExp(englishPattern, 'i');
            regexParts.push((text) => englishRegex.test(text));
            // console.log("V2EX Used Code Striker: English Keyword Regex:", englishRegex);
        }

        if (nonWordBoundaryKeywords.length > 0) {
            const escapedNonWordKeywords = nonWordBoundaryKeywords.map(kw =>
                kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
            );
            const nonWordPattern = `(${escapedNonWordKeywords.join('|')})`;
            const nonWordRegex = new RegExp(nonWordPattern, 'i');
            regexParts.push((text) => nonWordRegex.test(text));
            // console.log("V2EX Used Code Striker: Non-Word-Boundary Keyword Regex:", nonWordRegex);
        }

        if (regexParts.length > 0) {
            keywordRegexCombinedTest = (text) => {
                for (const testFn of regexParts) {
                    if (testFn(text)) return true;
                }
                return false;
            };
        }
    }

    // --- Menu Commands ---
    GM_registerMenuCommand('配置 V2EX 划掉 Code 关键词', () => {
        const currentKeywords = GM_getValue(STORAGE_KEY_KEYWORDS, defaultUsedKeywords.join(','));
        const newKeywordsString = prompt(
            '请输入评论中表示Code已使用的关键词,用英文逗号 (,) 分隔。\n\n' +
            '留空则表示评论中出现的所有Code都会被认为已使用。\n\n' +
            '当前配置:',
            currentKeywords
        );

        if (newKeywordsString !== null) { // Prompt wasn't cancelled
            const cleanedKeywords = newKeywordsString.trim();
            GM_setValue(STORAGE_KEY_KEYWORDS, cleanedKeywords);
            alert(
                '关键词已更新。\n' +
                `新配置: ${cleanedKeywords || '(空 - 所有评论Code都将被标记)'}\n\n` +
                '请刷新页面以应用更改。'
            );
        }
    });

    GM_registerMenuCommand(`切换显示/隐藏使用者信息 (${showUserInfoEnabled ? '当前: 显示' : '当前: 隐藏'})`, () => {
        const currentState = GM_getValue(STORAGE_KEY_SHOW_USER, true);
        const newState = !currentState;
        GM_setValue(STORAGE_KEY_SHOW_USER, newState);
        alert(
            `使用者信息显示已切换为: ${newState ? '显示' : '隐藏'}\n\n` +
            '请刷新页面以应用更改。'
        );
    });


    // --- Helper Function: findTextNodes (Unchanged) ---
    function findTextNodes(element, textNodes) {
        if (!element) return;
        for (const node of element.childNodes) {
            if (node.nodeType === Node.TEXT_NODE) {
                if (node.nodeValue.trim().length > 0) {
                    textNodes.push(node);
                }
            } else if (node.nodeType === Node.ELEMENT_NODE) {
                // Avoid recursing into already marked spans or user links
                if (!(node.tagName === 'SPAN' && node.classList.contains(markedClass)) &&
                    !(node.tagName === 'A' && node.classList.contains(userInfoClass)))
                {
                   if (node.tagName !== 'A' && node.tagName !== 'CODE') { // Avoid recursing into normal links/code blocks? Check if needed.
                       findTextNodes(node, textNodes);
                   } else {
                       findTextNodes(node, textNodes); // Search inside A and CODE for text nodes too
                   }
                }
            }
        }
    }

    // --- Main Logic ---
    console.log('V2EX Used Code Striker: Script running...');

    // 1. Extract used Codes and Claimant Info from comments
    const claimedCodeInfo = new Map(); // Map<string, { username: string, profileUrl: string }>
    const commentElements = document.querySelectorAll('div.cell[id^="r_"]'); // Select the whole comment cell
    console.log(`V2EX Used Code Striker: Found ${commentElements.length} comment cells.`);

    const keywordsAreActive = activeUsedKeywords.length > 0;

    commentElements.forEach((commentCell, index) => {
        const replyContentEl = commentCell.querySelector('.reply_content');
        const userLinkEl = commentCell.querySelector('strong > a[href^="/member/"]');

        if (!replyContentEl || !userLinkEl) {
            // console.warn(`V2EX Used Code Striker: Skipping comment cell ${index + 1}, missing content or user link.`);
            return; // Skip if structure is unexpected
        }

        const commentText = replyContentEl.textContent;
        const username = userLinkEl.textContent;
        const profileUrl = userLinkEl.href;

        const potentialCodes = commentText.match(codeRegex);

        if (potentialCodes) {
            let commentMatchesCriteria = false;
            if (!keywordsAreActive) {
                // Setting is empty: consider all codes in comments as used
                commentMatchesCriteria = true;
            } else {
                // Keywords are defined: check if comment contains keywords
                if (keywordRegexCombinedTest(commentText)) {
                    commentMatchesCriteria = true;
                }
            }

            if (commentMatchesCriteria) {
                potentialCodes.forEach(code => {
                    const codeUpper = code.toUpperCase();
                    // Only store the *first* user claiming a specific code
                    if (!claimedCodeInfo.has(codeUpper)) {
                        console.log(`V2EX Used Code Striker: Found potential used code "${code}" by user "${username}" in comment ${index + 1}`);
                        claimedCodeInfo.set(codeUpper, { username, profileUrl });
                    }
                });
            }
        }
    });

    console.log(`V2EX Used Code Striker: Extracted info for ${claimedCodeInfo.size} unique potential used codes based on config:`, claimedCodeInfo);

    if (claimedCodeInfo.size === 0) {
        console.log('V2EX Used Code Striker: No potential used codes found in comments matching criteria. Exiting.');
        return;
    }

    // 2. Find and mark Codes in main post and supplements
    const contentAreas = [
        document.querySelector('.topic_content'),          // Main post content
        ...document.querySelectorAll('.subtle .topic_content') // Supplement content (inside .markdown_body)
    ].filter(el => el); // Filter out nulls if no supplements

    console.log(`V2EX Used Code Striker: Found ${contentAreas.length} content areas to scan.`);

    contentAreas.forEach((area, areaIndex) => {
        const textNodes = [];
        findTextNodes(area, textNodes);

        textNodes.forEach(node => {
            // Check if the node is already inside a marked element (double check)
            if (node.parentNode && (node.parentNode.classList.contains(markedClass) || node.parentNode.classList.contains(userInfoClass))) {
                return;
            }

            const nodeText = node.nodeValue;
            let match;
            let lastIndex = 0;
            const newNodeContainer = document.createDocumentFragment();
            const regex = new RegExp(codeRegex.source, 'gi'); // Create new regex instance for each node
            regex.lastIndex = 0; // Reset lastIndex

            while ((match = regex.exec(nodeText)) !== null) {
                const matchedCode = match[0];
                const matchedCodeUpper = matchedCode.toUpperCase();

                if (claimedCodeInfo.has(matchedCodeUpper)) {
                    const claimInfo = claimedCodeInfo.get(matchedCodeUpper);

                    // Add text before the match
                    if (match.index > lastIndex) {
                        newNodeContainer.appendChild(document.createTextNode(nodeText.substring(lastIndex, match.index)));
                    }

                    // Create the strikethrough span for the code
                    const span = document.createElement('span');
                    span.textContent = matchedCode;
                    span.style.cssText = usedStyle;
                    span.title = `Code "${matchedCode}" likely used by ${claimInfo.username}`;
                    span.classList.add(markedClass);
                    newNodeContainer.appendChild(span);

                    // Optionally, add the user info link
                    if (showUserInfoEnabled && claimInfo) {
                        const userLink = document.createElement('a');
                        userLink.href = claimInfo.profileUrl;
                        userLink.textContent = ` (@${claimInfo.username})`;
                        userLink.style.cssText = userInfoStyle;
                        userLink.classList.add(userInfoClass);
                        userLink.target = '_blank'; // Open in new tab
                        userLink.title = `View profile of ${claimInfo.username}`;
                        newNodeContainer.appendChild(userLink);
                    }

                    lastIndex = regex.lastIndex;

                } else {
                   // If code is not in the claimed map, ensure loop continues correctly.
                   // regex.lastIndex is automatically advanced by exec().
                }
            }

            // Add any remaining text after the last match (or the whole text if no matches)
            if (lastIndex < nodeText.length) {
                newNodeContainer.appendChild(document.createTextNode(nodeText.substring(lastIndex)));
            }

            // Replace the original text node only if modifications were made
            if (newNodeContainer.hasChildNodes() && lastIndex > 0) { // lastIndex > 0 implies at least one match was processed
                node.parentNode.replaceChild(newNodeContainer, node);
            }
        });
    });

    console.log('V2EX Used Code Striker: Script finished.');

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址