V2EX Used Code Striker++

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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.');

})();