// ==UserScript==
// @name WaniKani JJ External Definition
// @namespace http://www.wanikani.com
// @version 1.4.2
// @description Get JJ External Definition from Weblio, Kanjipedia
// @author polv
// @author NicoleRauch
// @match *://www.wanikani.com/*
// @match *://preview.wanikani.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=weblio.jp
// @license MIT
// @require https://unpkg.com/dexie@3/dist/dexie.js
// @require https://gf.qytechs.cn/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=1276163
// @grant GM_xmlhttpRequest
// @connect kanjipedia.jp
// @connect weblio.jp
// @homepage https://github.com/patarapolw/wanikani-userscript/blob/master/userscripts/external-definition.user.js
// ==/UserScript==
// @ts-check
/// <reference path="./types/item-info.d.ts" />
/// <reference path="./types/gm.d.ts" />
(function () {
'use strict';
const AFTER_EN_MEANING = false;
/** @type {number | undefined} */
const MAX_ENTRIES = 3;
/** @type {number | undefined} */
const HTML_MAX_CHAR = 10000;
const entryClazz = 'wkexternaldefinition';
const style = document.createElement('style');
style.appendChild(
document.createTextNode(/* css */ `
.${entryClazz} {
--kanji-variant-size: 64px;
}
.${entryClazz} details {
margin-top: 1em;
}
.${entryClazz} details summary {
display: revert;
margin-bottom: 1em;
cursor: pointer;
}
.${entryClazz} .spoiler:not(:hover), .${entryClazz} .spoiler:not(:hover) * {
background-color: #ccc;
color: #ccc;
text-shadow: none;
}
.${entryClazz} .keep-10em {
display: inline-block;
width: 10em;
min-width: fit-content;
max-width: 100%;
}
/* Weblio fixes */
.${entryClazz} p {
margin-bottom: 0.5em;
}
.${entryClazz} a.crosslink {
color: #023e8a;
}
.${entryClazz} a {
text-decoration: none;
}
.${entryClazz} a.external {
text-decoration: underline;
}
.${entryClazz} ol {
list-style: revert;
padding: revert;
}
.${entryClazz} ul {
list-style: revert;
padding: revert;
}
.${entryClazz} .wnryjNotice {
border: #b5b6b5 solid 1px;
font-size: 0.8em;
line-height: 1.32em;
margin: 16px 0 0 0;
padding: 10px;
width: auto;
}
.${entryClazz} .SgkdjImg img {
width: 40%;
height: 40%;
}
.${entryClazz} .synonymsUnderDictWrp {
margin-top: 1em;
}
.${entryClazz} .synonymsUnderDict {
background-color: #f7f7f7;
clear: both;
margin: 0 0 0 8px;
padding: 2px 8px;
}
.${entryClazz} .synonymsUnderDict a {
padding-right: 1em;
}
.${entryClazz} .tssmjC {
background-color: #f0f0f0;
border: #666666 solid 1px;
color: #363636;
font-size: 0.9em;
line-height: 1.0em;
margin-right: 5px;
padding: 1px;
}
/* Kanjipedia fixes */
.${entryClazz}-kanjipedia-reading-horizontal * {
display: inline-block;
}
.${entryClazz}-kanjipedia-reading-horizontal li {
margin-right: 1em;
}
.${entryClazz}-kanjipedia-reading-horizontal .kanji-variant-header {
display: none;
}
.${entryClazz} .kanji-variant {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
font-size: var(--kanji-variant-size, 64px);
font-family: "HiraMinProN-W3", "Hiragino Mincho ProN W3", "Hiragino Mincho ProN", "ヒラギノ明朝 ProN W3", "游明朝", YuMincho, "HG明朝E", "MS P明朝", "MS PMincho", "MS 明朝", "MS Mincho", serif; /* Font list from Jisho.org */
margin-top: 0;
margin-bottom: 0;
}
.${entryClazz} .kanji-variant img {
height: var(--kanji-variant-size, 64px);
}
.${entryClazz} .kanji-variant + .kanji-variant {
margin-left: 1em;
}
.${entryClazz} .okurigana {
color: #ab9b96;
}
@media only screen and (min-width: 768px) {
.subject-readings__reading {
flex: 1;
}
}
`),
);
document.head.appendChild(style);
const radicalMap = {
Gun: ['𠂉'],
Leaf: ['丆'],
Beggar: ['丂'],
Spikes: ['业'],
Kick: ['𧘇'],
Viking: ['𤇾', '𦥯'],
Cape: ['𠃌'],
Hills: [],
Gladiator: ['龹'],
Pope: [],
Spring: ['𡗗'],
Squid: ['㑒', '僉'],
Yurt: [],
Chinese: ['𦰩', '堇'],
Bear: ['㠯'],
Blackjack: ['龷'],
Trash: ['𠫓'],
Tofu: [],
Creeper: [],
Bar: ['㦮', '戔'],
Saw: ['巩'],
Zombie: ['袁'],
Explosion: [],
Morning: ['𠦝', '龺'],
'Death Star': ['俞'],
Comb: [],
Elf: [],
Coral: ['丞'],
Cactus: [],
Satellite: ['䍃'],
Psychopath: ['鬯'], // Except this one; but it's a smaller radical not elsewhere described.
};
///////////////////////////////////////////////////////////////////////////////////////////////////
// @ts-ignore
const _Dexie = /** @type {typeof import('dexie').default} */ (Dexie);
/**
* @typedef {{ id: string; url: string; definition: string; reading: string; variant: string }} EntryKanjipedia
* @typedef {{ id: string; url: string; definitions: string[] }} EntryWeblio
*/
class Database extends _Dexie {
/** @type {import('dexie').Table<EntryKanjipedia, string>} */
kanjipedia;
/** @type {import('dexie').Table<EntryWeblio, string>} */
weblio;
constructor() {
super(entryClazz);
this.version(1).stores({
kanjipedia: 'id,url',
weblio: 'id,url',
});
}
}
const db = new Database();
///////////////////////////////////////////////////////////////////////////////////////////////////
// Updating the kanji and vocab we are looking for
/** @type {string | undefined} */
let kanji;
/** @type {string | undefined} */
let vocab;
let isSuru = false;
let isSuffix = false;
/** @type {string[]} */
let reading = [];
let kanjipediaDefinition;
let weblioDefinition;
let kanjipediaReading;
let qType = '';
let sType = '';
window.addEventListener('willShowNextQuestion', (e) => {
// First, remove any already existing entries to avoid displaying entries for other items:
document.querySelectorAll('.' + entryClazz).forEach((el) => el.remove());
kanji = undefined;
vocab = undefined;
reading = [];
qType = '';
kanjipediaDefinition = undefined;
kanjipediaReading = undefined;
weblioDefinition = undefined;
if ('detail' in e) {
const { subject, questionType } = /** @type {any} */ (e.detail);
qType = questionType;
sType = subject.subject_category || subject.type;
if (sType === 'Vocabulary') {
vocab = fixVocab(subject.characters);
reading = subject.readings
? subject.readings.map((r) => r.reading)
: [subject.characters];
} else {
kanji =
typeof subject.characters === 'string'
? subject.characters
: getRadicalKanji(subject.meanings);
}
}
updateInfo();
});
/**
*
* @param {string} v
* @returns
*/
function fixVocab(v) {
const suru = 'する';
isSuru = v.endsWith(suru) && v !== suru;
if (isSuru) {
v = v.substring(0, v.length - suru.length);
reading = reading.map((r) => r.replace(new RegExp(suru + '$'), ''));
}
const extMark = '〜';
isSuffix = v.startsWith(extMark);
if (isSuffix) {
v = v.substring(extMark.length);
}
return v.replace(/(.)々/g, '$1$1');
}
///////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Loading the information and updating the webpage
*
* @returns {Promise<void>}
*/
async function updateInfo() {
/**
*
* @param {string} definition
* @param {string} full_url
* @param {string} name
* @returns {string}
*/
function insertDefinition(definition, full_url, name) {
const output = document.createElement('div');
output.className = entryClazz;
output.lang = 'ja';
output.innerHTML = definition;
if (full_url) {
const a = document.createElement('a');
a.className = 'external';
a.innerText = 'Click for full entry';
a.href = full_url;
const p = document.createElement('p');
p.style.marginTop = '0.5em';
p.append(a);
output.append(p);
}
output.querySelectorAll('a').forEach((a) => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
if (name === 'Kanjipedia') {
kanjipediaDefinition = output;
kanjipediaInserter.renew();
} else {
weblioDefinition = output;
weblioInserter.renew();
}
return output.outerHTML;
}
/**
*
* @param {string} kanji
* @returns {Promise<string>}
*/
async function searchKanjipedia(kanji) {
/**
*
* @param {EntryKanjipedia} r
*/
const setContent = (r) => {
kanjipediaReading = r.reading;
let htmlVar = '';
if (r.variant) {
r.variant = r.variant.trim();
if (!r.variant.startsWith('<')) {
r.variant = `<div>${r.variant}</div>`;
}
const el = document.createElement('div');
el.innerHTML = r.variant;
el.querySelectorAll('img').forEach((it) => {
it.removeAttribute('style');
});
htmlVar = [
'<li class="kanji-variant-header">異体字</li>',
`<div class="kanji-variant">${el.innerHTML}</div>`,
].join('\n');
kanjipediaReading += htmlVar;
}
kanjipediaReadingInserter.renew();
return insertDefinition(
(qType === 'meaning' && sType !== 'Radical'
? htmlVar
: `<ul class="${entryClazz}-kanjipedia-reading-horizontal">${kanjipediaReading}</ul>`) +
r.definition
.split('<br>')
.map((s) => `<p>${s}</p>`)
.join('\n'),
r.url,
'Kanjipedia',
);
};
const r = await db.kanjipedia.get(kanji);
if (r) {
return setContent(r);
}
const kanjipediaUrlBase = 'https://www.kanjipedia.jp/';
const regexImgSrc = /img src="/g;
const replacementImgSrc = 'img width="16px" src="' + kanjipediaUrlBase;
const regexTxtNormal = /class="txtNormal">/g;
const replacementTxtNormal = '>.';
const regexSpaceBeforeCircledNumber = / ([\u2460-\u2473])/g;
return new Promise((resolve, reject) => {
function onerror(e) {
(window.unsafeWindow || window).console.error(arguments);
reject(e);
}
GM_xmlhttpRequest({
method: 'GET',
url: kanjipediaUrlBase + 'search?k=' + kanji + '&kt=1&sk=leftHand',
onerror,
onload: function (data) {
const div = document.createElement('div');
div.innerHTML = data.responseText.replace(
regexImgSrc,
replacementImgSrc,
);
const firstResult = /** @type {HTMLAnchorElement} */ (
div.querySelector('#resultKanjiList a')
);
if (!firstResult) {
resolve('');
return;
}
const rawKanjiURL = firstResult.href;
const kanjiPageURL = kanjipediaUrlBase + rawKanjiURL.slice(25);
GM_xmlhttpRequest({
method: 'GET',
url: kanjiPageURL,
onerror,
onload: function (data) {
const rawResponseNode = document.createElement('div');
rawResponseNode.innerHTML = data.responseText
.replace(regexImgSrc, replacementImgSrc)
.replace(regexTxtNormal, replacementTxtNormal)
.replace(regexSpaceBeforeCircledNumber, '<br/>$1');
const readingNode = rawResponseNode.querySelector(
'#kanjiLeftSection #onkunList',
);
if (!readingNode) return;
// Okurigana dot removal, so that it can be read as a vocabulary with Yomichan
readingNode.querySelectorAll('span').forEach((it) => {
const text = it.innerText;
if (text[0] === '.') {
it.innerText = text.substring(1);
it.classList.add('okurigana');
it.style.color = '#ab9b96';
}
});
const r = {
id: kanji,
url: kanjiPageURL,
reading: readingNode.innerHTML,
definition: Array.from(
rawResponseNode.querySelectorAll('#kanjiRightSection p'),
)
.map((p) => p.innerHTML)
.join('\n'),
variant: (() => {
const vs = [
...rawResponseNode.querySelectorAll('#kanjiOyaji'),
...rawResponseNode.querySelectorAll('.subKanji'),
].filter(
(n) => n.textContent !== decodeURIComponent(kanji || ''),
);
if (!vs.length) return '';
vs.map((v) => {
v.classList.add('kanji-variant');
v.querySelectorAll('img').forEach((img) => {
img.removeAttribute('width');
});
});
return vs.map((v) => v.innerHTML).join('\n');
})(),
};
db.kanjipedia.add(r);
resolve(setContent(r));
},
});
},
});
});
}
/**
*
* @param {string} vocab
* @returns {Promise<string>}
*/
async function searchWeblio(vocab) {
/**
*
* @param {EntryWeblio} [r]
*/
const setContent = (r) => {
if (!r || !r.definitions.length) {
if (kanji) {
return insertDefinition(
`No entries found. Try <a class="external" href="https://en.wiktionary.org/wiki/${kanji}" target="_blank" rel="noopener noreferrer">${kanji} - Wiktionary</a>`,
'',
'Wiktionary',
);
}
return '';
}
const reYomi = /(読み方:)([\p{sc=Katakana}\p{sc=Hiragana}ー]+)/gu;
const makeYomiSpoiler = (s) =>
qType === 'meaning' && sType !== 'Radical'
? s
.replace(reYomi, '$1<span class="spoiler keep-10em">$2</span>')
.replace(/<p(>.*?[[音訓]].*?<\/p>)/s, '<p class="spoiler"$1')
: s;
const sortedDef = r.definitions
.sort((t1, t2) => {
/**
*
* @param {string} t
* @returns {number}
*/
const fn = (t) => {
let isKanji = /[[音訓]]/.exec(t);
if (kanji && isKanji) return -10;
reYomi.lastIndex = 0;
const m = reYomi.exec(t);
if (m) {
if (!reading.length) return 0;
if (isKanji) return reading.length;
let readingIdx = reading.indexOf(m[2]);
if (readingIdx === -1) return 100;
if (isSuffix && t.includes('接尾')) {
readingIdx -= 0.5;
}
if (isSuru && t.includes('スル')) {
readingIdx -= 0.5;
}
return readingIdx;
}
return 1000;
};
return fn(t1) - fn(t2);
})
.map((html) => {
if (!HTML_MAX_CHAR || html.length < HTML_MAX_CHAR) {
return makeYomiSpoiler(html);
}
const div = document.createElement('div');
div.innerHTML = makeYomiSpoiler(html.substring(0, HTML_MAX_CHAR));
const mark = document.createElement('mark');
mark.style.cursor = 'pointer';
mark.setAttribute('data-html', html);
mark.textContent = '...';
html = div.outerHTML.replace(
/<\/div>$/,
mark.outerHTML.replace(
/^<mark /,
'$&' +
'onclick="parentElement.innerHTML=getAttribute(\'data-html\')" ',
) + '$&',
);
div.remove();
return html;
});
let vocabDefinition = sortedDef.splice(0, MAX_ENTRIES).join('<hr>');
if (sortedDef.length) {
vocabDefinition += `<details><summary>Show more</summary>${sortedDef.join(
'<hr>',
)}</details>`;
}
return insertDefinition(vocabDefinition, r.url, 'Weblio');
};
const r = await db.weblio.get(vocab);
if (r) {
return setContent(r);
}
const vocabPageURL = 'https://www.weblio.jp/content/' + vocab;
return new Promise((resolve, reject) => {
function onerror(e) {
(window.unsafeWindow || window).console.error(arguments);
setContent();
reject(e);
}
GM_xmlhttpRequest({
method: 'GET',
url: vocabPageURL,
onerror,
onload: function (data) {
if (!data.responseText) {
resolve(setContent());
return;
}
const div = document.createElement('div');
div.innerHTML = data.responseText;
const definitions = Array.from(div.querySelectorAll('.kiji'))
.flatMap((el) => {
return Array.from(el.children).filter(
(el) => el instanceof HTMLDivElement,
);
})
.map((el) => {
if (el instanceof HTMLElement) {
if (el.querySelector('script')) return '';
return el.innerHTML;
}
return '';
})
.filter((s) => s);
div.remove();
if (!definitions.length) {
resolve(setContent());
return;
}
const r = {
id: vocab,
url: vocabPageURL,
definitions,
};
db.weblio.add(r);
resolve(setContent(r));
},
});
});
}
if (kanji) {
await Promise.allSettled([searchKanjipedia(kanji), searchWeblio(kanji)]);
} else if (vocab) {
await searchWeblio(vocab);
}
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// Triggering updates on lessons and reviews
const kanjipediaInserter = wkItemInfo
.on('lesson,lessonQuiz,review,extraStudy,itemPage')
.forType('kanji,radical')
.under('meaning')
.spoiling('meaning')
.notify((state) => {
if (
!(
kanji &&
(kanji === state.characters ||
kanji === getRadicalKanji(state.meaning))
)
) {
return;
}
if (!kanjipediaDefinition) return;
const title = 'Kanjipedia Explanation';
if (
AFTER_EN_MEANING ||
state.on === 'itemPage' ||
(state.type === 'radical' && state.on === 'lesson')
) {
state.injector.append(title, kanjipediaDefinition);
} else {
state.injector.appendAtTop(title, kanjipediaDefinition);
}
});
const weblioInserter = wkItemInfo
.on('lesson,lessonQuiz,review,extraStudy,itemPage')
.under('meaning')
.spoiling('meaning')
.notify((state) => {
if (state.on === 'itemPage') {
qType = '';
}
const isVocabulary = state.type
.toLocaleLowerCase()
.endsWith('vocabulary');
let fixedCharacters = state.characters;
if (isVocabulary) {
fixedCharacters = fixVocab(state.characters);
}
if (state.on === 'itemPage') {
if (isVocabulary) {
kanji = '';
if (vocab !== fixedCharacters) {
reading = state.reading;
vocab = fixedCharacters;
updateInfo();
return;
}
} else {
vocab = '';
reading = [];
const newKanji = state.characters || getRadicalKanji(state.meaning);
if (kanji !== newKanji) {
kanji = newKanji;
updateInfo();
return;
}
if (!kanji) return;
}
} else {
if (isVocabulary) {
if (fixedCharacters !== vocab) return;
} else if (kanji) {
if (
typeof state.characters === 'string'
? kanji !== state.characters
: kanji !== getRadicalKanji(state.meaning)
)
return;
}
}
if (!weblioDefinition) return;
const title = 'Weblio Explanation';
if (
AFTER_EN_MEANING ||
state.on === 'itemPage' ||
(state.type === 'radical' && state.on === 'lesson')
) {
state.injector.append(title, weblioDefinition);
} else {
state.injector.appendAtTop(title, weblioDefinition);
}
});
let kanjipediaReadingPanelInterval = 0;
const kanjipediaReadingInserter = wkItemInfo
.on('lesson,lessonQuiz,review,extraStudy,itemPage')
.forType('kanji')
.under('reading')
.notify((state) => {
if (!(kanji && kanji === state.characters)) {
return;
}
if (!kanjipediaReading) return;
clearInterval(kanjipediaReadingPanelInterval);
if (state.on === 'itemPage') {
document
.querySelectorAll(`.${entryClazz}-reading`)
.forEach((el) => el.remove());
const dst = document.querySelector('.subject-readings');
if (dst) {
const el = document.createElement('div');
el.className = `subject-readings__reading subject-readings__reading--primary ${entryClazz} ${entryClazz}-reading`;
const h = document.createElement('h3');
h.className = 'subject-readings__reading-title';
h.innerText = 'Kanjipedia';
const content = document.createElement('div');
content.className = 'subject-readings__reading-items';
content.lang = 'ja';
content.innerHTML = kanjipediaReading;
el.append(h, content);
dst.append(el);
}
} else {
kanjipediaReadingPanelInterval = setInterval(() => {
const node = document.querySelector('.subject-readings');
if (node) {
if (node.querySelector(`.${entryClazz}`)) {
return clearInterval(kanjipediaReadingPanelInterval);
}
node.insertAdjacentHTML(
'beforeend',
'<div class="subject-readings__reading subject-readings__reading--primary ' +
entryClazz +
' ' +
entryClazz +
'-reading' +
'"><h3 class="subject-readings__reading-title">Kanjipedia</h3>' +
`<p class="subject-readings__reading-items" lang="ja">${kanjipediaReading}</p>` +
'</div>',
);
return clearInterval(kanjipediaReadingPanelInterval);
}
}, 100);
}
});
/**
*
* @param {string[]} meanings
* @returns {string | undefined}
*/
function getRadicalKanji(meanings) {
const [en] = meanings;
if (!en) return;
const ks = radicalMap[en];
if (!ks) return;
console.log(
`${entryClazz}: ${
ks.length
? `converted ${en} to ${ks.join(', ')}`
: `cannot convert ${en} to Kanji`
}`,
);
return ks[0];
}
})();