您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enhance the context sentence section, highlighting kanji and adding audio
// ==UserScript== // @name Advanced Context Sentence 2 // @namespace advancedContextSentence // @version 2.25 // @description Enhance the context sentence section, highlighting kanji and adding audio // @author Sinyaven // @license MIT // @match https://www.wanikani.com/* // @match https://preview.wanikani.com/* // @require https://unpkg.com/@popperjs/[email protected]/dist/umd/popper.min.js // @require https://unpkg.com/[email protected]/dist/tippy-bundle.umd.min.js // @require https://gf.qytechs.cn/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=1492607 // @require https://gf.qytechs.cn/scripts/489759-wk-custom-icons/code/CustomIcons.js?version=1417568 // @supportURL https://community.wanikani.com/t/55837 // @grant none // ==/UserScript== // Original script by abdullahalt (https://openuserjs.org/users/abdullahalt) (() => { "use strict"; /* global wkItemInfo, Icons, tippy */ /* eslint no-multi-spaces: off */ //--------------------------------------------------------------------------------------------------------------// //-----------------------------------------------INITIALIZATION-------------------------------------------------// //--------------------------------------------------------------------------------------------------------------// const wkof = window.wkof; const cssNamespace = "advanced-context-sentence"; const scriptId = "AdvancedContextSentence"; const scriptName = "Advanced Context Sentence"; const recognizedSelector = `.${cssNamespace} a.recognized`; const unrecognizedSelector = `.${cssNamespace} a.unrecognized`; let state = { settings: { recognizedKanjiColor: "#f100a1", unrecognizedKanjiColor: "#888888", recognitionLevel: "5", tooltip: { show: true, delay: 0, position: "top" }, voice: "browser", soundVolume: 100, ttsHighlightWord: true, showSentenceLink: true, sentenceLink: "https://ichi.moe/cl/qr/?q=%s&r=kana" }, kanjis: [], jiff: false // JLPT, Joyo and Frequency Filters }; window.speechSynthesis?.getVoices(); // should initiate loading of voices in Chromium so that once the button is clicked, speechSynthesis.getVoices() actually returns the complete list of available voices // Application start Point main(); async function main() { await init(); wkItemInfo.forType("vocabulary", "kanaVocabulary").under("examples").notifyWhenVisible(evolveContextSentence); } async function init() { createReferrer(); createStyle(); if (wkof) { wkof.include("ItemData,Settings"); await wkof .ready("ItemData,Settings") .then(loadSettings) .then(proccessLoadedSettings) .then(getKanji) .then(extractKanjiFromResponse); } else { console.warn( `${scriptName}: You are not using Wanikani Open Framework. Some functionality will be unavailable: 1. Highlighting of learned kanji, 2. Settings dialog` ); } } function evolveContextSentence() { let sentences = document.querySelectorAll(".subject-section__text--grouped, .subject-section__content > .context-sentences"); let patternSentences = document.querySelectorAll(".subject-collocations .context-sentences"); let allSentences = [...sentences, ...patternSentences]; if (wkof) evolveHeader(sentences[0].previousElementSibling || sentences[0].parentElement.previousElementSibling); allSentences.forEach(s => s.classList.add(cssNamespace)); allSentences.forEach(sentence => { const japaneseSentence = sentence.querySelector('p[lang="ja"]'); const sentenceText = japaneseSentence.textContent; const sentenceLink = createSentenceLink(sentenceText); const audioButton = createAudioButton(sentenceText); const chars = [...sentenceText]; const newNodes = chars.map(char => tagAndLinkKanji(char)); japaneseSentence.replaceChildren(...newNodes, sentenceLink, audioButton); }); highlightKanji(); } function evolveHeader(header) { if (header.querySelector(".wk-icon")) return; const settings = Icons.customIcon("settings"); settings.style = "font-size: 14px; cursor: pointer; margin-left: 10px;"; settings.onclick = openSettings; header.append(settings); } function createSentenceLink(sentence) { let result = document.createElement("a"); let icon = Icons.customIcon("srs-up"); icon.style = "transform: rotate(45deg);"; result.classList.add("sentence-link"); result.href = state.settings.sentenceLink.replace("%s", sentence); result.target = "_blank"; result.append(icon); return result; } function updateSentenceLinks() { document.body.classList.toggle("advanced-context-sentence-hide-sentence-link", !state.settings.showSentenceLink); document.querySelectorAll(`.${cssNamespace} .sentence-link`).forEach(l => { l.href = state.settings.sentenceLink.replace("%s", l.parentElement.textContent); }); } function recreateAudioButtons() { document.querySelectorAll(`.${cssNamespace} > p > span:last-child, .${cssNamespace} > p > button:last-child`).forEach(audioButton => audioButton.remove()); const sentences = document.querySelectorAll(`.${cssNamespace}`); sentences.forEach(sentence => { const japaneseSentence = sentence.querySelector('p[lang="ja"]'); const sentenceText = japaneseSentence.textContent; const audioButton = createAudioButton(sentenceText); japaneseSentence.append(audioButton); }); } function createAudioButton(sentence) { if (state.settings.voice === "google") { return createAudioButtonGoogleTL(sentence); } else { return createAudioButtonSpeechSynthesis(sentence); } } /** * To fix a weird issue that occurs in the session pages (where all audios play * if the audio for reading the word is clicked), * we have to create the audio element only for the time of palying the audio * and remove it afterward * @param {*} sentence */ function createAudioButtonGoogleTL(sentence) { const audioIdleClass = "sound-off"; const audioPlayClass = "sound-on"; // contains audio and button as sibiling elements const audioContainer = document.createElement("span"); const mpegSource = createSource("audio/mpeg", sentence); const oogSource = createSource("audio/oog", sentence); const button = document.createElement("button"); const soundOn = Icons.customIcon("sound-on"); const soundOff = Icons.customIcon("sound-off"); button.classList.add("audio-btn", audioIdleClass); button.append(soundOn, soundOff); button.onclick = () => { if (audioContainer.childElementCount > 1) { const audio = audioContainer.querySelector("audio"); audio.pause(); button.classList.replace(audioPlayClass, audioIdleClass); audio.remove(); return; } const audio = document.createElement("audio"); audio.append(mpegSource, oogSource); audio.volume = state.settings.soundVolume / 100; audio.onplay = () => { button.classList.replace(audioIdleClass, audioPlayClass); }; audio.onended = () => { button.classList.replace(audioPlayClass, audioIdleClass); audio.remove(); }; audioContainer.append(audio); audio.play(); }; audioContainer.append(button); return audioContainer; } function createAudioButtonSpeechSynthesis(sentence) { if (!window.SpeechSynthesisUtterance) { console.warn(`${scriptName}: your browser does not support SpeechSynthesisUtterance. Switch to Google Translate, update your browser, or use a different one if you want that feature`); return null; } const audioIdleClass = "sound-off"; const audioPlayClass = "sound-on"; const button = document.createElement("button"); const soundOn = Icons.customIcon("sound-on"); const soundOff = Icons.customIcon("sound-off"); button.classList.add("audio-btn", audioIdleClass); button.append(soundOn, soundOff); let msg = null; button.onclick = () => { if (!msg) { msg = new SpeechSynthesisUtterance(sentence); let voice = selectVoice(state.settings.voice); if (voice) { msg.voice = voice; }else { msg.lang = "ja-JP"; } msg.volume = state.settings.soundVolume / 100; msg.onstart = () => button.classList.replace(audioIdleClass, audioPlayClass); msg.onend = () => { button.classList.replace(audioPlayClass, audioIdleClass); highlightSection(button.parentElement, 0, 0); }; if (state.settings.ttsHighlightWord) msg.onboundary = e => highlightSection(button.parentElement, e.charIndex, e.charIndex + e.charLength); } window.speechSynthesis.speak(msg); }; return button; } function getVoiceList() { let voices = window.speechSynthesis?.getVoices() || []; voices = [voices.filter(v => v.lang.startsWith("ja-")), voices].find(a => a.length > 0) || []; voices = voices.map(v => [v.name, simplifyVoiceName(v.name, v.localService)]); // fall back to original name if the simplification leads to duplicates voices.forEach(v0 => { if (v0[1].trim() === "" || voices.reduce((sum, v1) => sum + (v0[1] === v1[1] ? 1 : 0), 0) > 1) { v0[1] = v0[0]; } }); if (voices.length === 0) voices = [["browser", "Web Browser (no Japanese voice detected)"]]; return voices; } function selectVoice(name) { let voices = window.speechSynthesis?.getVoices() || []; voices = [voices.filter(v => v.lang.startsWith("ja-")), voices].find(a => a.length > 0) || []; voices = [voices.filter(v => v.name === name ), voices].find(a => a.length > 0) || []; return voices[0]; } function simplifyVoiceName(name, localService) { return (localService ? "[local] " : "[online] ") + name.replace(/\bjapan(?:ese)?\b|\bnatural\b|\bonline\b/gi, "").replace(/[\s()-]+$|^[\s()-]+/g, ""); } //--------------------------------------------------------------------------------------------------------------// //----------------------------------------------SETTINGS--------------------------------------------------------// //--------------------------------------------------------------------------------------------------------------// function loadSettings() { return wkof.Settings.load(scriptId, state.settings); } function proccessLoadedSettings() { state.settings = wkof.settings[scriptId]; document.body.classList.toggle("advanced-context-sentence-hide-sentence-link", !state.settings.showSentenceLink); } function openSettings() { const voiceList = Object.fromEntries([...getVoiceList(), ["google", "[online] Google Translate"]]); var config = { script_id: scriptId, title: scriptName, pre_open: updateDialog, on_save: updateSettings, content: { highlightColors: { type: "section", label: "Highlights" }, recognizedKanjiColor: { type: "color", label: "Recognized Kanji", hover_tip: "Kanji you should be able to recognize will be highlighted using this color", default: state.settings.recognizedKanjiColor }, unrecognizedKanjiColor: { type: "color", label: "Unrecognized Kanji", hover_tip: "Kanji you shouldn't be able to recognize will be highlighted using this color", default: state.settings.unrecognizedKanjiColor }, recognitionLevel: { type: "dropdown", label: "Recognition Level", hover_tip: "Any kanji with this SRS level or higher will be highlighted with the 'Recognized Kanji' color", default: state.settings.recognitionLevel, content: { 1: stringifySrs(1), 2: stringifySrs(2), 3: stringifySrs(3), 4: stringifySrs(4), 5: stringifySrs(5), 6: stringifySrs(6), 7: stringifySrs(7), 8: stringifySrs(8), 9: stringifySrs(9) } }, tooltip: { type: "section", label: "Tooltip" }, show: { type: "checkbox", label: "Show Tooltip", hover_tip: "When hovering over kanji, show a tooltip that will display some of its properties", default: state.settings.tooltip.show, path: "@tooltip.show" }, delay: { type: "number", label: "Delay", hover_tip: "Delay in milliseconds before the tooltip is shown", default: state.settings.tooltip.delay, path: "@tooltip.delay" }, position: { type: "dropdown", label: "Position", hover_tip: "The placement of the tooltip", default: state.settings.tooltip.position, path: "@tooltip.position", content: { top: "Top", bottom: "Bottom", right: "Right", left: "Left" } }, voiceSection: { type: "section", label: "Voice" }, voice: { type: "dropdown", label: "Voice", hover_tip: "Select the machine voice that reads the sentence aloud", default: state.settings.voice, content: voiceList, on_change: updateDialog }, soundVolume: { type: "input", subtype: "range", label: "Volume", hover_tip: "Select how loud the text-to-speech output should be", default: state.settings.soundVolume }, ttsHighlightWord: { type: "checkbox", label: "Highlight word", hover_tip: "Highlight the section of the sentence that the synthetic voice is currently reading. Not available for the Google Translate voice" }, sentenceLinkSection: { type: "section", label: "Weblink" }, showSentenceLink: { type: "checkbox", label: "Show weblink", hover_tip: "Show a weblink after the sentence" }, sentenceLink: { type: "text", label: "Weblink", hover_tip: "An URL with %s as a placeholder for the japanese sentence." } } }; var dialog = new wkof.Settings(config); dialog.open(); } function updateDialog() { document.getElementById("AdvancedContextSentence_ttsHighlightWord").disabled = wkof.settings.AdvancedContextSentence.voice === "google"; } // Called when the user clicks the Save button on the Settings dialog. function updateSettings() { state.settings = wkof.settings[scriptId]; highlightKanji(); recreateAudioButtons(); updateSentenceLinks(); } //---------------------------------------------------------------------------------------------------------------// //-------------------------------------------HELPER FUNCTIONS----------------------------------------------------// //---------------------------------------------------------------------------------------------------------------// function tagAndLinkKanji(char) { return isKanji(char) ? wrapInAnchor(char) : wrapInSpan(char); } /** * Determine if the character is a Kanji, inspired by https://stackoverflow.com/a/15034560 */ function isKanji(char) { return isCommonOrUncommonKanji(char) || isRareKanji(char); } function isCommonOrUncommonKanji(char) { return char >= "\u4e00" && char <= "\u9faf"; } function isRareKanji(char) { return char >= "\u3400" && char <= "\u4dbf"; } /** * Renders the link for the kanji * Kanji pages always use https://www.wanikani.com/kanji/{kanji} where {kanji} is the kanji character */ function wrapInAnchor(char) { const anchor = document.createElement("a"); anchor.target = "_blank"; anchor.classList.add("recognized"); if (!wkof) { anchor.href = `https://www.wanikani.com/kanji/${char}`; anchor.textContent = char; return anchor; } const kanji = state.kanjis.find(item => item.char == char); anchor.dataset.srs = kanji ? kanji.srs : -1; anchor.dataset.kanji = char; anchor.href = kanji ? kanji.url : `https://jisho.org/search/${char}`; anchor.textContent = char; return anchor; } function wrapInSpan(char) { const span = document.createElement("span"); span.textContent = char; return span; } function highlightSection(sentenceContainer, start, end) { [...sentenceContainer.children].forEach((element, i) => { element.classList.toggle("tts-focus", i >= start && i < end); }); } function createTooltip(kanji) { if (!wkof) { const container = document.createElement("span"); return container; } const container = document.createElement("div"); container.classList.add("acs-tooltip"); if (!kanji) { const span = document.createElement("span"); span.textContent = "Wanikani doesn't have this kanji! :("; container.append(span); return container; } const onyomi = kanji.readings.filter(r => r.type === "onyomi").map(r => r.reading).join(", "); const kunyomi = kanji.readings.filter(r => r.type === "kunyomi").map(r => r.reading).join(", "); const meaning = kanji.meanings .map(m => m.meaning).join(", "); container.append(generateInfo("LV", kanji.level)); container.append(generateInfo("EN", meaning)); if ( onyomi !== "None" && onyomi !== "") container.append(generateInfo("ON", onyomi)); if (kunyomi !== "None" && kunyomi !== "") container.append(generateInfo("KN", kunyomi)); container.append(generateInfo("SRS", stringifySrs(kanji.srs))); if (state.jiff) { container.append(generateInfo("JOYO", kanji.joyo)); container.append(generateInfo("JLPT", kanji.jlpt)); container.append(generateInfo("FREQ", kanji.frequency)); } return container; } function stringifySrs(srs) { switch (srs) { case -1: return "Locked"; case 0: return "Ready To Learn"; case 1: return "Apprentice 1"; case 2: return "Apprentice 2"; case 3: return "Apprentice 3"; case 4: return "Apprentice 4"; case 5: return "Guru 1"; case 6: return "Guru 2"; case 7: return "Master"; case 8: return "Enlightened"; case 9: return "Burned"; default: return ""; } } function generateInfo(title, info) { const container = document.createElement("div"); const key = document.createElement("span"); key.classList.add("acs-tooltip-header"); const value = document.createElement("span"); key.textContent = title; value.textContent = info; container.append(key, " ", value); return container; } function getKanji() { const filters = { item_type: ["kan"] }; if (wkof.get_state("JJFFilters") === "ready") { state.jiff = true; filters.include_frequency_data = true; filters.include_jlpt_data = true; filters.include_joyo_data = true; } else { console.warn( `${scriptName}: You don't have Open Framework JLPT Joyo and Frequency Filters by @Kumirei installed (version 0.1.4 or later). Install the script if you want to get more information while hovering over kanji in context sentences. Script URL: https://community.wanikani.com/t/35096` ); } return wkof.ItemData.get_items({ wk_items: { options: { assignments: true }, filters } }); } function extractKanjiFromResponse(items) { state.kanjis = items.map(item => ({ char: item.data.characters, readings: item.data.readings, level: item.data.level, meanings: item.data.meanings, url: item.data.document_url, srs: item.assignments ? item.assignments.srs_stage : -1, jlpt: item.jlpt_level, joyo: item.joyo_grade, frequency: item.frequency }) ); } function createSource(type, sentence) { const source = document.createElement("source"); source.type = type; source.src = `https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=ja&total=1&idx=0&q=${encodeURIComponent(sentence)}`; return source; } let tippys = new Set(); function highlightKanji() { const rules = document.querySelector("#acs-style").sheet.cssRules; rules[0].style.color = state.settings.recognizedKanjiColor; rules[1].style.color = state.settings.unrecognizedKanjiColor; if (!wkof) return; tippys.forEach(t => t.destroy()); tippys = new Set(); const anchors = document.querySelectorAll(`.${cssNamespace} a:not(.sentence-link)`); anchors.forEach(anchor => { const srs = anchor.getAttribute("data-srs"); const char = anchor.getAttribute("data-kanji"); anchor.classList.remove("recognized", "unrecognized"); if (srs >= state.settings.recognitionLevel) { anchor.classList.add("recognized"); } else { anchor.classList.add("unrecognized"); } if (state.settings.tooltip.show) { const kanji = state.kanjis.find(item => item.char == char); const tooltip = createTooltip(kanji); tippy(anchor, { content: tooltip, size: "small", arrow: true, placement: state.settings.tooltip.position, delay: [state.settings.tooltip.delay, 20] }); tippys.add(anchor._tippy); } }); } // Necessary in order for audio to work function createReferrer() { const remRef = document.createElement("meta"); remRef.name = "referrer"; remRef.content = "same-origin"; document.head.append(remRef); } // Styles function createStyle() { const style = document.createElement("style"); style.id = "acs-style"; style.textContent = ` /* Kanji */ /* It's important for this one to be the first rule*/ ${recognizedSelector} { } /* It's important for this one to be the second rule*/ ${unrecognizedSelector} { } .${cssNamespace} p a, .${cssNamespace} p a:hover { text-decoration: none; } .${cssNamespace} .sentence-link { color: #a2a2a2; } body.advanced-context-sentence-hide-sentence-link .${cssNamespace} a.sentence-link { display: none; } .${cssNamespace} .audio-btn { cursor: pointer; margin-left: 0.2em; background: none; } .${cssNamespace} .audio-btn.sound-off svg:first-of-type, .${cssNamespace} .audio-btn.sound-on svg:last-of-type { display: none; } .${cssNamespace} .tts-focus { color: var(--inverted-text-color, white); background-color: var(--text-color, #333); } .acs-tooltip { text-align: left; } .acs-tooltip-header { color: #929292; } `; document.head.append(style); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址