V2EX Used Code Striker++

在 V2EX 送码帖中,根据评论和配置,自动划掉主楼/附言中被提及的 Code,并可选显示领取者。通过设置界面配置。

当前为 2025-04-23 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name V2EX Used Code Striker++
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.4.2
  5. // @description 在 V2EX 送码帖中,根据评论和配置,自动划掉主楼/附言中被提及的 Code,并可选显示领取者。通过设置界面配置。
  6. // @author 与Gemini协作完成 (Based on iblogc's work)
  7. // @match https://www.v2ex.com/t/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=v2ex.com
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @grant GM_registerMenuCommand
  12. // @grant GM_addStyle
  13. // @license MIT
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. // --- Storage Keys ---
  20. const STORAGE_KEY_TITLE_KEYWORDS = 'v2ex_striker_title_keywords';
  21. const STORAGE_KEY_COMMENT_KEYWORDS = 'v2ex_striker_comment_keywords';
  22. const STORAGE_KEY_SHOW_USER = 'v2ex_striker_show_user';
  23. const MODAL_ID = 'v2ex-striker-settings-modal';
  24. const OVERLAY_ID = 'v2ex-striker-settings-overlay';
  25.  
  26. // --- Default Settings ---
  27. const defaultTitleKeywords = ['送', '发', '福利', '邀请码', '激活码', '码', 'giveaway', 'invite', 'code'];
  28. const defaultCommentKeywords = ['用', 'used', 'taken', '领', 'redeem', 'thx', '感谢'];
  29. const defaultShowUserInfo = true;
  30.  
  31. // --- Load Settings ---
  32. const titleKeywordsString = GM_getValue(STORAGE_KEY_TITLE_KEYWORDS, defaultTitleKeywords.join(','));
  33. const commentKeywordsString = GM_getValue(STORAGE_KEY_COMMENT_KEYWORDS, defaultCommentKeywords.join(','));
  34. const showUserInfoEnabled = GM_getValue(STORAGE_KEY_SHOW_USER, defaultShowUserInfo);
  35.  
  36. let activeTitleKeywords = [];
  37. if (titleKeywordsString && titleKeywordsString.trim() !== '') {
  38. activeTitleKeywords = titleKeywordsString.split(',').map(kw => kw.trim().toLowerCase()).filter(Boolean);
  39. }
  40.  
  41. let activeCommentKeywords = [];
  42. if (commentKeywordsString && commentKeywordsString.trim() !== '') {
  43. activeCommentKeywords = commentKeywordsString.split(',').map(kw => kw.trim()).filter(Boolean);
  44. }
  45.  
  46. // --- Settings Modal Functions (Define early) ---
  47. function buildSettingsModal() {
  48. // (Modal building code remains the same as previous version)
  49. // Remove existing modal if any
  50. document.getElementById(MODAL_ID)?.remove();
  51. document.getElementById(OVERLAY_ID)?.remove();
  52.  
  53. // Overlay
  54. const overlay = document.createElement('div');
  55. overlay.id = OVERLAY_ID;
  56. overlay.onclick = closeSettingsModal;
  57.  
  58. // Modal Container
  59. const modal = document.createElement('div');
  60. modal.id = MODAL_ID;
  61.  
  62. // Title
  63. const title = document.createElement('h2');
  64. title.textContent = 'V2EX Used Code Striker 设置';
  65. modal.appendChild(title);
  66.  
  67. // 1. Title Keywords
  68. const titleDiv = document.createElement('div');
  69. titleDiv.className = 'setting-item';
  70. const titleLabel = document.createElement('label');
  71. titleLabel.textContent = '帖子标题关键词 (英文逗号分隔):';
  72. titleLabel.htmlFor = 'v2ex-striker-title-keywords-input';
  73. const titleInput = document.createElement('textarea');
  74. titleInput.id = 'v2ex-striker-title-keywords-input';
  75. titleInput.value = GM_getValue(STORAGE_KEY_TITLE_KEYWORDS, defaultTitleKeywords.join(','));
  76. titleInput.rows = 2;
  77. titleDiv.appendChild(titleLabel);
  78. titleDiv.appendChild(titleInput);
  79. modal.appendChild(titleDiv);
  80. const titleDesc = document.createElement('p');
  81. titleDesc.className = 'setting-desc';
  82. titleDesc.textContent = '包含这些词的帖子标题才会激活脚本。留空则不根据标题判断(不推荐)。';
  83. modal.appendChild(titleDesc);
  84.  
  85. // 2. Comment Keywords
  86. const commentDiv = document.createElement('div');
  87. commentDiv.className = 'setting-item';
  88. const commentLabel = document.createElement('label');
  89. commentLabel.textContent = '评论区关键词 (英文逗号分隔):';
  90. commentLabel.htmlFor = 'v2ex-striker-comment-keywords-input';
  91. const commentInput = document.createElement('textarea');
  92. commentInput.id = 'v2ex-striker-comment-keywords-input';
  93. commentInput.value = GM_getValue(STORAGE_KEY_COMMENT_KEYWORDS, defaultCommentKeywords.join(','));
  94. commentInput.rows = 3;
  95. commentDiv.appendChild(commentLabel);
  96. commentDiv.appendChild(commentInput);
  97. modal.appendChild(commentDiv);
  98. const commentDesc = document.createElement('p');
  99. commentDesc.className = 'setting-desc';
  100. commentDesc.textContent = '评论需包含这些词才会标记对应 Code。留空则评论中所有 Code 都被标记。';
  101. modal.appendChild(commentDesc);
  102.  
  103. // 3. Show User Info
  104. const showUserDiv = document.createElement('div');
  105. showUserDiv.className = 'setting-item setting-item-checkbox';
  106. const showUserInput = document.createElement('input');
  107. showUserInput.type = 'checkbox';
  108. showUserInput.id = 'v2ex-striker-show-user-input';
  109. showUserInput.checked = GM_getValue(STORAGE_KEY_SHOW_USER, defaultShowUserInfo);
  110. const showUserLabel = document.createElement('label');
  111. showUserLabel.textContent = '在划掉的 Code 旁显示使用者信息';
  112. showUserLabel.htmlFor = 'v2ex-striker-show-user-input';
  113. showUserDiv.appendChild(showUserInput);
  114. showUserDiv.appendChild(showUserLabel);
  115. modal.appendChild(showUserDiv);
  116.  
  117. // Action Buttons
  118. const buttonDiv = document.createElement('div');
  119. buttonDiv.className = 'setting-actions';
  120. const saveButton = document.createElement('button');
  121. saveButton.textContent = '保存设置';
  122. saveButton.onclick = saveSettings;
  123. const cancelButton = document.createElement('button');
  124. cancelButton.textContent = '取消';
  125. cancelButton.className = 'cancel-button';
  126. cancelButton.onclick = closeSettingsModal;
  127. buttonDiv.appendChild(cancelButton);
  128. buttonDiv.appendChild(saveButton);
  129. modal.appendChild(buttonDiv);
  130.  
  131. document.body.appendChild(overlay);
  132. document.body.appendChild(modal);
  133. }
  134.  
  135. function closeSettingsModal() {
  136. document.getElementById(MODAL_ID)?.remove();
  137. document.getElementById(OVERLAY_ID)?.remove();
  138. }
  139.  
  140. function saveSettings() {
  141. const titleKeywords = document.getElementById('v2ex-striker-title-keywords-input').value.trim();
  142. const commentKeywords = document.getElementById('v2ex-striker-comment-keywords-input').value.trim();
  143. const showUser = document.getElementById('v2ex-striker-show-user-input').checked;
  144.  
  145. GM_setValue(STORAGE_KEY_TITLE_KEYWORDS, titleKeywords);
  146. GM_setValue(STORAGE_KEY_COMMENT_KEYWORDS, commentKeywords);
  147. GM_setValue(STORAGE_KEY_SHOW_USER, showUser);
  148.  
  149. closeSettingsModal();
  150. alert('设置已保存!\n请刷新页面以应用新的设置。');
  151. }
  152.  
  153. // --- Add Modal Styles (Always add styles) ---
  154. GM_addStyle(`
  155. #${OVERLAY_ID} { /* Styles remain the same */
  156. position: fixed; top: 0; left: 0; width: 100%; height: 100%;
  157. background-color: rgba(0, 0, 0, 0.4); z-index: 9998; backdrop-filter: blur(3px);
  158. }
  159. #${MODAL_ID} { /* Styles remain the same */
  160. position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
  161. width: 90%; max-width: 500px; background-color: #f9f9f9; border-radius: 12px;
  162. box-shadow: 0 5px 25px rgba(0, 0, 0, 0.15); padding: 25px 30px; z-index: 9999;
  163. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
  164. color: #333; box-sizing: border-box;
  165. }
  166. #${MODAL_ID} h2 { margin-top: 0; margin-bottom: 25px; font-size: 1.4em; font-weight: 600; text-align: center; color: #1d1d1f; }
  167. #${MODAL_ID} .setting-item { margin-bottom: 10px; }
  168. #${MODAL_ID} .setting-desc { font-size: 0.8em; color: #6e6e73; margin-top: 0px; margin-bottom: 15px; }
  169. #${MODAL_ID} label { display: block; margin-bottom: 5px; font-weight: 500; font-size: 0.95em; color: #333; }
  170. #${MODAL_ID} textarea {
  171. width: 100%; padding: 10px; border: 1px solid #d2d2d7; border-radius: 6px;
  172. font-size: 0.9em; box-sizing: border-box; resize: vertical; min-height: 40px; font-family: inherit;
  173. }
  174. #${MODAL_ID} textarea:focus { border-color: #007aff; outline: none; box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2); }
  175. #${MODAL_ID} .setting-item-checkbox { display: flex; align-items: center; margin-top: 20px; margin-bottom: 25px; }
  176. #${MODAL_ID} .setting-item-checkbox input[type="checkbox"] { margin-right: 10px; width: 16px; height: 16px; accent-color: #007aff; }
  177. #${MODAL_ID} .setting-item-checkbox label { margin-bottom: 0; font-weight: normal; }
  178. #${MODAL_ID} .setting-actions { margin-top: 25px; display: flex; justify-content: flex-end; gap: 10px; }
  179. #${MODAL_ID} button { padding: 10px 20px; border: none; border-radius: 6px; font-size: 0.95em; font-weight: 500; cursor: pointer; transition: background-color 0.2s ease; }
  180. #${MODAL_ID} button:last-child { background-color: #007aff; color: white; }
  181. #${MODAL_ID} button:last-child:hover { background-color: #005ecf; }
  182. #${MODAL_ID} button.cancel-button { background-color: #e5e5ea; color: #1d1d1f; }
  183. #${MODAL_ID} button.cancel-button:hover { background-color: #dcdce0; }
  184. `);
  185.  
  186. // --- Menu Command Registration (Always register) ---
  187. function registerSettingsMenu() {
  188. GM_registerMenuCommand('⚙️ V2EX Striker 设置', buildSettingsModal);
  189. }
  190. registerSettingsMenu();
  191.  
  192. // --- Initial Title Check ---
  193. const postTitle = document.title.toLowerCase();
  194. let isGiveawayPost = false;
  195. if (activeTitleKeywords.length > 0) {
  196. isGiveawayPost = activeTitleKeywords.some(keyword => postTitle.includes(keyword));
  197. } else {
  198. console.log('V2EX Striker: Title keyword list is empty, skipping title check (not recommended).');
  199. // If title keywords are empty, maybe run the script anyway? Or force user to add keywords?
  200. // For now, let's assume empty means run always (though the UI description discourages it).
  201. isGiveawayPost = true; // Or set to false if empty list should disable the script.
  202. }
  203.  
  204. if (!isGiveawayPost) {
  205. console.log('V2EX Striker: Post title does not match configured keywords. Script inactive for marking codes on this page.');
  206. return; // Stop script execution for marking codes
  207. }
  208.  
  209. // --- IF TITLE CHECK PASSES, CONTINUE WITH THE REST OF THE LOGIC ---
  210.  
  211. console.log('V2EX Striker: Post title matched. Running main script logic...');
  212.  
  213. // --- Regex, Styles, Classes (Define constants used below) ---
  214. const codeRegex = /(?:[A-Z0-9][-_]?){6,}/gi;
  215. const usedStyle = 'text-decoration: line-through; color: grey;';
  216. const userInfoStyle = 'font-size: smaller; margin-left: 5px; color: #999; text-decoration: none;';
  217. const markedClass = 'v2ex-used-code-marked';
  218. const userInfoClass = 'v2ex-code-claimant';
  219.  
  220. // --- Keyword Regex Building (Comment Keywords) ---
  221. let keywordRegexCombinedTest = (text) => false; // Default test function
  222. if (activeCommentKeywords.length > 0) {
  223. // (Keyword regex building code remains the same)
  224. const wordCharRegex = /^[a-zA-Z0-9_]+$/;
  225. const englishKeywords = activeCommentKeywords.filter(kw => wordCharRegex.test(kw));
  226. const nonWordBoundaryKeywords = activeCommentKeywords.filter(kw => !wordCharRegex.test(kw));
  227. const regexParts = [];
  228.  
  229. if (englishKeywords.length > 0) {
  230. const englishPattern = `\\b(${englishKeywords.join('|')})\\b`;
  231. const englishRegex = new RegExp(englishPattern, 'i');
  232. regexParts.push((text) => englishRegex.test(text));
  233. }
  234. if (nonWordBoundaryKeywords.length > 0) {
  235. const escapedNonWordKeywords = nonWordBoundaryKeywords.map(kw => kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
  236. const nonWordPattern = `(${escapedNonWordKeywords.join('|')})`;
  237. const nonWordRegex = new RegExp(nonWordPattern, 'i');
  238. regexParts.push((text) => nonWordRegex.test(text));
  239. }
  240. if (regexParts.length > 0) {
  241. keywordRegexCombinedTest = (text) => regexParts.some(testFn => testFn(text));
  242. }
  243. }
  244.  
  245.  
  246. // --- Helper Function: findTextNodes (Unchanged) ---
  247. function findTextNodes(element, textNodes) {
  248. // (findTextNodes code remains the same)
  249. if (!element) return;
  250. for (const node of element.childNodes) {
  251. if (node.nodeType === Node.TEXT_NODE) {
  252. if (node.nodeValue.trim().length > 0) {
  253. textNodes.push(node);
  254. }
  255. } else if (node.nodeType === Node.ELEMENT_NODE) {
  256. if (!(node.tagName === 'SPAN' && node.classList.contains(markedClass)) &&
  257. !(node.tagName === 'A' && node.classList.contains(userInfoClass)))
  258. {
  259. if (node.tagName !== 'A' && node.tagName !== 'CODE') {
  260. findTextNodes(node, textNodes);
  261. } else {
  262. findTextNodes(node, textNodes);
  263. }
  264. }
  265. }
  266. }
  267. }
  268.  
  269. // --- Main Logic (Extraction and Marking - Adapted from v1.3) ---
  270. console.log('V2EX Striker: Starting code extraction and marking...');
  271.  
  272. // 1. Extract used Codes and Claimant Info from comments
  273. const claimedCodeInfo = new Map();
  274. const commentElements = document.querySelectorAll('div.cell[id^="r_"]');
  275. console.log(`V2EX Striker: Found ${commentElements.length} comment cells.`);
  276.  
  277. const commentKeywordsAreActive = activeCommentKeywords.length > 0;
  278.  
  279. commentElements.forEach((commentCell, index) => {
  280. // (Comment processing logic remains the same)
  281. const replyContentEl = commentCell.querySelector('.reply_content');
  282. const userLinkEl = commentCell.querySelector('strong > a[href^="/member/"]');
  283. if (!replyContentEl || !userLinkEl) return;
  284.  
  285. const commentText = replyContentEl.textContent;
  286. const username = userLinkEl.textContent;
  287. const profileUrl = userLinkEl.href;
  288. const potentialCodes = commentText.match(codeRegex);
  289.  
  290. if (potentialCodes) {
  291. let commentMatchesCriteria = false;
  292. if (!commentKeywordsAreActive) {
  293. commentMatchesCriteria = true;
  294. } else if (keywordRegexCombinedTest(commentText)) {
  295. commentMatchesCriteria = true;
  296. }
  297.  
  298. if (commentMatchesCriteria) {
  299. potentialCodes.forEach(code => {
  300. const codeUpper = code.toUpperCase();
  301. if (!claimedCodeInfo.has(codeUpper)) {
  302. // console.log(`V2EX Striker: Found potential used code "${code}" by user "${username}" in comment ${index + 1}`);
  303. claimedCodeInfo.set(codeUpper, { username, profileUrl });
  304. }
  305. });
  306. }
  307. }
  308. });
  309.  
  310. console.log(`V2EX Striker: Extracted info for ${claimedCodeInfo.size} unique potential used codes based on config:`, claimedCodeInfo.size > 0 ? [...claimedCodeInfo.keys()] : 'None'); // Log keys for less clutter
  311.  
  312. if (claimedCodeInfo.size === 0) {
  313. console.log('V2EX Striker: No potential used codes found in comments matching criteria. Exiting marking phase.');
  314. return;
  315. }
  316.  
  317. // 2. Find and mark Codes in main post and supplements
  318. const contentAreas = [
  319. document.querySelector('.topic_content'),
  320. ...document.querySelectorAll('.subtle .topic_content')
  321. ].filter(Boolean);
  322.  
  323. console.log(`V2EX Striker: Found ${contentAreas.length} content areas to scan for marking.`);
  324.  
  325. contentAreas.forEach((area) => {
  326. // (Marking logic remains the same)
  327. const textNodes = [];
  328. findTextNodes(area, textNodes);
  329.  
  330. textNodes.forEach(node => {
  331. if (node.parentNode && (node.parentNode.classList.contains(markedClass) || node.parentNode.classList.contains(userInfoClass))) {
  332. return;
  333. }
  334.  
  335. const nodeText = node.nodeValue;
  336. let match;
  337. let lastIndex = 0;
  338. const newNodeContainer = document.createDocumentFragment();
  339. const regex = new RegExp(codeRegex.source, 'gi');
  340. regex.lastIndex = 0;
  341.  
  342. while ((match = regex.exec(nodeText)) !== null) {
  343. const matchedCode = match[0];
  344. const matchedCodeUpper = matchedCode.toUpperCase();
  345.  
  346. if (claimedCodeInfo.has(matchedCodeUpper)) {
  347. const claimInfo = claimedCodeInfo.get(matchedCodeUpper);
  348. if (match.index > lastIndex) {
  349. newNodeContainer.appendChild(document.createTextNode(nodeText.substring(lastIndex, match.index)));
  350. }
  351. const span = document.createElement('span');
  352. span.textContent = matchedCode;
  353. span.style.cssText = usedStyle;
  354. span.title = `Code "${matchedCode}" likely used by ${claimInfo.username}`;
  355. span.classList.add(markedClass);
  356. newNodeContainer.appendChild(span);
  357.  
  358. if (showUserInfoEnabled && claimInfo) {
  359. const userLink = document.createElement('a');
  360. userLink.href = claimInfo.profileUrl;
  361. userLink.textContent = ` (@${claimInfo.username})`;
  362. userLink.style.cssText = userInfoStyle;
  363. userLink.classList.add(userInfoClass);
  364. userLink.target = '_blank';
  365. userLink.title = `View profile of ${claimInfo.username}`;
  366. newNodeContainer.appendChild(userLink);
  367. }
  368. lastIndex = regex.lastIndex;
  369. }
  370. }
  371.  
  372. if (lastIndex < nodeText.length) {
  373. newNodeContainer.appendChild(document.createTextNode(nodeText.substring(lastIndex)));
  374. }
  375.  
  376. if (newNodeContainer.hasChildNodes() && lastIndex > 0) {
  377. node.parentNode.replaceChild(newNodeContainer, node);
  378. }
  379. });
  380. });
  381.  
  382. console.log('V2EX Striker: Script finished.');
  383.  
  384. })();