本記事は生成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 作品が並び、すべての顔を横断して絞り込めるようになります。

全 50 作品が並んだ Finder のファセット

全体の流れ

キュレーションが検索に出るまでには、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.pytimeout(既定では 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 作品すべてがファセットに並び、たとえば「性別:女性」で全作品を横断した顔の一覧が出ます。

Finder の横断検索結果

つまずきポイントまとめ

大量登録で引っかかった点をまとめます。

  • Accept ヘッダ: JSONkeeper への POST は Accept 要件を満たさないと 302(情報ページ)に流れる。curl は既定で通るが、urllib など Accept を送らないクライアントでは明示が必要。
  • X-Access-Token ヘッダ: これが無いと文書は保存されるが Activity Stream に載らず、検索対象にならない。
  • クロールのタイムアウト 2 種: 大量だと nginx(60 秒 → 504)と gunicorn(ワーカータイムアウト)に当たる。Canvas Indexer に直接、かつ gunicorn_config.pytimeout を延長して回避する。
  • プロキシのアップストリーム陳腐化: nginx は jsonkeeper などの静的なホスト名を、設定読み込み時に一度だけ解決してキャッシュします(resolver 未設定時)。docker compose down && up でコンテナを作り直すと IP が変わることがあり、その場合 nginx が古い IP を掴んだままになって 405 などを返します(docker compose restart は同じコンテナの再起動なので IP は保持され、この問題は起きません)。コンテナを作り直したらプロキシも作り直す/再起動するのが確実です。

まとめ

公開済みのキュレーション群は、一覧 → 保存(POST)→ 索引化(クロール)の 3 段階で、自前の ICP にまとめて取り込めます。保存の 2 ヘッダ(X-Access-TokenAccept)と、クロールのタイムアウト回避が要点でした。これで、範囲選択して作ったキュレーションを横断的に検索するところまで、手元で一通り再現できます。