V2EX Used Code Striker++

在 V2EX 送码帖中,根据评论和配置,自动划掉主楼/附言中被提及的 Code,并可选显示领取者。正则和关键词均可配置。

  1. // ==UserScript==
  2. // @name V2EX Used Code Striker++
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.5.0
  5. // @description 在 V2EX 送码帖中,根据评论和配置,自动划掉主楼/附言中被提及的 Code,并可选显示领取者。正则和关键词均可配置。
  6. // @author 与Gemini协作完成
  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_deleteValue
  12. // @grant GM_registerMenuCommand
  13. // @grant GM_addStyle
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. (function() {
  18. 'use strict';
  19.  
  20. // --- Storage Keys ---
  21. const STORAGE_KEY_CODE_REGEX = 'v2ex_striker_code_regex';
  22. const STORAGE_KEY_TITLE_KEYWORDS = 'v2ex_striker_title_keywords';
  23. const STORAGE_KEY_COMMENT_KEYWORDS = 'v2ex_striker_comment_keywords';
  24. const STORAGE_KEY_SHOW_USER = 'v2ex_striker_show_user';
  25. const MODAL_ID = 'v2ex-striker-settings-modal';
  26. const OVERLAY_ID = 'v2ex-striker-settings-overlay';
  27.  
  28. // --- Default Settings ---
  29. const defaultCodeRegexString = '/(?:[A-Z0-9][-_]?){6,}/gi';
  30. const defaultTitleKeywords = ['送', '发', '福利', '邀请码', '激活码', '码', 'giveaway', 'invite', 'code'];
  31. const defaultCommentKeywords = ['用', 'used', 'taken', '领', 'redeem', 'thx', '感谢'];
  32. const defaultShowUserInfo = true;
  33.  
  34. // --- Helper: Parse Regex String ---
  35. const defaultCodeRegexObject = new RegExp(defaultCodeRegexString.match(/^\/(.+)\/([gimyus]*)$/)[1], defaultCodeRegexString.match(/^\/(.+)\/([gimyus]*)$/)[2]);
  36.  
  37. function parseRegexString(regexString) {
  38. try {
  39. const match = regexString.match(/^\/(.+)\/([gimyus]*)$/);
  40. if (match && match[1]) { // Ensure pattern part exists
  41. return new RegExp(match[1], match[2] || ''); // Use flags or empty string if none
  42. } else {
  43. console.warn('V2EX Striker: Invalid regex format in storage ("' + regexString + '"). Using default.');
  44. return defaultCodeRegexObject;
  45. }
  46. } catch (e) {
  47. console.error('V2EX Striker: Error parsing stored regex ("' + regexString + '"). Using default.', e);
  48. return defaultCodeRegexObject;
  49. }
  50. }
  51.  
  52. // --- Load Settings ---
  53. const codeRegexString = GM_getValue(STORAGE_KEY_CODE_REGEX, defaultCodeRegexString);
  54. const titleKeywordsString = GM_getValue(STORAGE_KEY_TITLE_KEYWORDS, defaultTitleKeywords.join(','));
  55. const commentKeywordsString = GM_getValue(STORAGE_KEY_COMMENT_KEYWORDS, defaultCommentKeywords.join(','));
  56. const showUserInfoEnabled = GM_getValue(STORAGE_KEY_SHOW_USER, defaultShowUserInfo);
  57.  
  58. // Parse loaded regex
  59. const activeCodeRegex = parseRegexString(codeRegexString); // This is the RegExp object to use
  60.  
  61. let activeTitleKeywords = [];
  62. if (titleKeywordsString && titleKeywordsString.trim() !== '') {
  63. activeTitleKeywords = titleKeywordsString.split(',').map(kw => kw.trim().toLowerCase()).filter(Boolean);
  64. }
  65.  
  66. let activeCommentKeywords = [];
  67. if (commentKeywordsString && commentKeywordsString.trim() !== '') {
  68. activeCommentKeywords = commentKeywordsString.split(',').map(kw => kw.trim()).filter(Boolean);
  69. }
  70.  
  71. console.log('V2EX Striker: Active Code Regex:', activeCodeRegex);
  72. console.log('V2EX Striker: Title Keywords:', activeTitleKeywords.length > 0 ? activeTitleKeywords : '(Inactive - No keywords configured)');
  73. console.log('V2EX Striker: Comment Keywords:', activeCommentKeywords.length > 0 ? activeCommentKeywords : '(None - All comment codes considered used)');
  74. console.log('V2EX Striker: Show Username:', showUserInfoEnabled);
  75.  
  76.  
  77. // --- Settings Modal Functions (Define early) ---
  78. function buildSettingsModal() {
  79. document.getElementById(MODAL_ID)?.remove();
  80. document.getElementById(OVERLAY_ID)?.remove();
  81.  
  82. const overlay = document.createElement('div');
  83. overlay.id = OVERLAY_ID;
  84. overlay.onclick = closeSettingsModal;
  85.  
  86. const modal = document.createElement('div');
  87. modal.id = MODAL_ID;
  88.  
  89. const title = document.createElement('h2');
  90. title.textContent = 'V2EX Used Code Striker 设置';
  91. modal.appendChild(title);
  92.  
  93. // 1. Code Regex
  94. const regexDiv = document.createElement('div');
  95. regexDiv.className = 'setting-item';
  96. const regexLabel = document.createElement('label');
  97. regexLabel.textContent = 'Code 正则表达式 (格式: /pattern/flags):';
  98. regexLabel.htmlFor = 'v2ex-striker-regex-input';
  99. const regexInput = document.createElement('input'); // Use text input for regex
  100. regexInput.type = 'text';
  101. regexInput.id = 'v2ex-striker-regex-input';
  102. regexInput.value = GM_getValue(STORAGE_KEY_CODE_REGEX, defaultCodeRegexString); // Load string value
  103. regexDiv.appendChild(regexLabel);
  104. regexDiv.appendChild(regexInput);
  105. modal.appendChild(regexDiv);
  106. const regexDesc = document.createElement('p');
  107. regexDesc.className = 'setting-desc';
  108. regexDesc.textContent = '用于匹配主楼和评论中 Code 的正则表达式。';
  109. modal.appendChild(regexDesc);
  110.  
  111.  
  112. // 2. Title Keywords
  113. const titleDiv = document.createElement('div');
  114. titleDiv.className = 'setting-item';
  115. const titleLabel = document.createElement('label');
  116. titleLabel.textContent = '帖子标题关键词 (英文逗号分隔):';
  117. titleLabel.htmlFor = 'v2ex-striker-title-keywords-input';
  118. const titleInput = document.createElement('textarea');
  119. titleInput.id = 'v2ex-striker-title-keywords-input';
  120. titleInput.value = GM_getValue(STORAGE_KEY_TITLE_KEYWORDS, defaultTitleKeywords.join(','));
  121. titleInput.rows = 2;
  122. titleDiv.appendChild(titleLabel);
  123. titleDiv.appendChild(titleInput);
  124. modal.appendChild(titleDiv);
  125. const titleDesc = document.createElement('p');
  126. titleDesc.className = 'setting-desc';
  127. titleDesc.textContent = '包含这些词的帖子标题才会激活脚本。留空(不推荐)则不根据标题判断,所有帖子都会执行脚本。';
  128. modal.appendChild(titleDesc);
  129.  
  130. // 3. Comment Keywords
  131. const commentDiv = document.createElement('div');
  132. commentDiv.className = 'setting-item';
  133. const commentLabel = document.createElement('label');
  134. commentLabel.textContent = '评论区关键词 (英文逗号分隔):';
  135. commentLabel.htmlFor = 'v2ex-striker-comment-keywords-input';
  136. const commentInput = document.createElement('textarea');
  137. commentInput.id = 'v2ex-striker-comment-keywords-input';
  138. commentInput.value = GM_getValue(STORAGE_KEY_COMMENT_KEYWORDS, defaultCommentKeywords.join(','));
  139. commentInput.rows = 3;
  140. commentDiv.appendChild(commentLabel);
  141. commentDiv.appendChild(commentInput);
  142. modal.appendChild(commentDiv);
  143. const commentDesc = document.createElement('p');
  144. commentDesc.className = 'setting-desc';
  145. commentDesc.textContent = '评论需包含这些词才会标记对应 Code。留空则评论中提到的所有 Code 都被标记。';
  146. modal.appendChild(commentDesc);
  147.  
  148. // 4. Show User Info
  149. const showUserDiv = document.createElement('div');
  150. showUserDiv.className = 'setting-item setting-item-checkbox';
  151. const showUserInput = document.createElement('input');
  152. showUserInput.type = 'checkbox';
  153. showUserInput.id = 'v2ex-striker-show-user-input';
  154. showUserInput.checked = GM_getValue(STORAGE_KEY_SHOW_USER, defaultShowUserInfo);
  155. const showUserLabel = document.createElement('label');
  156. showUserLabel.textContent = '在划掉的 Code 旁显示使用者信息';
  157. showUserLabel.htmlFor = 'v2ex-striker-show-user-input';
  158. showUserDiv.appendChild(showUserInput);
  159. showUserDiv.appendChild(showUserLabel);
  160. modal.appendChild(showUserDiv);
  161.  
  162. // --- Action Buttons ---
  163. const buttonContainer = document.createElement('div');
  164. buttonContainer.className = 'setting-actions-container'; // Container for alignment
  165.  
  166. const resetButton = document.createElement('button');
  167. resetButton.textContent = '重置为默认';
  168. resetButton.className = 'reset-button';
  169. resetButton.onclick = resetSettingsToDefaults;
  170.  
  171. const actionButtonsDiv = document.createElement('div');
  172. actionButtonsDiv.className = 'setting-actions'; // Right-aligned buttons
  173.  
  174. const cancelButton = document.createElement('button');
  175. cancelButton.textContent = '取消';
  176. cancelButton.className = 'cancel-button';
  177. cancelButton.onclick = closeSettingsModal;
  178.  
  179. const saveButton = document.createElement('button');
  180. saveButton.textContent = '保存设置';
  181. saveButton.className = 'save-button';
  182. saveButton.onclick = saveSettings;
  183.  
  184. actionButtonsDiv.appendChild(cancelButton);
  185. actionButtonsDiv.appendChild(saveButton);
  186.  
  187. buttonContainer.appendChild(resetButton); // Reset on the left
  188. buttonContainer.appendChild(actionButtonsDiv); // Save/Cancel group on the right
  189. modal.appendChild(buttonContainer);
  190.  
  191. document.body.appendChild(overlay);
  192. document.body.appendChild(modal);
  193. }
  194.  
  195. function closeSettingsModal() {
  196. document.getElementById(MODAL_ID)?.remove();
  197. document.getElementById(OVERLAY_ID)?.remove();
  198. }
  199.  
  200. function saveSettings() {
  201. const regexValue = document.getElementById('v2ex-striker-regex-input').value.trim();
  202. const titleKeywords = document.getElementById('v2ex-striker-title-keywords-input').value.trim();
  203. const commentKeywords = document.getElementById('v2ex-striker-comment-keywords-input').value.trim();
  204. const showUser = document.getElementById('v2ex-striker-show-user-input').checked;
  205.  
  206. // Basic validation for regex format (optional but good practice)
  207. if (!/^\/.+\/[gimyus]*$/.test(regexValue) && regexValue !== '') {
  208. if (!confirm(`输入的正则表达式 "${regexValue}" 格式可能不正确 (应为 /pattern/flags),确定要保存吗?`)) {
  209. return; // Don't save if user cancels
  210. }
  211. }
  212.  
  213. GM_setValue(STORAGE_KEY_CODE_REGEX, regexValue);
  214. GM_setValue(STORAGE_KEY_TITLE_KEYWORDS, titleKeywords);
  215. GM_setValue(STORAGE_KEY_COMMENT_KEYWORDS, commentKeywords);
  216. GM_setValue(STORAGE_KEY_SHOW_USER, showUser);
  217.  
  218. closeSettingsModal();
  219. alert('设置已保存!\n请刷新页面以应用新的设置。');
  220. }
  221.  
  222. function resetSettingsToDefaults() {
  223. if (confirm("确定要将正则、标题关键词和评论区关键词重置为默认设置吗?")) {
  224. // Set storage back to defaults
  225. GM_setValue(STORAGE_KEY_CODE_REGEX, defaultCodeRegexString);
  226. GM_setValue(STORAGE_KEY_TITLE_KEYWORDS, defaultTitleKeywords.join(','));
  227. GM_setValue(STORAGE_KEY_COMMENT_KEYWORDS, defaultCommentKeywords.join(','));
  228. GM_setValue(STORAGE_KEY_SHOW_USER, defaultShowUserInfo);
  229.  
  230. // Update fields in the current modal immediately
  231. const modal = document.getElementById(MODAL_ID);
  232. if (modal) {
  233. modal.querySelector('#v2ex-striker-regex-input').value = defaultCodeRegexString;
  234. modal.querySelector('#v2ex-striker-title-keywords-input').value = defaultTitleKeywords.join(',');
  235. modal.querySelector('#v2ex-striker-comment-keywords-input').value = defaultCommentKeywords.join(',');
  236. modal.querySelector('#v2ex-striker-show-user-input').checked = defaultShowUserInfo;
  237. // Show user checkbox remains as it was
  238. }
  239. // No need for alert here, immediate update is feedback enough
  240. console.log("V2EX Striker: Settings reset to defaults (excluding Show User).");
  241. }
  242. }
  243.  
  244. // --- Add Modal Styles (Always add styles) ---
  245. // Added styles for input[type=text] and adjusted button layout
  246. GM_addStyle(`
  247. #${OVERLAY_ID} { /* Styles remain the same */
  248. position: fixed !important; top: 0 !important; left: 0 !important; width: 100% !important; height: 100% !important;
  249. background-color: rgba(0, 0, 0, 0.4) !important; z-index: 99998 !important; backdrop-filter: blur(3px);
  250. display: block !important; margin: 0 !important; padding: 0 !important; border: none !important;
  251. }
  252. #${MODAL_ID} { /* Styles remain the same */
  253. position: fixed !important; top: 50% !important; left: 50% !important; transform: translate(-50%, -50%) !important;
  254. width: clamp(300px, 90%, 500px) !important; max-width: 500px !important; background-color: #f9f9f9 !important; border-radius: 12px !important;
  255. box-shadow: 0 5px 25px rgba(0, 0, 0, 0.15) !important; padding: 25px 30px !important; z-index: 99999 !important;
  256. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif !important;
  257. color: #333 !important; box-sizing: border-box !important; display: block !important; margin: 0 !important; border: none !important;
  258. max-height: 95vh !important; overflow-y: auto !important; /* Allow scrolling */
  259. }
  260. #${MODAL_ID} h2 { margin-top: 0 !important; margin-bottom: 25px !important; font-size: 1.4em !important; font-weight: 600 !important; text-align: center !important; color: #1d1d1f !important; }
  261. #${MODAL_ID} .setting-item { margin-bottom: 10px !important; }
  262. #${MODAL_ID} .setting-desc { font-size: 0.8em !important; color: #6e6e73 !important; margin-top: 0px !important; margin-bottom: 15px !important; }
  263. #${MODAL_ID} label { display: block !important; margin-bottom: 5px !important; font-weight: 500 !important; font-size: 0.95em !important; color: #333 !important; }
  264. #${MODAL_ID} input[type="text"],
  265. #${MODAL_ID} textarea {
  266. width: 100% !important; padding: 10px !important; border: 1px solid #d2d2d7 !important; border-radius: 6px !important;
  267. font-size: 0.9em !important; box-sizing: border-box !important; font-family: inherit !important; background-color: #fff !important; color: #333 !important;
  268. }
  269. #${MODAL_ID} textarea { resize: vertical !important; min-height: 40px !important; }
  270. #${MODAL_ID} input[type="text"]:focus,
  271. #${MODAL_ID} textarea:focus {
  272. border-color: #007aff !important; outline: none !important; box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2) !important;
  273. }
  274. #${MODAL_ID} .setting-item-checkbox { display: flex !important; align-items: center !important; margin-top: 20px !important; margin-bottom: 25px !important; }
  275. #${MODAL_ID} .setting-item-checkbox input[type="checkbox"] { margin-right: 10px !important; width: 16px !important; height: 16px !important; accent-color: #007aff !important; vertical-align: middle !important; }
  276. #${MODAL_ID} .setting-item-checkbox label { margin-bottom: 0 !important; font-weight: normal !important; display: inline-block !important; vertical-align: middle !important; }
  277. #${MODAL_ID} .setting-actions-container { /* New container for button layout */
  278. margin-top: 25px !important;
  279. display: flex !important;
  280. justify-content: space-between !important; /* Space between reset and save/cancel */
  281. align-items: center !important;
  282. }
  283. #${MODAL_ID} .setting-actions { /* Group for save/cancel */
  284. display: flex !important;
  285. gap: 10px !important;
  286. }
  287. #${MODAL_ID} button {
  288. padding: 10px 20px !important; border: none !important; border-radius: 6px !important;
  289. font-size: 0.95em !important; font-weight: 500 !important; cursor: pointer !important; transition: background-color 0.2s ease !important;
  290. }
  291. #${MODAL_ID} button.reset-button {
  292. background-color: #f5f5f7; /* Lighter gray for reset */
  293. color: #555;
  294. padding: 10px 15px !important; /* Slightly smaller padding maybe */
  295. }
  296. #${MODAL_ID} button.reset-button:hover {
  297. background-color: #e9e9ed;
  298. }
  299. #${MODAL_ID} button.save-button {
  300. background-color: #007aff !important; color: white !important;
  301. }
  302. #${MODAL_ID} button.save-button:hover { background-color: #005ecf !important; }
  303. #${MODAL_ID} button.cancel-button { background-color: #e5e5ea !important; color: #1d1d1f !important; }
  304. #${MODAL_ID} button.cancel-button:hover { background-color: #dcdce0 !important; }
  305. `);
  306.  
  307. // --- Menu Command Registration (Always register) ---
  308. function registerSettingsMenu() {
  309. GM_registerMenuCommand('⚙️ V2EX Striker 设置', buildSettingsModal);
  310. }
  311. registerSettingsMenu();
  312.  
  313. // --- Initial Title Check ---
  314. const postTitle = document.title.toLowerCase();
  315. let isGiveawayPost = false;
  316. if (activeTitleKeywords.length > 0) {
  317. isGiveawayPost = activeTitleKeywords.some(keyword => postTitle.includes(keyword));
  318. } else {
  319. console.log('V2EX Striker: Title keyword list is empty, skipping title check.');
  320. isGiveawayPost = true; // Assume run always if title keywords are empty
  321. }
  322.  
  323. if (!isGiveawayPost) {
  324. console.log('V2EX Striker: Post title does not match configured keywords. Script inactive for marking codes on this page.');
  325. return; // Stop script execution for marking codes
  326. }
  327.  
  328. // --- IF TITLE CHECK PASSES, CONTINUE WITH THE REST OF THE LOGIC ---
  329.  
  330. console.log('V2EX Striker: Post title matched. Running main script logic...');
  331.  
  332. // --- Regex, Styles, Classes (Define constants used below) ---
  333. // Use activeCodeRegex parsed earlier
  334. const usedStyle = 'text-decoration: line-through; color: grey;';
  335. const userInfoStyle = 'font-size: smaller; margin-left: 5px; color: #999; text-decoration: none;';
  336. const markedClass = 'v2ex-used-code-marked';
  337. const userInfoClass = 'v2ex-code-claimant';
  338.  
  339. // --- Keyword Regex Building (Comment Keywords) ---
  340. let keywordRegexCombinedTest = (text) => false; // Default test function
  341. if (activeCommentKeywords.length > 0) {
  342. const wordCharRegex = /^[a-zA-Z0-9_]+$/;
  343. const englishKeywords = activeCommentKeywords.filter(kw => wordCharRegex.test(kw));
  344. const nonWordBoundaryKeywords = activeCommentKeywords.filter(kw => !wordCharRegex.test(kw));
  345. const regexParts = [];
  346.  
  347. if (englishKeywords.length > 0) {
  348. const englishPattern = `\\b(${englishKeywords.join('|')})\\b`;
  349. const englishRegex = new RegExp(englishPattern, 'i');
  350. regexParts.push((text) => englishRegex.test(text));
  351. }
  352. if (nonWordBoundaryKeywords.length > 0) {
  353. const escapedNonWordKeywords = nonWordBoundaryKeywords.map(kw => kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
  354. const nonWordPattern = `(${escapedNonWordKeywords.join('|')})`;
  355. const nonWordRegex = new RegExp(nonWordPattern, 'i');
  356. regexParts.push((text) => nonWordRegex.test(text));
  357. }
  358. if (regexParts.length > 0) {
  359. keywordRegexCombinedTest = (text) => regexParts.some(testFn => testFn(text));
  360. }
  361. }
  362.  
  363.  
  364. // --- Helper Function: findTextNodes (Unchanged) ---
  365. function findTextNodes(element, textNodes) {
  366. if (!element) return;
  367. for (const node of element.childNodes) {
  368. if (node.nodeType === Node.TEXT_NODE) {
  369. if (node.nodeValue.trim().length > 0) {
  370. textNodes.push(node);
  371. }
  372. } else if (node.nodeType === Node.ELEMENT_NODE) {
  373. if (!(node.tagName === 'SPAN' && node.classList.contains(markedClass)) &&
  374. !(node.tagName === 'A' && node.classList.contains(userInfoClass)))
  375. {
  376. if (node.tagName !== 'A' && node.tagName !== 'CODE') {
  377. findTextNodes(node, textNodes);
  378. } else {
  379. findTextNodes(node, textNodes);
  380. }
  381. }
  382. }
  383. }
  384. }
  385.  
  386. // --- Main Logic (Extraction and Marking) ---
  387. console.log('V2EX Striker: Starting code extraction and marking...');
  388.  
  389. // 1. Extract used Codes and Claimant Info from comments
  390. const claimedCodeInfo = new Map();
  391. const commentElements = document.querySelectorAll('div.cell[id^="r_"]');
  392. // console.log(`V2EX Striker: Found ${commentElements.length} comment cells.`); // Less verbose
  393.  
  394. const commentKeywordsAreActive = activeCommentKeywords.length > 0;
  395.  
  396. commentElements.forEach((commentCell) => {
  397. const replyContentEl = commentCell.querySelector('.reply_content');
  398. const userLinkEl = commentCell.querySelector('strong > a[href^="/member/"]');
  399. if (!replyContentEl || !userLinkEl) return;
  400.  
  401. const commentText = replyContentEl.textContent;
  402. const username = userLinkEl.textContent;
  403. const profileUrl = userLinkEl.href;
  404.  
  405. // Use the globally loaded activeCodeRegex here
  406. const potentialCodes = commentText.match(activeCodeRegex); // Apply active regex
  407.  
  408. if (potentialCodes) {
  409. let commentMatchesCriteria = false;
  410. if (!commentKeywordsAreActive) {
  411. commentMatchesCriteria = true;
  412. } else if (keywordRegexCombinedTest(commentText)) {
  413. commentMatchesCriteria = true;
  414. }
  415.  
  416. if (commentMatchesCriteria) {
  417. potentialCodes.forEach(code => {
  418. const codeUpper = code.toUpperCase();
  419. if (!claimedCodeInfo.has(codeUpper)) {
  420. claimedCodeInfo.set(codeUpper, { username, profileUrl });
  421. }
  422. });
  423. }
  424. }
  425. });
  426.  
  427. // console.log(`V2EX Striker: Extracted info for ${claimedCodeInfo.size} unique potential used codes.`); // Less verbose
  428.  
  429. if (claimedCodeInfo.size === 0) {
  430. console.log('V2EX Striker: No potential used codes found in comments matching criteria. Exiting marking phase.');
  431. return;
  432. }
  433.  
  434. // 2. Find and mark Codes in main post and supplements
  435. const contentAreas = [
  436. document.querySelector('.topic_content'),
  437. ...document.querySelectorAll('.subtle .topic_content') // Corrected selector
  438. ].filter(Boolean);
  439.  
  440. // console.log(`V2EX Striker: Found ${contentAreas.length} content areas to scan for marking.`); // Less verbose
  441.  
  442. contentAreas.forEach((area) => {
  443. const textNodes = [];
  444. findTextNodes(area, textNodes);
  445.  
  446. textNodes.forEach(node => {
  447. if (node.parentNode && (node.parentNode.classList.contains(markedClass) || node.parentNode.classList.contains(userInfoClass))) {
  448. return;
  449. }
  450.  
  451. const nodeText = node.nodeValue;
  452. let match;
  453. let lastIndex = 0;
  454. const newNodeContainer = document.createDocumentFragment();
  455. // Create a new RegExp instance based on the active one for stateful matching (lastIndex)
  456. const regex = new RegExp(activeCodeRegex.source, activeCodeRegex.flags);
  457. regex.lastIndex = 0; // Reset
  458.  
  459. while ((match = regex.exec(nodeText)) !== null) {
  460. const matchedCode = match[0];
  461. const matchedCodeUpper = matchedCode.toUpperCase();
  462.  
  463. if (claimedCodeInfo.has(matchedCodeUpper)) {
  464. const claimInfo = claimedCodeInfo.get(matchedCodeUpper);
  465. if (match.index > lastIndex) {
  466. newNodeContainer.appendChild(document.createTextNode(nodeText.substring(lastIndex, match.index)));
  467. }
  468. const span = document.createElement('span');
  469. span.textContent = matchedCode;
  470. span.style.cssText = usedStyle;
  471. span.title = `Code "${matchedCode}" likely used by ${claimInfo.username}`;
  472. span.classList.add(markedClass);
  473. newNodeContainer.appendChild(span);
  474.  
  475. if (showUserInfoEnabled && claimInfo) {
  476. const userLink = document.createElement('a');
  477. userLink.href = claimInfo.profileUrl;
  478. userLink.textContent = ` (@${claimInfo.username})`;
  479. userLink.style.cssText = userInfoStyle;
  480. userLink.classList.add(userInfoClass);
  481. userLink.target = '_blank';
  482. userLink.title = `View profile of ${claimInfo.username}`;
  483. newNodeContainer.appendChild(userLink);
  484. }
  485. lastIndex = regex.lastIndex;
  486. // Prevent infinite loops for zero-length matches (shouldn't happen with current regex, but good practice)
  487. if (lastIndex === match.index) {
  488. regex.lastIndex++;
  489. }
  490. }
  491. // Ensure progress even if no match is found in claimedCodeInfo
  492. if (regex.lastIndex === match.index) {
  493. regex.lastIndex++;
  494. }
  495. }
  496.  
  497. if (lastIndex < nodeText.length) {
  498. newNodeContainer.appendChild(document.createTextNode(nodeText.substring(lastIndex)));
  499. }
  500.  
  501. if (newNodeContainer.hasChildNodes() && lastIndex > 0) {
  502. node.parentNode.replaceChild(newNodeContainer, node);
  503. }
  504. });
  505. });
  506.  
  507. console.log('V2EX Striker: Script finished.');
  508.  
  509. })();