Yuzhe's Blog

yuzhes

KakuYaku: building a Japanese pop-up dictionary extension from scratch

When reading Japanese materials, I always need quick lookup. Existing tools were either too heavy (manual selection + dictionary lookup) or too light (just kanji readings, no grammar analysis), so I built one myself.


Tech choices

Tokenization: Sudachi

Japanese tokenization is not like Chinese; it needs dedicated tools. Sudachi is an open-source tokenizer from WorksApplications, with Python/Java/Rust bindings, and I used the Node.js native module version.

Sudachi output is rich:

{
  "surface": "食べて",
  "dictionaryForm": "食べる",
  "reading": "タベテ",
  "pos": "動詞",
  "posDetail": ["一般", "*", "*", "*", "*"]
}

Dictionary: JMDict + KANJIDIC2 + PostgreSQL/PGroonga

Dictionary data uses EDRDG open data:

Data is stored in PostgreSQL with the PGroonga extension for full-text search. PGroonga is optimized for Japanese/Chinese and is friendlier for Japanese text than pg_trgm.

JMDict part-of-speech annotations are stored as real text[] arrays, and queries use = ANY().

Frontend: Chrome Extension Manifest V3 + Vue 3

I used the vite-vue3-browser-extension-v3 template. Manifest V3’s service worker model is a bit annoying—the background page is no longer persistent, so reconnections are frequent, and message channel lifetime needs attention.

LLM: DeepSeek

Grammar parsing and translation are powered by an LLM. I started with Gemini Flash 2.0 (free quota: 1500 req/day); after the quota was used up in a day during dev testing, I switched to DeepSeek (deepseek-chat), which is compatible with OpenAI API and costs around $0.14/Mtok.


Core implementation

Tokenization + highlighting

The content script walks page text nodes, sends them to the API for tokenization, then replaces each raw text node with highlighted spans:

const response = await sendToBackground({ action: 'analyze', text: node.textContent })
const tokens = response.tokens

// Replace text node with span sequence
const fragment = document.createDocumentFragment()
for (const token of mergedTokens) {
  const span = document.createElement('span')
  span.className = `kaku-yaku-highlight kaku-yaku-${token.pos}`
  span.dataset.surface = token.surface
  fragment.appendChild(span)
}
node.parentNode.replaceChild(fragment, node)

Conjugation merge for verbs

Sudachi splits 「食べている」 into three tokens: 食べ, て, いる. For learners, that is one grammatical unit.

Merge rules:

// posDetail[1] === 'サ変可能' means サ変名詞
const isSahenNoun = (t: Token) =>
  t.pos === '名詞' && t.posDetail?.[1] === 'サ変可能'

Click a highlighted token and show a floating card with:

Positioning uses position: absolute, with coordinates from getBoundingClientRect() + scrollY, so the popup tracks scroll correctly.

AI grammar analysis

Call DeepSeek through OpenAI-compatible API and force JSON with response_format: { type: 'json_object' } (so no regex needed to strip Markdown code fences):

const res = await client.chat.completions.create({
  model: 'deepseek-chat',
  messages: [
    { role: 'system', content: `JSON schema: { role, function, rule, example, exampleTrans }` },
    { role: 'user', content: `Sentence: "${sentence}"\nExplain: "${targetWord}"` },
  ],
  response_format: { type: 'json_object' },
  max_tokens: 300,
})

Results are cached in Map<paragraphText, { grammar?, translation? }> so words within the same paragraph share cache. Parsed paragraphs show a cyan border on the left.


Pitfalls

Manifest V3 service worker lifecycle

Service workers can be killed at any time. When a content script sends a message to background while the worker is asleep, sendMessage throws message channel closed before response.

Fix: explicitly return a Promise in each case branch; don’t rely on async/await to keep the message channel alive implicitly.

JMDict field names

In the API payload, gloss fields are gloss: string[], not meanings[0].glosses. The docs were unclear; this came up in debugging.

sshfs-win remote development

I developed on Windows while code executed on a Mac Mini (100.73.231.27 via Tailscale). CRXJS HMR dev server on localhost:3303 was unreachable remotely. I switched to production watch build and mounted dist/chrome via sshfs-win, then loaded it as an unpacked extension in Chrome.

Gemini API baseURL

The correct base URL is https://generativelanguage.googleapis.com/v1beta/openai/, not /openai/v1/. It is not obvious in the docs.


Current features

The main reason I built this was practical convenience. The next planned feature is one-click Anki export—importing vocab data into Anki for review.