WaniKani JJ External Definition

Get JJ External Definition from Weblio, Kanjipedia

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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://greasyfork.org/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];
  }
})();