本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
この記事のねらい
前回の記事で、IIIF(International Image Interoperability Framework、画像相互運用のための国際規格)の IIIF Curation Platform(ICP) を macOS のローカルに Docker で構築しました。そこでは 1 件のキュレーションを保存して検索できるところまでを確認しました。
この記事では、その続きとして「すでに Web で公開している複数のキュレーションを、まとめて自前の ICP に登録し、横断検索できるようにする」手順を記録します。題材には、NDL(国立国会図書館)デジタルコレクションの浮世絵などから顔を検出してまとめた NDL Image Bank Face Finder の公開キュレーション群(50 件・約 3,400 点の顔)を使います。
前提として、前回の手順で ICP が http://icp.localhost:8888/cp で動いているものとします。
完成イメージ
50 件すべてを登録・索引化すると、Finder の「作品」ファセットに 50 作品が並び、すべての顔を横断して絞り込めるようになります。

全体の流れ
キュレーションが検索に出るまでには、3 つの段階があります。
1. 一覧を得る curations/index.json(公開元が持つ一覧)
▼
2. 保存する 各 curation JSON を JSONkeeper に POST
▼
3. 索引化する Canvas Indexer にクロールさせる
▼
Finder で横断検索
手順
1. 登録するキュレーションの一覧を得る
NDL Image Bank Face Finder は、公開キュレーションの一覧を curations/index.json として持っています。中身は次の形で、各作品の JSON ファイルのパスが入っています。
{
"works": [
{ "slug": "namazue", "title": "鯰絵", "count": 426, "figures": 65, "manifests": 54, "file": "curations/works/namazue.json" },
{ "slug": "bunposoga", "title": "文鳳麁画", "count": 355, "figures": 27, "manifests": 1, "file": "curations/works/bunposoga.json" }
]
}
この file を順にたどれば、全キュレーションの URL が得られます。公開元によって一覧の持ち方は違うので、対象サイトに合わせてここだけ読み替えてください。
2. JSONkeeper に一括登録する
各キュレーション JSON を取得して、POST /cp/curation/api で JSONkeeper に保存します。ここで 2 つのヘッダが重要です。
X-Access-Token: <任意の文字列>: これを付けると「所有者付き」の文書として保存され、検索対象(Activity Stream)に載ります。付けないと保存はされますが検索対象になりません。Accept: application/json: JSONkeeper の POST は「受け入れ可能な Accept があるか」を確認し、無いと情報ページ(JSONkeeper のトップ/)へのリダイレクト(302)にフォールバックします。curlは既定でAccept: */*を送るので通りますが、プログラム(Python のurllibなど)で Accept を省くと 302 に流れ(プロキシ経由では最終的に 404 に着地し)、登録に失敗します。これは実際にはまった箇所です。
Python の標準ライブラリだけで書いた登録スクリプトです。
#!/usr/bin/env python3
"""公開されている全 curation を、ローカルの JSONkeeper に登録する。
前提: ICP スタックが http://icp.localhost:8888/cp で稼働していること。"""
import json
import urllib.request
BASE = "http://icp.localhost:8888/cp"
SRC = "https://nakamura196.github.io/ndl-face-finder"
TOKEN = "ndl-face-finder" # X-Access-Token(任意の文字列。所有者=検索対象化の条件)
def get(url):
with urllib.request.urlopen(url, timeout=30) as r:
return r.read()
def main():
works = json.loads(get(f"{SRC}/curations/index.json"))["works"]
print(f"== {len(works)} curations found ==")
ok = ng = 0
for i, w in enumerate(works, 1):
name = w["file"].split("/")[-1]
try:
body = get(f"{SRC}/{w['file']}")
req = urllib.request.Request(
f"{BASE}/curation/api", data=body, method="POST",
headers={"Content-Type": "application/ld+json",
"Accept": "application/json", # ← これが無いと 302 に流れる
"X-Access-Token": TOKEN}) # ← これが無いと検索対象にならない
with urllib.request.urlopen(req, timeout=60) as r:
code = r.status
if code == 201:
ok += 1
print(f" [{i:2d}/{len(works)}] 201 {name} ({w.get('title','')}, {w.get('count','?')} faces)")
else:
ng += 1
print(f" [{i:2d}/{len(works)}] {code} {name} (NG)")
except Exception as e:
ng += 1
print(f" [{i:2d}/{len(works)}] ERR {name}: {e}")
print(f"== POST done: ok={ok} ng={ng} ==")
if __name__ == "__main__":
main()
実行すると、50 件すべてが 201(作成)で登録できました。
== 50 curations found ==
[ 1/50] 201 namazue.json (鯰絵, 426 faces)
[ 2/50] 201 bunposoga.json (文鳳麁画, 355 faces)
...
[50/50] 201 odakazumasekihanga.json (織田一磨の石版画, 5 faces)
== POST done: ok=50 ng=0 ==
Activity Stream の件数も確認できます。
curl -s http://icp.localhost:8888/cp/curation/as/collection.json \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('totalItems'))"
# => 50
3. Canvas Indexer にクロールさせる
保存しただけでは Finder に出ません。Canvas Indexer に Activity Stream を巡回させて索引を作ります。
ここで大量登録ならではのタイムアウトが 2 つ出ました。50 件(約 3,400 点の顔)のクロールは、手元では 約 5 分 40 秒かかりました。
- リバースプロキシ(nginx)の 60 秒: プロキシ経由で
/cp/index/crawlを叩くと504 Gateway Time-outになります。プロキシを介さず、Canvas Indexer に直接(前回のstart_port=9001なら Canvas Indexer はホストの9002)クロールを投げると回避できます。 - gunicorn のワーカータイムアウト:
Canvas-Indexer/gunicorn_config.pyのtimeout(既定では 180 秒)を超えるとワーカーが打ち切られ、途中まで(今回は 38/50)で止まります。長めに延ばします。
# gunicorn のタイムアウトを延ばす
sed -i '' 's/^timeout = .*/timeout = 600/' Canvas-Indexer/gunicorn_config.py # macOS の BSD sed
docker compose restart canvasindexer # gunicorn 設定を再読込(restart なのでコンテナ IP は保持される)
# プロキシを介さず、Canvas Indexer に直接クロールさせる(長めのタイムアウト)
curl -s --max-time 700 http://localhost:9002/crawl
# => {"message": "done"}
gunicorn_config.pyはコンテナにボリュームマウントされているので、値を書き換えてdocker compose restart canvasindexerすれば反映されます。クロールはボリューム上の SQLite に増分で書き込まれるため、途中で止まっても、もう一度走らせれば続きから進みます。
索引ができたか確認します。
# キュレーション単位の件数
curl -s http://localhost:9002/api | python3 -c "import sys,json;print(json.load(sys.stdin)['total'])"
# => 50
# キャンバス(顔)単位の総数
curl -s 'http://localhost:9002/api?select=canvas&from=canvas,curation' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['total'])"
# => 3428
http://icp.localhost:8888/cp/finder/ を開くと、50 作品すべてがファセットに並び、たとえば「性別:女性」で全作品を横断した顔の一覧が出ます。

