您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
When your answer exactly matches the accepted answer on marumori.io, automatically click the "next arrow" button to advance.
// ==UserScript== // @name Marumori Auto-Advance on Exact Match // @namespace http://tampermonkey.net/ // @version 2.1 // @description When your answer exactly matches the accepted answer on marumori.io, automatically click the "next arrow" button to advance. // @author Matskye // @icon https://www.google.com/s2/favicons?sz=64&domain=marumori.io // @match https://marumori.io/* // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; // ── URL Scope: Only apply on the reviews and lessons pages ── function shouldApply() { return location.href.startsWith("https://marumori.io/study-lists/reviews") || location.href.startsWith("https://marumori.io/study-lists/lessons"); } // ── Normalization Functions ── // normalizeMeaning: Lowercases, trims, replaces hyphens with spaces, // removes both straight (') and typographic (’) apostrophes, // collapses multiple spaces, and removes any leading/trailing punctuation. function normalizeMeaning(text) { if (!text) return ""; let s = text.toLowerCase().trim(); s = s.replace(/-/g, " "); s = s.replace(/['’]/g, ""); // Remove both types of apostrophes s = s.replace(/\s+/g, " "); s = s.replace(/^[.,;:!?]+|[.,;:!?]+$/g, ""); return s; } // normalizeAnswerVariants: Returns an array of normalized variants: // one variant is the normalized text, and one is the normalized text with any parenthesized content removed. function normalizeAnswerVariants(text) { text = text.trim(); let v1 = normalizeMeaning(text); let v2 = normalizeMeaning(text.replace(/\s*\([^)]*\)/g, "")); return Array.from(new Set([v1, v2])); } // ── Accepted Answer Extraction ── // For "reading": searches wrappers with header "reading", "kunyomi", or "onyomi" // and collects all text from spans with class "reading" (or span.primary for vocabulary), // joining them with semicolons. // For "meaning": searches wrappers with header "meaning" or "meanings" and collects // all text from spans with class "meaning". (A fallback looks for a paragraph with class "spoiler" if needed.) function getAcceptedAnswer(fieldType) { let wrappers = document.querySelectorAll('.full_wrap .left_small .item_wrapper'); if (fieldType === 'reading') { let readings = []; wrappers.forEach(wrapper => { let header = wrapper.querySelector('h4'); if (!header) return; let headerText = header.textContent.trim().toLowerCase(); if (headerText === 'reading') { let span = wrapper.querySelector('span.primary'); if (span && span.textContent.trim()) { readings.push(span.textContent.trim()); } else { let spans = wrapper.querySelectorAll('span.reading'); spans.forEach(s => { if (s.textContent.trim()) readings.push(s.textContent.trim()); }); } } else if (headerText === 'kunyomi' || headerText === 'onyomi') { let spans = wrapper.querySelectorAll('span.reading'); spans.forEach(s => { if (s.textContent.trim()) readings.push(s.textContent.trim()); }); } }); if (readings.length > 0) { let joined = readings.join("; "); console.log('[AutoAdvance] Found accepted readings:', joined); return joined; } } else if (fieldType === 'meaning') { // First, try wrappers with header "meaning" or "meanings" for (let wrapper of wrappers) { let header = wrapper.querySelector('h4'); if (!header) continue; let headerText = header.textContent.trim().toLowerCase(); if (headerText === 'meaning' || headerText === 'meanings') { let meaningSpans = wrapper.querySelectorAll('span.meaning'); if (meaningSpans && meaningSpans.length > 0) { let meanings = Array.from(meaningSpans).map(span => span.textContent.trim()); let joined = meanings.join("; "); console.log('[AutoAdvance] Found accepted meaning (from spans):', joined); return joined; } } } // Fallback: try to find a paragraph with class "spoiler" inside .left_small let p = document.querySelector('.left_small p.spoiler'); if (p) { let spans = p.querySelectorAll('span.meaning'); if (spans && spans.length > 0) { let meanings = Array.from(spans).map(span => span.textContent.trim()); let joined = meanings.join("; "); console.log('[AutoAdvance] Found accepted meaning (fallback):', joined); return joined; } } } console.log('[AutoAdvance] Accepted answer not found for field:', fieldType); return null; } // ── Exact Match Checking ── // For "reading": splits the accepted readings by semicolon and checks for an exact match. // For "meaning": if the accepted answer contains a semicolon or newline, it is split; otherwise, // the entire string is one candidate. Then each candidate is normalized (and variants generated) // and compared to the normalized submitted answer. function isExactMatch(submitted, accepted, fieldType) { if (!submitted || !accepted) return false; if (fieldType === 'reading') { let candidates = accepted.split(";").map(s => s.trim()).filter(Boolean); console.log('[AutoAdvance] Reading candidates:', candidates); return candidates.includes(submitted); } else if (fieldType === 'meaning') { let candidates = (accepted.includes(";") || accepted.includes("\n")) ? accepted.split(/;|\n/).map(s => s.trim()).filter(Boolean) : [accepted]; console.log('[AutoAdvance] Raw meaning candidates:', candidates); let normSubmitted = normalizeMeaning(submitted); for (let candidate of candidates) { let variants = normalizeAnswerVariants(candidate); console.log('[AutoAdvance] Candidate variants for "' + candidate + '":', variants); if (variants.includes(normSubmitted)) return true; } return false; } return false; } // ── Global Variables ── let lastSubmittedAnswer = ''; let lastFieldType = ''; // ── Key Listener ── function keyListener(e) { if (e.key === 'Enter') { let target = e.target; if (target && target.matches('input.pan_input')) { if (!target.disabled) { lastSubmittedAnswer = target.value.trim(); let placeholder = target.getAttribute('placeholder') || ''; if (placeholder.includes('読み方') || placeholder.toLowerCase().includes('reading')) { lastFieldType = 'reading'; } else if (placeholder.toLowerCase().includes('meaning')) { lastFieldType = 'meaning'; } else { lastFieldType = ''; } console.log('[AutoAdvance] Captured answer:', lastSubmittedAnswer, 'for field:', lastFieldType); } } } } // ── Mutation Observer Callback ── function mutationCallback(mutations) { mutations.forEach(mutation => { if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') { let target = mutation.target; if (target && target.matches('input.pan_input') && target.disabled) { console.log('[AutoAdvance] Detected disabled input.'); setTimeout(() => { let accepted = getAcceptedAnswer(lastFieldType); console.log('[AutoAdvance] Submitted:', lastSubmittedAnswer, '| Accepted:', accepted); if (accepted && isExactMatch(lastSubmittedAnswer, accepted, lastFieldType)) { console.log('[AutoAdvance] Exact match found. Clicking next arrow...'); let nextArrow = document.querySelector('button.next-arrow'); if (nextArrow) { nextArrow.click(); console.log('[AutoAdvance] Next arrow clicked.'); } else { console.log('[AutoAdvance] Next arrow button not found.'); } } else { console.log('[AutoAdvance] Not an exact match or accepted answer missing.'); } lastSubmittedAnswer = ''; lastFieldType = ''; }, 50); } } }); } // ── Initialization and Cleanup ── let observer = null; let autoAdvanceActive = false; function initAutoAdvance() { if (autoAdvanceActive) return; autoAdvanceActive = true; document.addEventListener('keydown', keyListener, true); observer = new MutationObserver(mutationCallback); observer.observe(document.body, { attributes: true, subtree: true }); console.log('[AutoAdvance] Auto-advance enabled on', location.href); } function cleanupAutoAdvance() { if (!autoAdvanceActive) return; autoAdvanceActive = false; document.removeEventListener('keydown', keyListener, true); if (observer) { observer.disconnect(); observer = null; } console.log('[AutoAdvance] Auto-advance disabled on', location.href); } function checkURL() { if (shouldApply()) { initAutoAdvance(); } else { cleanupAutoAdvance(); } } // ── URL Change Handling for SPA ── const _pushState = history.pushState; history.pushState = function() { _pushState.apply(history, arguments); setTimeout(checkURL, 100); }; const _replaceState = history.replaceState; history.replaceState = function() { _replaceState.apply(history, arguments); setTimeout(checkURL, 100); }; window.addEventListener('popstate', function() { setTimeout(checkURL, 100); }); checkURL(); setInterval(checkURL, 2000); })();