// ==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);
})();