つまずきポイントまとめ
大量登録で引っかかった点をまとめます。
Acceptヘッダ: JSONkeeper への POST は Accept 要件を満たさないと 302(情報ページ)に流れる。curlは既定で通るが、urllibなど Accept を送らないクライアントでは明示が必要。X-Access-Tokenヘッダ: これが無いと文書は保存されるが Activity Stream に載らず、検索対象にならない。- クロールのタイムアウト 2 種: 大量だと nginx(60 秒 → 504)と gunicorn(ワーカータイムアウト)に当たる。Canvas Indexer に直接、かつ
gunicorn_config.pyのtimeoutを延長して回避する。 - プロキシのアップストリーム陳腐化: nginx は
jsonkeeperなどの静的なホスト名を、設定読み込み時に一度だけ解決してキャッシュします(resolver未設定時)。docker compose down && upでコンテナを作り直すと IP が変わることがあり、その場合 nginx が古い IP を掴んだままになって405などを返します(docker compose restartは同じコンテナの再起動なので IP は保持され、この問題は起きません)。コンテナを作り直したらプロキシも作り直す/再起動するのが確実です。
まとめ
公開済みのキュレーション群は、一覧 → 保存(POST)→ 索引化(クロール)の 3 段階で、自前の ICP にまとめて取り込めます。保存の 2 ヘッダ(X-Access-Token と Accept)と、クロールのタイムアウト回避が要点でした。これで、範囲選択して作ったキュレーションを横断的に検索するところまで、手元で一通り再現できます。
- 前回の構築記事: https://zenn.dev/nakamura196/articles/iiif-curation-platform-docker-macos
- 題材の公開キュレーション: https://nakamura196.github.io/ndl-face-finder/
- IIIF Curation Platform(CODH): http://codh.rois.ac.jp/iiif-curation-platform/


コメント
…