// ==UserScript==
// @name WaniKani JJ External Definition
// @namespace http://www.wanikani.com
// @version 0.12.6
// @description Get JJ External Definition from Weblio, Kanjipedia
// @author polv
// @author NicoleRauch
// @match *://www.wanikani.com/*/session*
// @match *://www.wanikani.com/*vocabulary/*
// @match *://www.wanikani.com/*kanji/*
// @match *://www.wanikani.com/*radicals/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=weblio.jp
// @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=1102710
// @grant GM_xmlhttpRequest
// @connect kanjipedia.jp
// @connect weblio.jp
// @homepage https://github.com/patarapolw/WanikaniExternalDefinition
// ==/UserScript==
// @ts-check
/// <reference path="./types/wanikani.d.ts" />
/// <reference path="./types/item-info.d.ts" />
/// <reference path="./types/gm.d.ts" />
(function () {
'use strict';
/** @type {number | undefined} */
const MAX_ENTRIES = 3;
/** @type {number | undefined} */
const HTML_MAX_CHAR = 10000;
const entryClazz = 'wkexternaldefinition';
const style = document.createElement('style');
style.innerHTML = [
'.' + entryClazz + ' a.crosslink {',
' color: #023e8a;',
'}',
'.' + entryClazz + ' a {',
' text-decoration: none;',
'}',
'.' + entryClazz + ' .kanji-variant {',
' display: inline-block;',
' text-align: center;',
' width: 100%;',
' font-size: 2em;',
' font-family: serif;',
' margin-top: 0;',
' margin-bottom: 0;',
'}',
'.' + entryClazz + ' .kanji-variant {',
' font-size: 2em;',
'}',
'.' + entryClazz + ' .kanji-variant img {',
' height: 2em;',
'}',
'.' + entryClazz + ' .kanji-variant + .kanji-variant {',
' margin-left: 1em;',
'}',
'.' + entryClazz + ' .okurigana {',
' color: #ab9b96;',
'}',
].join('\n');
document.head.appendChild(style);
///////////////////////////////////////////////////////////////////////////////////////////////////
// @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;
function getCurrent() {
// First, remove any already existing entries to avoid displaying entries for other items:
$('.' + entryClazz).remove();
kanji = undefined;
vocab = undefined;
reading = [];
kanjipediaDefinition = undefined;
kanjipediaReading = undefined;
weblioDefinition = undefined;
let key = 'currentItem';
if (document.URL.includes('/lesson/session')) {
key = $.jStorage.get('l/quizActive')
? 'l/currentQuizItem'
: 'l/currentLesson';
}
const current = $.jStorage.get(key);
if (!current) return;
if ('voc' in current) {
vocab = fixVocab(current.voc);
reading = current.kana;
} else if ('kan' in current && typeof current.kan === 'string') {
kanji = current.kan;
} else if ('rad' in current) {
kanji = current.characters;
}
updateInfo();
}
if (typeof $ !== 'undefined') {
$.jStorage.listenKeyChange('currentItem', getCurrent);
$.jStorage.listenKeyChange('l/currentLesson', getCurrent);
$.jStorage.listenKeyChange('l/currentQuizItem', getCurrent);
$.jStorage.listenKeyChange('l/startQuiz', (key) => {
if ($.jStorage.get(key)) {
getCurrent();
}
});
getCurrent();
}
/**
*
* @param {string} v
* @returns
*/
function fixVocab(v) {
const suru = 'する';
isSuru = v.endsWith(suru);
if (isSuru) {
v = v.substring(0, v.length - suru.length);
}
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;
const a = document.createElement('a');
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;
if (r.variant) {
r.variant = r.variant.trim();
if (!r.variant.startsWith('<')) {
r.variant = `<div>${r.variant}</div>`;
}
kanjipediaReading += [
'<li>異体字</li>',
`<div class="kanji-variant">${r.variant}<div>`,
].join('\n');
}
kanjipediaReadingInserter.renew();
insertDefinition(
r.definition
.split('<br>')
.map((s) => `<p>${s}</p>`)
.join('\n'),
r.url,
'Kanjipedia',
);
return r.definition;
};
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 firstResult = /** @type {HTMLAnchorElement} */ (
$('<div />')
.append(
data.responseText.replace(regexImgSrc, replacementImgSrc),
)
.find('#resultKanjiList a')[0]
);
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 = $('<div />').append(
data.responseText
.replace(regexImgSrc, replacementImgSrc)
.replace(regexTxtNormal, replacementTxtNormal)
.replace(regexSpaceBeforeCircledNumber, '<br/>$1'),
);
const readingNode = rawResponseNode.find(
'#kanjiLeftSection #onkunList',
);
// Okurigana dot removal, so that it can be read as a vocabulary with Yomichan
readingNode.find('span').each((_, it) => {
const $it = $(it);
const text = $it.text();
if (text[0] === '.') {
$it.text(text.substring(1));
$it.addClass('okurigana').css('color', '#ab9b96');
}
});
const r = {
id: kanji,
url: kanjiPageURL,
reading: readingNode.html(),
definition: rawResponseNode
.find('#kanjiRightSection p')
.html(),
variant: (() => {
const vs = [
...rawResponseNode.find('#kanjiOyaji'),
...rawResponseNode.find('.subKanji'),
].filter(
(n) => $(n).text() !== decodeURIComponent(kanji || ''),
);
if (!vs.length) return '';
const $vs = $(vs).addClass('kanji-variant');
$vs.find('img').removeAttr('width').css('height', '2em');
return $vs.html();
})(),
};
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.definitions.length) return '';
const vocabDefinition = r.definitions
.sort((t1, t2) => {
/**
*
* @param {string} t
* @returns {number}
*/
const fn = (t) => {
if (/[[音訓]]/.exec(t)) return kanji ? -10 : 10;
const m = /読み方:([\p{sc=Katakana}\p{sc=Hiragana}ー]+)/u.exec(
t,
);
if (m) {
if (reading.length && !reading.includes(m[1])) return 5;
if (isSuffix) {
if (t.includes('接尾')) return -1;
}
if (isSuru) {
if (t.includes('スル')) return -1;
}
return 0;
}
return 1000;
};
return fn(t1) - fn(t2);
})
.slice(0, MAX_ENTRIES)
.map((html) => {
const div = document.createElement('div');
div.innerHTML = html.substring(0, HTML_MAX_CHAR);
html = div.innerHTML;
div.remove();
return html;
})
.join('<hr>');
insertDefinition(vocabDefinition, r.url, 'Weblio');
return vocabDefinition;
};
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);
reject(e);
}
GM_xmlhttpRequest({
method: 'GET',
url: vocabPageURL,
onerror,
onload: function (data) {
if (!data.responseText) {
resolve('');
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('');
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')
.under('meaning')
.notify((state) => {
if (!(kanji && kanji === state.characters)) {
return;
}
if (!kanjipediaDefinition) return;
const title = 'Kanjipedia Explanation';
if (
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')
.notify((state) => {
if (state.on === 'itemPage') {
if (state.type === 'vocabulary') {
if (!vocab) {
vocab = state.characters;
reading = state.reading;
updateInfo();
return;
}
} else {
if (!kanji) {
kanji = state.characters;
updateInfo();
return;
}
}
}
if (state.type === 'vocabulary') {
if (state.characters !== vocab) return;
} else if (!(kanji && kanji === state.characters)) {
return;
}
if (!weblioDefinition) return;
const title = 'Weblio Explanation';
if (
state.on === 'itemPage' ||
(state.type === 'radical' && state.on === 'lesson')
) {
state.injector.append(title, weblioDefinition);
} else {
state.injector.appendAtTop(title, weblioDefinition);
}
});
const kanjipediaReadingInserter = wkItemInfo
.on('lesson,lessonQuiz,review,extraStudy,itemPage')
.forType('kanji')
.under('reading')
.notify((state) => {
if (!(kanji && kanji === state.characters)) {
return;
}
if (!kanjipediaReading) return;
const id = [entryClazz, 'kanjipedia', 'reading'].join('--');
$('#' + id).remove();
if (state.on === 'lesson') {
$('#supplement-kan-reading:visible .pure-u-1-4 > div')
.first()
.after(
'<span id="' +
id +
'" lang="ja" class="' +
entryClazz +
' ' +
entryClazz +
'-reading' +
'"><h2 style="margin-top: 1.25em;">Kanjipedia</h2>' +
kanjipediaReading +
'</span>',
);
} else if (state.on === 'itemPage') {
$('.span4')
.removeClass('span4')
.addClass('span3')
.last()
.after(
'<div id="' +
id +
'" lang="ja" class="span3 ' +
entryClazz +
' ' +
entryClazz +
'-reading' +
'"><h3>Kanjipedia</h3>' +
kanjipediaReading +
'</div>',
);
} else {
$('#item-info #item-info-col1 #item-info-reading:visible').after(
'<section id="' +
id +
'" lang="ja" class="' +
entryClazz +
' ' +
entryClazz +
'-reading' +
'"><h2>Kanjipedia</h2>' +
kanjipediaReading +
'</section>',
);
}
});
})();