Claude CodeでEPUBを組み、epubcheckで通るまで直させる手順
原稿からEPUB 3を生成する小さなスクリプトとepubcheckを組み合わせ、エラーIDを読んで直す検証ループをClaude Codeに任せる設定と、実際に出たエラー3種を示します。
EPUBを組んでepubcheckで通すループをClaude Codeに任せる
EPUBは、ZIPの中にXHTML・目次・パッケージ定義をそろえて入れる形式です。1か所でも規則から外れると、ストアの審査やリーダーアプリで弾かれます。規則への適合は人の目では追いにくいため、公式の適合チェッカーであるepubcheckを機械的な採点役にします。
流れは単純です。Claude Codeが原稿からEPUBを組み、epubcheckを実行し、出たエラーIDとメッセージを読んで原因のファイルを直し、エラーが0件になるまで繰り返します。
epubcheckは、DAISYコンソーシアムがW3Cに代わって保守しているオープンソースの適合チェッカーです。READMEによると、v5.4.0はEPUB 2と3の両方を検証でき、EPUB 3はEPUB 3.4の仕様に照らして検査されます。コマンドラインのツールとしても、Javaライブラリとしても使えます。
この記事では、次の3つを順に作ります。
- 原稿から最小のEPUB 3を組むPythonスクリプト
- epubcheckの出力を読んで直すための、CLAUDE.mdと権限の設定
- 検証に通るまでClaudeを止めないStop hook
本の構成や目次の章立てそのものはClaudeでKindle出版の構成・目次を作る手順で扱っています。ここでは、決まった原稿をEPUBファイルにして検証する工程に絞ります。
epubcheckを手元に用意する
配布物はGitHubのリリースページにあるZIPです。v5.4.0のZIPを展開すると、epubcheck.jarとlibディレクトリが出てきます。libはepubcheck.jarと同じ場所に置いたまま使います。
mkdir -p tools && cd tools
curl -sL -o epubcheck.zip \
https://github.com/w3c/epubcheck/releases/download/v5.4.0/epubcheck-5.4.0.zip
unzip -q epubcheck.zip
mv epubcheck-5.4.0 epubcheck
java -jar epubcheck/epubcheck.jar --help | head -3Javaを入れたくない環境には、READMEにDockerの手順があります。docker build . -t epubcheckでイメージを作り、ホストのディレクトリを/dataにマウントしてepubcheck sample.epubのように実行します。JSONレポートを出すなら末尾に--json output.jsonを付けます。
出力の言語はJavaのロケールに従います。日本語ロケールの環境では、エラー文が日本語で出ます。Claudeに渡すログを英語に固定したいときは、java -Duser.language=en -jar ...と指定します。以降の出力例は英語に固定したものです。
原稿からEPUBを組むスクリプト
Claude Codeに「EPUBを作って」と頼むと、毎回違う手順でファイルを組み立てがちです。ビルドは1本のスクリプトに固定しておくと、直す対象とチェックの入口が揃います。次のスクリプトは、src/ch*.mdの1行目の# 見出しを章タイトルとして、1ファイル1章のXHTMLを作り、目次(nav)とパッケージ定義(OPF)を添えてZIPにまとめます。
build_epub.py の全文
#!/usr/bin/env python3
"""原稿(Markdown風)から最小のEPUB 3を組む。"""
import html, pathlib, re, sys, uuid, zipfile
SRC = pathlib.Path("src")
OUT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "book.epub")
def to_xhtml(title, md):
body = []
for line in md.splitlines():
if line.startswith("# "):
continue
m = re.match(r"!\[(.*)\]\((.+)\)", line)
if m:
body.append(f'<p><img src="{m.group(2)}"/></p>')
elif line.strip():
body.append(f"<p>{html.escape(line)}</p>")
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="ja">
<head><meta charset="UTF-8"/><title>{html.escape(title)}</title></head>
<body><h1>{html.escape(title)}</h1>
{chr(10).join(body)}
</body></html>"""
chapters = []
for p in sorted(SRC.glob("ch*.md")):
text = p.read_text(encoding="utf-8")
title = re.match(r"# (.+)", text).group(1)
chapters.append((p.stem, title, to_xhtml(title, text)))
toc_items = "".join(f'<li><a href="{n}.xhtml">{html.escape(t)}</a></li>' for n, t, _ in chapters)
nav = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="ja">
<head><meta charset="UTF-8"/><title>目次</title></head>
<body><nav epub:type="toc"><h1>目次</h1><ol>{toc_items}</ol></nav></body></html>"""
images = sorted((SRC / "images").glob("*.png"))
img_items = "".join(f'<item id="img-{p.stem}" href="images/{p.name}" media-type="image/png"/>' for p in images)
manifest = img_items + '<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>' + "".join(f'<item id="{n}" href="{n}.xhtml" media-type="application/xhtml+xml"/>' for n, _, _ in chapters)
spine = "".join(f'<itemref idref="{n}"/>' for n, _, _ in chapters)
opf = f"""<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid" xml:lang="ja">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="bookid">urn:uuid:{uuid.uuid4()}</dc:identifier>
<dc:title>サンプル本</dc:title>
<dc:language>ja</dc:language>
<meta property="dcterms:modified">2026-10-20T00:00:00Z</meta>
</metadata>
<manifest>{manifest}</manifest>
<spine>{spine}</spine>
</package>"""
with zipfile.ZipFile(OUT, "w") as z:
info = zipfile.ZipInfo("mimetype")
info.compress_type = zipfile.ZIP_STORED # 無圧縮・先頭に置く
z.writestr(info, "application/epub+zip")
z.writestr("META-INF/container.xml", """<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>""", compress_type=zipfile.ZIP_DEFLATED)
z.writestr("OEBPS/content.opf", opf, compress_type=zipfile.ZIP_DEFLATED)
for p in images:
z.write(p, f"OEBPS/images/{p.name}", compress_type=zipfile.ZIP_STORED)
z.writestr("OEBPS/nav.xhtml", nav, compress_type=zipfile.ZIP_DEFLATED)
for n, _, x in chapters:
z.writestr(f"OEBPS/{n}.xhtml", x, compress_type=zipfile.ZIP_DEFLATED)
print("wrote", OUT)最小構成に必要なものは、無圧縮で先頭に置くmimetype、META-INF/container.xml、パッケージ定義のOPF、navプロパティを持つ目次文書、章のXHTMLです。画像はsrc/imagesに置けば、マニフェストへの登録と同梱を自動で行います。
python3 build_epub.py book.epub
java -jar tools/epubcheck/epubcheck.jar book.epub実際に出たエラーを読む
スクリプトの初版は、わざと目次文書(nav)を作らずに書き始めました。epubcheck 5.4.0で検証すると、次の出力になります。
Validating using EPUB version 3.4 rules.
ERROR(RSC-005): book.epub/OEBPS/content.opf(9,11): Error while parsing file: Exactly one manifest item must declare the "nav" property (number of "nav" items: 0).
Check finished with errors
Messages: 0 fatals / 1 error / 0 warnings / 0 infos形式は重大度(ID): ファイル(行,列): メッセージです。Claudeはこの行から、直すファイルがcontent.opfの9行目付近で、足りないのはproperties="nav"を持つマニフェスト項目だと読み取れます。nav文書を生成して登録すると、No errors or warnings detected.になり、終了コードは0でした。
その後、画像を足したり、ファイルの一部を壊したりして、出たエラーを集めました。
| ID | 出たきっかけ | メッセージの要点 | 直し方 |
|---|---|---|---|
| RSC-005 | 出たきっかけnavプロパティの項目がない | メッセージの要点マニフェストにnavを宣言した項目が必要 | 直し方nav文書を作りproperties="nav"で登録 |
| RSC-007 | 出たきっかけ本文が参照する画像をZIPに入れていない | メッセージの要点参照先OEBPS/images/fig1.pngが見つからない | 直し方画像を同梱し、マニフェストにも登録 |
| OPF-029 | 出たきっかけPNGをmedia-type image/jpegで登録 | メッセージの要点ファイルがOPFのメディアタイプと一致しない | 直し方image/pngに直す |
| RSC-005 | 出たきっかけOPFからdcterms:modifiedを消した | メッセージの要点dcterms:modifiedのmeta要素はちょうど1回必要 | 直し方更新日時のmetaを1つ入れる |
| RSC-005 | 出たきっかけ章の<title>を空にした | メッセージの要点title要素は空にできない | 直し方章タイトルを入れる |
| RSC-007 | 出たきっかけnavのリンク先を存在しないch9.xhtmlにした | メッセージの要点参照先が見つからない(指す場所はnav.xhtmlの5行目) | 直し方目次のhrefを実在の章に合わせる |
| RSC-007 | 出たきっかけ本文の参照をfig1.PNGと大文字にした | メッセージの要点ZIP内はfig1.pngなので見つからない | 直し方ファイル名の大文字小文字を揃える |
| RSC-016 | 出たきっかけ本文に閉じていない<br>を書いた | メッセージの要点brは終了タグで閉じる必要がある(致命的エラー) | 直し方<br/>にする |
画像の参照切れ(RSC-007)はXHTMLの7行目にあるimgの参照元を、OPF-029は画像ファイル自体を指して報告しました。エラーIDだけでなく、どのファイルを指しているかまで読ませると、Claudeが無関係な章を書き換える事故を避けられます。RSC-016は重大度がFATALで、Messages行にも1 fatalと数えられます。XMLとして壊れているため、これを直すまで他の検査に進めません。
最初の依頼文
CLAUDE.mdと権限が整っていれば、依頼は短くて済みます。
src/ch*.md から book.epub を組み、epubcheck が0件になるまで直してください。
エラーは ID・ファイル・行の一覧にしてから、指されたファイルだけを直します。epubcheckは、エラーの原因となったファイルと行を報告してくれます。Claudeが原稿の本文を書き換えず、ビルドスクリプトや目次の側を直しているかは、git diffで変更されたファイルを見れば判断できます。原稿のMarkdownが差分に含まれていたら、そこは人が見直す合図です。
結果をJSONで読ませる
エラーが増えてくると、画面の文字列より構造化された出力のほうが扱いやすくなります。--jsonにファイル名を渡すと、検査結果がJSONで書き出されます。トップレベルにはmessages、checker、publication、itemsなどのキーがあり、messagesの各要素にID、severity、message、locationsが入っていました。locationsにはファイルのパス、行、列が入ります。
java -jar tools/epubcheck/epubcheck.jar book.epub --json out.json
jq -r '.messages[] | "\(.severity) \(.ID) \(.locations[0].path):\(.locations[0].line) \(.message)"' out.jsonエラーも警告もないときは、messagesが空の配列で返り、jqの出力は何も表示されません。Claudeには、まず1行1件のこの形に整えた一覧を読ませると、修正の順番も決めやすくなります。
終了コードも手がかりになります。エラーがあるEPUBでは1、すべて通ったEPUBでは0が返ったので、シェルスクリプトやhookの中では出力の文字列を探さず、終了コードだけで合否を判定できます。-q(--quiet)を付けると、エラー以外のコンソール出力を抑えられます。
CLAUDE.mdに検証ループの手順を書く
毎回の指示で「epubcheckで確認して」と書く代わりに、CLAUDE.mdに手順を固定します。
## EPUBのビルドと検証
- EPUBは必ず `python3 build_epub.py book.epub` で組む。ZIPを手で組み替えない
- 組んだら `java -jar tools/epubcheck/epubcheck.jar book.epub --failonwarnings` を実行する
- 直す前にエラーIDと「ファイル(行,列)」を一覧にし、指されたファイルだけを直す
- 修正の中心は build_epub.py と src/ 配下。生成物の book.epub は直接編集しない
- 「No errors or warnings detected.」が出るまで繰り返す。出たら終了する--failonwarningsを付けると、警告があるだけでも終了コードが1になります。付けない場合、エラーがなければ警告が残っても0を返します。公開前の最終確認には付けておくと、警告の見落としを防げます。
「生成物を直接編集しない」の1行は効きます。EPUBはZIPなので、Claudeが展開して中身を書き換えると、次のビルドで直しが消えます。
実行権限を絞る
ループの中でClaudeが実行するコマンドは、ビルドとepubcheckだけで足ります。プロジェクトの.claude/settings.jsonに、許可するコマンドを書きます。
{
"permissions": {
"allow": [
"Bash(python3 build_epub.py *)",
"Bash(java -jar tools/epubcheck/epubcheck.jar *)"
]
}
}Claude Codeの権限ルールは、Bash(npm run *)のように、プログラムとサブコマンドを書いたあとにワイルドカードを置く形式です。ここでは、スクリプトの名前とjarのパスまでを固定し、引数だけを自由にしています。権限設計の考え方はffmpegの権限設計の記事にも例があります。
通るまで終わらせないStop hook
CLAUDE.mdは守られないこともあります。確実に検証させたいなら、Claudeが応答を終えようとするタイミングでepubcheckを走らせるStop hookが使えます。hookのスクリプトがexit 2で終わり、標準エラーに理由を書くと、公式のhooksガイドにあるとおり、Claudeはそれをフィードバックとして受け取って作業を続けます。
.claude/hooks/epubcheck-gate.sh
#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
python3 build_epub.py book.epub > /dev/null || { echo "build_epub.py が失敗しました" >&2; exit 2; }
if ! OUT=$(java -jar tools/epubcheck/epubcheck.jar book.epub --failonwarnings 2>&1); then
echo "$OUT" | head -40 >&2
exit 2
fi設定は.claude/settings.jsonのhooksに書きます。
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/epubcheck-gate.sh" }
]
}
]
}
}スクリプトを単体で動かすと、ビルドが壊れているときは検証結果の先頭40行を標準エラーに出してexit 2で終了し、通っているときは何も出力せずexit 0で終わりました。Stopのhookがexit 2で終わると、Claudeは作業に戻されます。この挙動はhooksガイドに書かれています。
先頭のstop_hook_activeの判定は外せません。hooksガイドには、Stop hookが連続8回ブロックし、その間Claudeがツールを呼んでいないと、Claude Codeがhookを上書きして止まると書かれています。スクリプト側で「すでにhookで継続している最中」かどうかを見て、そのときは終了を許す書き方が案内されています。8回を超えて収束させたい場合は、環境変数CLAUDE_CODE_STOP_HOOK_BLOCK_CAPで上限を上げられます。
編集のたびにチェックしたいなら、PostToolUseのhookに載せる方法もあります。使い分けはhooksでテストを自動実行する記事にまとまっています。章ごとに書き換えている最中は、編集のたびに走るhookより、作業の区切りで走るStop hookのほうがやり取りが静かです。
epubcheckが通っても残ること
epubcheckが検査するのは、EPUBとしての適合性です。次の点は、0件になっても保証されません。
- 見た目の崩れ: CSSの当たり方や、リーダーアプリごとの表示差は検査対象ではありません
- 代替テキスト: 先の検証では、
altのないimgを含めてもメッセージは0件でした。画像の説明は、CLAUDE.mdに「imgには必ずaltを付ける」と書いてClaudeに守らせます - 販売先ごとの審査: ストアの入稿規約は別の物差しです。出品先の要件は、出品先のヘルプで別に確かめます
「エラーが0件」はEPUBとして壊れていないことの証明であり、読みやすさの保証ではありません。表示は、実機やプレビューアで目視する工程を残します。
まとめ
EPUB制作では、ビルドを1本のスクリプトに固定し、epubcheckの重大度(ID): ファイル(行,列)をそのままClaudeの入力にすると、修正の範囲が狭まります。CLAUDE.mdに手順を書き、権限は2つのコマンドに絞り、Stop hookで「0件になるまで終わらない」仕組みを足すと、人が見張らなくてもループが回ります。残る人の仕事は、見た目の確認と、altのような検査の外にある項目です。