ハングル学習アプリ「推し韓マル」では、基本の単語 5,500 語あまり・例文・リアルな会話・流行語・「今日の韓国語」を、毎月アプリのアップデートなしで届けています。その配信の流れをまとめます。
全体の流れ
content-src/**/*.jsonl (人が直す元ファイル · git)
│ build_content.py 検証 → SQLite (同じ元 = 同じ結果 · content_version = ハッシュ)
│ check_examples.py 例文の整合の点検 (報告)
│ build_fonts.mjs 使う文字だけを残した部分フォント
│ pack_assets.mjs アプリに入れる版を暗号化
▼
アプリのビルドに同梱 (content.bin) ← アプリを最初に起動したときに使うコンテンツ
│ release_content.mjs 圧縮 + 暗号化 + 往復検査 + manifest
│ deploy_content.sh サーバーに上げる (順番を保証 · 削除しない)
▼
アプリ: manifest を確認 → 新しい配信ならダウンロード → 次の起動で入れ替え + 案内シート
1. 元ファイルは JSONL、ビルドは決定的に
元ファイルは、1 行 = 1 レコードの JSONL です。diff が行単位で見え、人が直しても衝突が小さく済みます。
ビルドのスクリプトの原則は、次のとおりです。
- 検証:キー・型・必須の値・許される値・id の重複・つながり先があるか・id の頭の文字と type の整合・日付の形式・つながりの種類ごとの from/to の type
- 同じ元なら同じ結果:並び順を固定し、ビルドの時刻を記録しない。
content_version= 元ファイル+スキーマのバージョンのハッシュ - 一時ファイルに作ってから入れ替える:失敗しても、もとの DB がそのまま残る
[完了] assets/content/content.db · content_version=6aafa6e2dbe43d91 · schema=2 · 2452KB
items=11551 {'EXPRESSION': 28, 'SENTENCE': 5914, 'TREND': 28, 'WORD': 5581} · links=6123
content_version が決定的なので、「中身が本当に変わったか」がハッシュの比較 1 回で分かります。アプリ・サーバー・配信ファイルの名前が、すべてこの値を使います。
2. 例文の整合の自動点検:単語 ↔ 例文のずれを見つける
単語帳のコンテンツでよくある事故は、「単語の意味を直したのに、例文は古い意味のまま」です。規則をひとつ決めて、機械に見させます。
例文の韓国語にはその単語が、例文の日本語にはその意味の表記(活用形を含む)が入っていなければならない。
日本語の意味は活用するので、「語幹」を取り出して比べます。
KANJI = re.compile(r'[\u4e00-\u9fff々]+')
VERB_END = 'うくすつぬふむるぐぶい'
def ja_keys(gloss: str) -> list[str]:
g = re.sub(r'(.*?)|\(.*?\)', '', gloss).replace('〜', '').strip()
m = KANJI.search(g)
if m:
return [m.group(0)] # 漢字があれば漢字の部分 (活用と無関係)
if g == 'する':
return ['し', 'す', 'さ', 'せ']
if g.endswith('する'):
return [g[:-2]]
if g.endswith('ない') and len(g) > 2: # わからない → わから · わか
keys = [g[:-2]]
if len(g) - 3 >= 2:
keys.append(g[:-3])
return keys
if g.endswith('だ') and len(g) >= 2:
return [g[:-1]]
if len(g) >= 2 and g[-1] in VERB_END: # 最後の 1 文字を落とす
return [g[:-1]]
return [g or gloss]
韓国語の側は、動詞・形容詞の不規則な活用(했어요・돼요・써요・몰라요)が多いので照合を省き、日本語の側で照合します。結果は頻度順の表に保存し、頻度 400 位以内のずれは 0 を保っています。
つまずいたこと:この点検は、ビルドした DB を読みます。元ファイルを直した直後にビルドせずに回して、「まだずれが 1 件」と出て迷ったことがあります。順番は必ず、ビルド → 点検です。
3. 部分フォント:5.2 MB → 800 KB
日本語と韓国語のフォントは大きいです。アプリに実際に出てくる文字だけを残します。
const chars = {
ja: uniqueChars(BASE, range(0x3040, 0x30ff), range(0x31f0, 0x31ff), // 記号 + かな全部
jaContent, uiStrings, praise, legalChars()), // コンテンツ + UI の文字列
ko: uniqueChars(BASE, range(0x3131, 0x318e), koContent, praise), // 記号 + 互換字母 + コンテンツ
};
for (const t of TARGETS) {
const subset = await subsetFont(readFileSync(t.src), chars[t.lang], { targetFormat: 'sfnt' });
writeFileSync(path.join(OUT_DIR, t.out), subset);
}
writeFileSync(path.join(OUT_DIR, 'subset-chars.json'), JSON.stringify(chars, null, 2) + '\n');
assets/fonts/NotoSansJP-Regular.ttf 814KB (元 5.2MB)
assets/fonts/NotoSansKR-Regular.ttf 277KB (元 5.9MB)
[完了] 部分フォント 5 個 合計 2.66MB · 文字数 ja=2457 ko=1492
- 含める文字の一覧(
subset-chars.json)も一緒に残します。Jest のテストが「UI の文字列のすべての文字がフォントにあるか」を検査し、豆腐(□)のまま出すのを防ぎます - コンテンツが変わればフォントも変わります。だから、フォントの生成をコンテンツのビルドの 1 段階にまとめました
- 会社の情報のように、フォントの一覧に原文が残ると困る文字は、並べ替えて重複を除いた 1 文字ずつの形でだけ入れます
スポンサーリンク
スポンサーリンク
4. 同梱・配信するコンテンツの保護(考え方だけ)
平文の DB をアプリのバンドルやサーバーにそのまま置くと、誰でも取り出せてしまいます。このプロジェクトは、次の原則に従っています(細かい方式・キー・形式は公開しません)。
- アプリのバンドルとサーバーには、圧縮+認証付き暗号(AEAD)でまとめたファイルだけを置く。アプリはメモリの中でだけ開き、機器に平文のファイルを残さない
- 認証付き暗号なので、1 ビットでも変わると開けなくなる。破損や差し替えの検知を別に作る必要がない
- 同じ元なら同じファイルになるようにする。ビルドのたびにバイナリが変わって git の diff が汚れることがない
- 画のデータには、目に見えない透かしを入れる。複製が出てきたら、同じ鍵で計算し直して出どころを証明できる
- 作った直後に往復の検査:アプリと同じ順番で開き直し、元とバイト単位で比べる。失敗したらファイルを残さない
参考:アプリに入る鍵は、結局はアプリから取り出せます。この保護の目的は、「URL さえ分かれば誰でも持っていける」無防備な状態をなくし、大量の複製を高くつかせることにあります。
5. サーバーへの配信:順番が安全を作る
# 上げる順番: .htaccess → 暗号化したコンテンツ (.bin) → manifest.json を最後に
# --delete は使わない (前の配信のファイルを残す · 古い版のアプリ · ダウンロード中の利用者を守る)
- manifest を最後に上げる。manifest が指すファイルがまだない瞬間を、アプリに見せないためです
- 古いファイルを消さない。古い版のアプリや、ダウンロードの途中の利用者が壊れません
- サーバーのフォルダは読み取り(GET・HEAD)だけを許し、フォルダの一覧とスクリプトの実行を止めています
- 本番の配信のあと、公開の URL で確かめます:manifest 200・配信番号の一致・コンテンツ 200・DELETE の拒否・フォルダ一覧の拒否・.htaccess の拒否
- 配信先のパスを、スクリプトが検査します(パスのガード)。別のサイトのフォルダに上げてしまう事故を防ぎます
6. アプリの側:manifest を信じず、検査する
アプリの更新の判断は、DB・ネットワーク・暗号のライブラリから切り離した純粋関数にして、単体テストしています。(下は検査の項目の一部を縮めて写したものです。)
export function parseRemoteManifest(value: unknown): RemoteManifest | null {
if (!value || typeof value !== 'object') return null;
const v = value as Record<string, unknown>;
const ok =
Number.isInteger(v.release) && (v.release as number) >= 2026000000 && (v.release as number) <= 2099123199 &&
typeof v.contentVersion === 'string' && /^[0-9a-f]{8,64}$/.test(v.contentVersion) &&
Number.isInteger(v.schemaVersion) &&
v.file === `${v.release as number}-${v.contentVersion}.bin` && // ファイル名まで突き合わせる
Number.isInteger(v.size) && (v.size as number) > 24 &&
typeof v.title === 'string' && v.title.trim() !== '';
return ok ? (v as unknown as RemoteManifest) : null; // ひとつでも違えば受け取らない
}
/** 起動時に使うコンテンツ: 受け取っておいたものが同梱版・使用中より新しければ昇格 (→ 案内シート)。 */
export function chooseContent(bundled, active, pending) {
const usable = (r) => r !== null && r.schemaVersion === bundled.schemaVersion && r.release > bundled.release;
if (usable(pending) && (!usable(active) || pending.release > active.release)) return { use: pending, promoted: true };
return { use: usable(active) ? active : null, promoted: false };
}
- 配信の番号は
YYYYMMDDNNの整数です。比べやすく、日付の表示もついでにできます - スキーマが違えば使わない。アプリのアップデートで DB の構造が変わっても、古い構造の配信版を開いて落ちることがありません
- アプリのアップデートで同梱版のほうが新しくなったら、同梱版を使う。受け取っておいた古いコンテンツが、新しいアプリの同梱版を上書きしません
- 新しいコンテンツはダウンロードしておくだけで、次の起動のときに入れ替えます。使っている途中で DB が変わりません
おわりに
- 元ファイルは人が直しやすい JSONL、作るものは決定的なビルドで
- 「変わったか」はハッシュで、「合っているか」は検査とテストで判断する
- 配信は、順番(manifest を最後に)と保存(消さない)で安全にする
- アプリは、サーバーの応答を信じずに検査する
スポンサーリンク
スポンサーリンク
この記事について
自社のハングル学習アプリ「推し韓マル」の、毎月のコンテンツ配信の仕組みの記録です。コードとログは実際のソースとビルドから抜き出し、コメントを読みやすく縮めています。動作を変える修正はしていません。コンテンツの保護については、有料のコンテンツの保護に直結するため、鍵の名前や値・方式・形式を伏せ、考え方だけを書いています。
- この仕組みで毎月新しい表現が届くアプリ:推し韓マル(App Store・Google Play)
- Web(無料):iffthen hangul


コメント