Skip to content

Instantly share code, notes, and snippets.

@piazzatron
Last active September 29, 2025 16:20
Show Gist options
  • Select an option

  • Save piazzatron/309b533dfae9a844eb9d85e7cd88519d to your computer and use it in GitHub Desktop.

Select an option

Save piazzatron/309b533dfae9a844eb9d85e7cd88519d to your computer and use it in GitHub Desktop.
Print Wani Vocab By Lesson Level
// INSTRUCTIONS
// 1) Ensure Anki is open + AnkiConnect is installed, and your origin (e.g. https://www.wanikani.com) is whitelisted.
// 2) (Optional) Set an ignore list in localStorage:
// localStorage.setItem('WK_IGNORE_LIST', JSON.stringify(['工事', '猫']));
// 3) Open WaniKani Advanced Lesson Picker, paste this in the console.
(async () => {
const ANKI_URL = 'http://127.0.0.1:8765';
const FIELD_NAME = 'Expression';
const IGNORE_LS_KEY = 'WK_IGNORE_LIST';
// ---- helpers ----
const stripHtml = (s) => String(s ?? '').replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim();
const nfc = (s) => (s ?? '').normalize('NFC').trim();
const esc = (s) => String(s).replace(/"/g, '\\"');
function loadIgnoreSet() {
const raw = localStorage.getItem(IGNORE_LS_KEY);
if (!raw) return new Set();
try {
const parsed = JSON.parse(raw);
const arr = Array.isArray(parsed) ? parsed
: (parsed && Array.isArray(parsed.words)) ? parsed.words
: [];
return new Set(arr.map(nfc));
} catch {
const arr = String(raw).split(/[,\n\r\t ]+/).map(s => s.trim()).filter(Boolean);
return new Set(arr.map(nfc));
}
}
async function ankiInvoke(action, params = {}) {
const res = await fetch(ANKI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, version: 6, params }),
});
const json = await res.json();
if (json.error) throw new Error(json.error);
return json.result;
}
// Returns array parallel to queries: [[ids], [ids], ...] (AnkiConnect multi → array-of-arrays)
async function ankiMultiFindNotes(queries) {
const actions = queries.map(q => ({ action: 'findNotes', params: { query: q } }));
const res = await fetch(ANKI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'multi', version: 6, params: { actions } }),
});
const json = await res.json();
if (json.error) throw new Error(json.error);
if (Array.isArray(json.result) && (json.result.length === 0 || Array.isArray(json.result[0]))) {
return json.result;
}
// fallback to single calls
const out = [];
for (const q of queries) {
try { out.push(await ankiInvoke('findNotes', { query: q }) || []); }
catch { out.push([]); }
}
return out;
}
async function wordsPresentInAnki(words) {
if (!words.length) return new Set();
const queries = words.map(w => `${FIELD_NAME}:"${esc(w)}"`); // Expression:"猫"
const perWordIds = await ankiMultiFindNotes(queries);
const allIds = [...new Set(perWordIds.flat())];
if (!allIds.length) return new Set();
const notes = await ankiInvoke('notesInfo', { notes: allIds });
const byId = new Map(notes.map(n => [n.noteId, n]));
const inAnki = new Set();
for (let i = 0; i < words.length; i++) {
const w = nfc(words[i]);
const ids = perWordIds[i] || [];
for (const id of ids) {
const n = byId.get(id);
const val = nfc(stripHtml(n?.fields?.[FIELD_NAME]?.value ?? ''));
if (val === w) { inAnki.add(w); break; }
}
}
return inAnki;
}
// ---- fetch JLPT CSV ----
const res = await fetch('https://raw.githubusercontent.com/elzup/jlpt-word-list/master/out/all.csv');
const text = await res.text();
const lines = text.split('\n').slice(1);
const jlptMap = {};
const meanings = {};
const readings = {};
for (const line of lines) {
if (!line.trim()) continue;
const cols = line.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/);
const expr = (cols[0] || '').replace(/^"|"$/g, '');
const read = (cols[1] || '').replace(/^"|"$/g, '');
const mean = (cols[2] || '').replace(/^"|"$/g, '');
const tags = (cols[3] || '').replace(/^"|"$/g, '');
const m = tags.match(/JLPT_.*(\d)/);
if (expr && m?.[1]) {
const level = 'N' + m[1];
jlptMap[expr] = level;
readings[expr] = read;
meanings[expr] = mean;
}
}
// ---- scrape WaniKani panels, collect words and the elements to hide ----
const waniLevelMap = {};
const wordToElements = new Map(); // word → [tileElements]
document.querySelectorAll('.lesson-picker__level').forEach(section => {
const level = section.querySelector('.wk-panel__title')?.textContent?.trim() || 'UNKNOWN';
const items = section.querySelectorAll('.subject-character__characters');
const words = [];
items.forEach(el => {
const w = nfc(el.textContent);
if (!w) return;
words.push(w);
// choose a tile/container to hide
const tile = el.closest(
'.subject-character, .subject-summary, .lesson-picker__item, .subject-item, .character-grid__cell, .subject-tile'
) || el.parentElement;
if (!wordToElements.has(w)) wordToElements.set(w, []);
wordToElements.get(w).push(tile);
});
waniLevelMap[level] = words;
});
// ---- ignore list ----
const ignoreSet = loadIgnoreSet();
// ---- Anki check on all visible, non-ignored words that have JLPT info ----
const allWordsForAnki = [
...new Set(
Object.values(waniLevelMap)
.flat()
.filter(w => jlptMap[w])
.filter(w => !ignoreSet.has(w))
)
];
let inAnkiSet = null;
try { inAnkiSet = await wordsPresentInAnki(allWordsForAnki); }
catch { inAnkiSet = null; }
// ---- build output (per-level NEW, then one global ALREADY IN ANKI) ----
const out = [];
const alreadyByJlpt = {};
const alreadySeen = new Set();
for (const [waniLevel, wordsRaw] of Object.entries(waniLevelMap)) {
const newByJlpt = {};
for (const w of wordsRaw) {
if (!jlptMap[w]) continue;
if (ignoreSet.has(w)) continue;
const isInAnki = inAnkiSet && inAnkiSet.has(w);
if (isInAnki) {
if (!alreadySeen.has(w)) {
alreadySeen.add(w);
(alreadyByJlpt[jlptMap[w]] ||= []).push(w);
}
} else {
(newByJlpt[jlptMap[w]] ||= []).push(w);
}
}
out.push('');
out.push(`WANI LEVEL ${waniLevel}`);
out.push('--------------------------------');
const keys = Object.keys(newByJlpt).sort().reverse();
if (keys.length === 0) {
out.push('(No new items — everything here is already in Anki or ignored)');
} else {
for (const jl of keys) {
out.push(`${jl}: ${newByJlpt[jl].length}`);
for (const w of newByJlpt[jl]) {
out.push(` ${w} (${readings[w]}), ${meanings[w]}`);
}
}
}
}
if (inAnkiSet !== null) {
const jlKeys = Object.keys(alreadyByJlpt).sort().reverse();
if (jlKeys.length) {
out.push('');
out.push('ALREADY IN ANKI');
out.push('--------------------------------');
for (const jl of jlKeys) {
out.push(`${jl}: ${alreadyByJlpt[jl].length}`);
for (const w of alreadyByJlpt[jl]) {
out.push(` ${w} (${readings[w]}), ${meanings[w]}`);
}
}
}
} else {
out.push('');
out.push('NOTE: Anki check failed; exported everything as NEW (ignore list still applied).');
}
console.log(out.join('\n'));
// ---- FINAL STEP: hide tiles for any words that are in Anki OR in ignore list ----
const toHide = new Set([
...ignoreSet,
...(inAnkiSet ? inAnkiSet : [])
]);
for (const w of toHide) {
const els = wordToElements.get(w);
if (!els) continue;
els.forEach(el => { if (el && el.style) el.style.display = 'none'; });
}
// Optional: provide an unhide helper in case you want to restore them quickly
window.WK_unhideAllIgnoredOrAnki = () => {
for (const w of toHide) {
const els = wordToElements.get(w) || [];
els.forEach(el => { if (el && el.style) el.style.display = ''; });
}
};
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment