kaku-yaku: I built a Japanese-Learning Browser Extension
kaku-yaku: I Built a Japanese-Learning Browser Extension
Why I made this
When I read Japanese technical documentation, I kept hitting unknown words. The standard flow was: select text → right-click → search, or jump to another tab with Jisho. Every time I did that, my reading rhythm got interrupted, and after looking up a term I had to find my place again.
I tried Yomichan first, and it was definitely better than manual lookup, but I wanted more: not just lookups, but also furigana, grammar breakdowns, and saving difficult words for review. Existing tools each covered part of the workflow, but none of them combined in a way I was happy with.
The idea was simple: build it myself.
Technology choice
Bookmarklet or extension?
At first I started with a Bookmarklet because the threshold is low and you can skip Chrome Web Store review. I wrote a version that injects a script into pages and traverses text nodes, and it was barely usable.
The problems became obvious quickly. A bookmarklet has no persistent storage, so a refresh loses all state; a word list feature is basically impossible. And stricter CSP sites (like GitHub) block eval(), so scripts fail to inject.
Moving to a browser extension was inevitable.
MV2 or MV3?
Chrome’s stance on Manifest V3 is clear now: MV2 support is being phased out. If I’m building this for real, MV3 is the only sensible choice.
The biggest change in MV3 is that background pages are now service workers. A MV2 extension can keep a background page alive and preserve state, but an MV3 service worker can be killed anytime and only wakes when needed.
That change brought a lot of friction, which I’ll detail below.
The hardest part: the furigana overlay
The furigana feature looks simple: show reading on top of kanji. In practice, DOM constraints are tricky.
Keep text selectable
The naive approach is to overlay an absolutely positioned <div> above the kanji. The problem is that overlay blocks text selection, so users select the reading span instead of the original character.
<ruby> is a better semantic choice:
<ruby>食<rt>た</rt></ruby>べている
But replacing an entire paragraph with concatenated <ruby> tags breaks the existing DOM structure. Existing <a>, <strong>, <code> nesting is lost, and React/Vue-rendered components can break.
Replace only text nodes
The final approach is to operate only on leaf text nodes (Node.TEXT_NODE) and avoid element nodes. I traverse the DOM with a TreeWalker and skip nodes inside <script>, <style>, <textarea>, and other unsafe nodes:
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const parent = node.parentElement
if (!parent) return NodeFilter.FILTER_REJECT
const tag = parent.tagName.toLowerCase()
if (['script', 'style', 'textarea', 'input'].includes(tag))
return NodeFilter.FILTER_REJECT
// 已经处理过的节点跳过
if (parent.closest('.kaku-yaku-processed'))
return NodeFilter.FILTER_REJECT
return NodeFilter.FILTER_ACCEPT
},
}
)
For each text node, replace it with a tokenized set of <span> wrappers, each using <ruby> for readings where needed:
const fragment = document.createDocumentFragment()
for (const token of tokens) {
const span = document.createElement('span')
span.className = `ky-token ky-${token.pos}`
if (token.reading && token.reading !== token.surface) {
const ruby = document.createElement('ruby')
ruby.textContent = token.surface
const rt = document.createElement('rt')
rt.textContent = token.reading
ruby.appendChild(rt)
span.appendChild(ruby)
} else {
span.textContent = token.surface
}
fragment.appendChild(span)
}
node.parentNode!.replaceChild(fragment, node)
No existing element node is changed; only text nodes become an equivalent structure, so selection, copy, and search still behave normally.
Tokenization pitfalls
The backend uses Sudachi under NestJS. Its precision is high, but token granularity is not always ideal for study workflows.
Inflected verbs being split too granularly
In Sudachi default mode, 「食べている」 can be split into 食べ, て, and いる. That’s fine for linguistic analysis, but for learners it’s one grammar unit (progressive form) and should display as a whole.
The merge rules are not hard to write, but edge cases are numerous:
動詞+助動詞*→ Merge (食べている、食べた、食べられた)サ変名詞+する+助動詞*→ Merge (参加している、制圧した)形容詞+助動詞*→ Merge (美しかった、楽しかった)てfollowed byいる/おく/しまうand other helpers → Merge
Adding each rule meant running a batch of examples to ensure it wouldn’t over-merge unrelated tokens. For example, in 「食べて帰る」, て must not merge with 帰る because 帰る is an independent verb, not an auxiliary.
The key is the lexical class: posDetail[0] must be 非自立可能 for the auxiliary merge case.
Reading is sometimes missing
For katakana terms Sudachi sometimes omits reading, and sometimes returns a value equal to surface. The frontend filters this: only render <ruby> when reading exists and differs from the surface form, otherwise render plain text.
MV3 service worker pitfalls
Messaging from content script to background is the normal communication pattern:
chrome.runtime.sendMessage({ action: 'analyze', text })
But in MV3, the service worker can be asleep. When asleep, it may not be fully active when a message arrives, so sendMessage can sometimes succeed and sometimes throw:
Error: Could not establish connection. Receiving end does not exist.
or:
Error: The message port closed before a response was received.
The root causes differ, but both are tied to the worker lifecycle.
Fix #1: every message handler branch in background must return true explicitly (so Chrome knows the listener responds asynchronously), not rely on implicit async behavior:
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.action === 'analyze') {
handleAnalyze(msg.text).then(sendResponse)
return true // 必须显式返回 true
}
})
Fix #2: Content script should ping background once before sending, and retry on failure instead of failing immediately.
Vocabulary list design
The word list is stored in chrome.storage.local with a simple shape:
interface WordEntry {
surface: string
reading: string
meaning: string
addedAt: number
reviewCount: number
lastReviewedAt?: number
}
Double-click to highlight a word and add it; the side panel displays the list.
For review features, I considered several options:
Option 1: integrate with Anki. Anki has AnkiConnect and can manage cards via HTTP API. It has strong review quality and mature algorithms, but it requires users to install Anki + AnkiConnect, which raises the setup barrier.
Option 2: build simple spaced repetition in-extension. A simplified SM-2 can compute the next review time from reviewCount and lastReviewedAt. It is zero-dependency and works immediately, but the review logic is not as deep as Anki.
Option 3: export CSV. The lightest option: export the word list as Anki-importable CSV and let users handle the rest.
Currently I have option three partially implemented—storage is done, export is not yet wired in. Review is next, and I will likely do Anki export first because I do not want to rebuild review scheduling from scratch.
Next steps
The extension is usable for my own workflow now, but there are two major gaps:
Anki export: format WordEntry into Anki Basic deck CSV and export in one click so users can import themselves; or integrate with AnkiConnect, which requires extra user configuration.
Chrome Web Store release: the review process is heavy, especially for privacy-policy pages, screenshots, and copy. The extension calls a remote API (kaku-yaku-api), so reviewers will check data handling closely. I plan to document the data flow clearly, so this should be manageable.
Firefox support is also in scope. WebExtension APIs are mostly compatible, and the main difference is how browser.sidePanel and chrome.sidePanel are called.
It is genuinely useful now. Reading Japanese resources no longer requires tab switching; hovering lets me see reading and meaning, and unknown grammar can be analyzed with a single click. The main motivation was simple: build it for myself. You only polish a tool when it becomes useful enough for your own use.