您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Colors vocabulary on the lesson picker based on whether their readings are known
// ==UserScript== // @name WaniKani Vocab Reading Analyzer // @namespace wyverex // @version 1.2.3 // @description Colors vocabulary on the lesson picker based on whether their readings are known // @author Andreas Krügersen-Clark // @match https://www.wanikani.com/ // @match https://www.wanikani.com/dashboard // @match https://www.wanikani.com/subject-lessons/picker // @grant none // @require https://unpkg.com/wanakana // @license MIT // @run-at document-end // ==/UserScript== (function () { if (!window.wkof) { alert( '"Wanikani Vocab Reading Analyzer" script requires Wanikani Open Framework.\nYou will now be forwarded to installation instructions.' ); window.location.href = "https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549"; return; } const StoreName = "cachedReadings"; const RendakuPrefixCandidates = { か: ["が"], き: ["ぎ"], く: ["ぐ"], け: ["げ"], こ: ["ご"], さ: ["ざ"], し: ["じ"], す: ["ず"], せ: ["ぜ"], そ: ["ぞ"], た: ["だ"], ち: ["ぢ"], つ: ["づ"], て: ["で"], と: ["ど"], は: ["ば", "ぱ"], ひ: ["び", "ぴ"], ふ: ["ぶ", "ぷ"], へ: ["べ", "ぺ"], ほ: ["ぼ", "ぽ"], }; const RendakuSuffixCandidates = { く: "っ", つ: "っ", ち: "っ", }; const DefaultColors = { easyColor: "#A1FA4F", secondaryColor: "#6DA3EE", rendakuColor: "#FFF200", newColor: "#F06356", }; const wkof = window.wkof; const shared = { settings: {}, db: undefined, dialog: undefined, vocab: undefined, kanji: undefined, learnedVocabProcessed: false, // KanjiId -> [learned readings] lastReadingCacheTime: new Date(0), readingsCache: {}, }; wkof.include("ItemData,Menu,Settings"); if (window.location.href.includes("subject-lessons/picker")) { wkof.ready("ItemData").then(openDB).catch(loadError); } wkof.ready("document,Menu,Settings").then(loadSettings).then(installMenu).catch(loadError); function loadError(e) { console.error('Failed to load data from WKOF for "Vocab Analyzer"', e); } function loadSettings() { return wkof.Settings.load("wk_vocab_analyzer", DefaultColors).then(() => (shared.settings = wkof.settings.wk_vocab_analyzer)); } function openDB() { const dbRequest = window.indexedDB.open("wk-vocab-analyzer", 1); dbRequest.onerror = (event) => { console.error("Could not open database for Vocab Analyzer. Analyzing vocab with learned, secondary readings is not supported."); startup(); }; dbRequest.onsuccess = (event) => { shared.db = event.target.result; const transaction = shared.db.transaction([StoreName], "readonly"); const store = transaction.objectStore(StoreName); const request = store.get("main"); request.onsuccess = () => { const data = request.result; shared.lastReadingCacheTime = data.lastReadingCacheTime; shared.readingsCache = data.cache; startup(); }; }; dbRequest.onupgradeneeded = (event) => { const db = event.target.result; const store = db.createObjectStore(StoreName, { keyPath: "id" }); store.add({ id: "main", lastReadingCacheTime: new Date(0), cache: {} }); }; } function startup() { const kanjiConfig = { wk_items: { options: { subjects: true }, filters: { level: "1..+0", item_type: "kanji" } } }; wkof.ItemData.get_items(kanjiConfig).then(processKanji); } // ---------------------------------------------------------------------- function installMenu() { if (window.location.href.includes("subject-lessons/picker")) { return; } wkof.Menu.insert_script_link({ name: "wk_vocab_analyzer", submenu: "Settings", title: "Vocab Reading Analyzer", on_click: openSettings, }); } // prettier-ignore function openSettings() { let config = { script_id: 'wk_vocab_analyzer', title: 'Vocab Reading Analyzer', content: { display: { type: "group", label: "Colors", content: { easyColor: { type: "color", label: "Easy reading", full_width: false }, secondaryColor: { type: "color", label: "Secondary reading" }, rendakuColor: { type: "color", label: "Rendaku reading" }, newColor: { type: "color", label: "New reading" }, reset: { type: "button", label: "Reset to defaults", text: "Reset", on_click: resetToDefaults } } } } }; shared.dialog = new wkof.Settings(config); shared.dialog.open(); } function resetToDefaults() { shared.settings.easyColor = DefaultColors.easyColor; shared.settings.secondaryColor = DefaultColors.secondaryColor; shared.settings.rendakuColor = DefaultColors.rendakuColor; shared.settings.newColor = DefaultColors.newColor; shared.dialog.refresh(); } // ---------------------------------------------------------------------- function processKanji(items) { shared.kanji = items; if (shared.db) { // Get all learned vocab const config = { wk_items: { options: { subjects: true, assignments: true }, filters: { srs: { value: [-1, 0], invert: true }, item_type: "voc" } }, }; wkof.ItemData.get_items(config).then(cacheNewlyLearnedReadings); } else { processVocab(); } } function cacheNewlyLearnedReadings(items) { if (items.length > 0) { let hasUpdates = false; for (let vocab of items) { const startTime = new Date(vocab.assignments.started_at); if (startTime > shared.lastReadingCacheTime) { const analysis = analyzeVocab(vocab); if (analysis) { for (let kanji of analysis) { if (shared.readingsCache[kanji.id] === undefined) { shared.readingsCache[kanji.id] = new Set(); } shared.readingsCache[kanji.id].add(kanji.reading); hasUpdates = true; } } } } if (hasUpdates) { const transaction = shared.db.transaction([StoreName], "readwrite"); const store = transaction.objectStore(StoreName); store.put({ id: "main", lastReadingCacheTime: new Date(), cache: shared.readingsCache }); } } processVocab(); } function processVocab() { // Get unlocked, not yet learned vocab const vocabConfig = { wk_items: { options: { subjects: true }, filters: { srs: "init", item_type: "voc" } } }; wkof.ItemData.get_items(vocabConfig).then((items) => { shared.vocab = items; processData(); }); } // ==================================================================================== function processData() { if (window.location.href.includes("subject-lessons/picker")) { const uiResults = {}; for (let vocab of shared.vocab) { const analysis = analyzeVocab(vocab); const isEasy = analysis !== undefined && analysis.reduce((p, c) => p && c.primary && !c.rendaku, true); let isNewReading = false; let hasRendaku = false; if (!isEasy) { if (analysis) { for (const kanji of analysis) { if (kanji.rendaku) { hasRendaku = true; } else if (!kanji.primary) { const cachedReadings = shared.readingsCache[kanji.id]; if (!cachedReadings || !cachedReadings.has(kanji.reading)) { isNewReading = true; break; } } } } else { isNewReading = true; } } uiResults[vocab.id] = { isEasy, hasRendaku, isNewReading }; } annotateVocabInLessonPicker(uiResults); } } // Returns [kanjiMatch] function analyzeVocab(vocab) { const data = vocab.data; const kanjiReadings = getKanjiReadings(data.component_subject_ids); for (let reading of data.readings) { if (reading.primary && reading.accepted_answer) { const tokens = getCharacterTokens(data.characters); const kanjiMatches = matchKanjiReadings(tokens, reading.reading, kanjiReadings); return kanjiMatches; } } } // Returns an object of <kanji character> -> { primaryReading[], secondaryReading[] } function getKanjiReadings(kanjiIds) { const kanjiById = wkof.ItemData.get_index(shared.kanji, "subject_id"); let kanjiReadings = {}; for (let id of kanjiIds) { let primaryReadings = []; let secondaryReadings = []; const kanji = kanjiById[id].data; for (let reading of kanji.readings) { if (reading.primary && reading.accepted_answer) { primaryReadings.push(reading.reading); } else { secondaryReadings.push(reading.reading); } } kanjiReadings[kanji.characters] = { id, primary: primaryReadings, secondary: secondaryReadings }; } return kanjiReadings; } function getCharacterTokens(characters) { let result = []; const tokens = wanakana.tokenize(characters, { detailed: true }); for (let token of tokens) { if (token.type === "kanji") { // The tokenizer returns strings of subsequent kanji as a single token, e.g. 地中海. Split them const subTokens = [...token.value]; for (let sub of subTokens) { result.push({ type: "kanji", value: sub }); } } else { result.push(token); } } return result; } function matchKanjiReadings(tokens, reading, kanjiReadings, lastChosenReading) { if (tokens.length == 0) { return reading.length == 0 ? [] : undefined; } const cToken = tokens[0]; if (cToken.type === "kanji") { // Check which reading this is const kReadings = kanjiReadings[cToken.value]; if (cToken.value === "々") { // This is a repeater of the previous reading if (reading.startsWith(lastChosenReading)) { const subResult = matchKanjiReadings(tokens.slice(1), reading.slice(lastChosenReading.length), kanjiReadings, lastChosenReading); if (subResult !== undefined) { return [{ id: kReadings.id, character: cToken.value, reading: lastChosenReading, primary: true }, ...subResult]; } } } for (let primary of kReadings.primary) { const match = matchReading(reading, primary); if (match.match) { const subResult = matchKanjiReadings(tokens.slice(1), reading.slice(primary.length), kanjiReadings, primary); if (subResult !== undefined) { return [{ id: kReadings.id, character: cToken.value, reading: primary, primary: true, rendaku: match.rendaku }, ...subResult]; } } } for (let secondary of kReadings.secondary) { const match = matchReading(reading, secondary); if (match.match) { const subResult = matchKanjiReadings(tokens.slice(1), reading.slice(secondary.length), kanjiReadings, secondary); if (subResult !== undefined) { return [ { id: kReadings.id, character: cToken.value, reading: secondary, primary: false, rendaku: match.rendaku }, ...subResult, ]; } } } return undefined; } else if (cToken.type === "hiragana" || cToken.type === "katakana") { const length = cToken.value.length; if (length > reading.length) { // This is a character vs reading mismatch due to a non-matching kanji return undefined; } return matchKanjiReadings(tokens.slice(1), reading.slice(length), kanjiReadings); } else if (cToken.type === "japanesePunctuation" && cToken.value === "ー") { // Long vowel kana return matchKanjiReadings(tokens.slice(1), reading.slice(1), kanjiReadings); } else { // Skip this token, it doesn't participate in the reading return matchKanjiReadings(tokens.slice(1), reading, kanjiReadings); } } function matchReading(reading, candidate) { if (reading.startsWith(candidate)) { return { match: true, rendaku: false }; } const firstKana = candidate[0]; if (candidate.length > 1) { const lastKana = candidate[candidate.length - 1]; // Try rendaku suffix const suffixCandidate = RendakuSuffixCandidates[lastKana]; if (suffixCandidate !== undefined) { const newCandidate = candidate.slice(0, candidate.length - 1) + suffixCandidate; if (reading.startsWith(newCandidate)) { return { match: true, rendaku: true }; } } } // Try rendaku prefix const prefixCandidates = RendakuPrefixCandidates[firstKana]; if (prefixCandidates !== undefined) { for (const rendaku of prefixCandidates) { const newCandidate = rendaku + candidate.slice(1); if (reading.startsWith(newCandidate)) { return { match: true, rendaku: true }; } } } return { match: false, rendaku: false }; } // ==================================================================================== function annotateVocabInLessonPicker(vocabResults) { const subjectElements = document.querySelectorAll("[data-subject-id]"); for (let element of subjectElements) { const id = element.getAttribute("data-subject-id"); if (id in vocabResults) { const target = element.firstElementChild.firstElementChild.firstElementChild; if (vocabResults[id].isEasy) { target.style.color = shared.settings.easyColor; } else if (vocabResults[id].isNewReading) { target.style.color = shared.settings.newColor; } else if (vocabResults[id].hasRendaku) { target.style.color = shared.settings.rendakuColor; } else { target.style.color = shared.settings.secondaryColor; } } } } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址