本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

Spotlight は Stanford University Libraries が開発しているデジタル展示プラットフォームです。名前は聞いていたものの実際に触ったことがなかったので、ローカルに立てて、実在の IIIF マニフェストで展示を一つ組んでみました。

結論から言うと、IIIF を主軸にした展示を作るなら現実的な選択肢です。ただし「入れればそれらしく見える」類のソフトではなく、素の状態はかなり素っ気ない。そして途中でいくつか無言で失敗する挙動を踏んだので、そこが本記事の中心です。

検証したのは projectblacklight/spotlightbd8d5b7(Spotlight 5.3.0 / Blacklight 8.12.3 / Rails 8.1)です。

Omeka とは層が違う

まず位置づけ。似たソフトとして Omeka がよく挙がりますが、両者は同じ層のものではありません。

OmekaSpotlight
実体資料管理 DB + 展示機能既存 Solr 索引の上に載る展示レイヤー
スタックPHP + MySQL(Classic は PHP 7.1+/MySQL 5.5.5+、S は PHP 8.1+/MySQL 5.7.9+)Rails + Solr 必須
レンタルサーバ動く無理(Solr 常駐、ジョブキュー、画像処理)
検索MySQL の全文検索(既定)Solr のファセット検索が売り

Spotlight は Blacklight(Solr ベースの検索アプリケーション)の上に構築されています。想定されているのは「大学図書館が既に持っている DAMS の Solr 索引を指して、キュレーターが展示を組む」という使い方で、だから検索とファセットが最初から一級市民です。逆にアイテムの CRUD はおまけで、アップロード機能は画像 1 枚+最小メタデータしか扱えません。

「手軽に立てて資料管理も込みで完結させたい」なら Omeka、「既に Solr ベースのリポジトリがあって検索体験を重視する」なら Spotlight、という住み分けだと思います。

立ち上げでいきなり詰まる

Spotlight は Rails アプリではなく Rails エンジンです。Dockerfile がイメージビルド時に rails new でアプリを生成し、エンジンを path 参照する構成になっています。

ここで 2 つ引っかかりました。

公式の compose.yaml には Solr サービスしか定義されていません。 app サービスは自分で足す必要があります。

さらに DockerfileENTRYPOINT が参照する bin/docker-entrypoint.sh がリポジトリに存在しませんbin/ にあるのは rails だけ)。そのまま up すると起動しません。

# compose.override.yaml
services:
  app:
    build: { context: . }
    environment:
      - RAILS_ENV=development
      - RAILS_QUEUE=inline
      - SOLR_URL=http://solr:8983/solr/blacklight-core
      - SECRET_KEY_BASE=dev-only-not-a-secret
    entrypoint: []          # ← ENTRYPOINT を潰して CMD の puma を直接使う
    command: >
      sh -c "bundle exec rake db:prepare &&
             bundle exec puma -b tcp://0.0.0.0:3000"
    ports: ["3100:3000"]
    depends_on: [solr]

RAILS_QUEUE=inline は ActiveJob を同期実行にします。IIIF の収集がリクエスト内で完了するので、投入結果をその場で確認できて検証が楽になります。

管理者は README にあるシードタスクで作れます。

docker compose exec app bundle exec rake spotlight:seed_admin_user
# => Admin user created with email: admin@localhost (password: 'testing')

API は無い

自動投入したかったので API を探しましたが、トークン認証の REST API はありませんconfig/routes.rb を読むと、すべて Devise セッション + CSRF 前提の HTML コントローラです。公式 wiki の Resource Scenarios もアイテム追加の手段として gem か独自インデックスパイプラインを挙げるだけで、HTTP API の記載はありません。

HTTP Basic も通りません。Devise の http_authenticatable が既定 false なので、curl -u422InvalidAuthenticityToken)で弾かれます。

結局、ログインフォームを叩いて cookie を取り、毎回 <meta name="csrf-token"> を拾って POST する、という素朴なやり方に落ち着きました。エンジンが /spotlight にマウントされている点にも注意が要ります(Devise だけはアプリ側ルートなので /users/sign_in)。

用途メソッド・パス
展示作成POST /spotlight/
IIIF 収集POST /spotlight/:slug/iiif_harvestersresource[url]
タグ付与PATCH /spotlight/:slug/catalog/:doc_idsolr_document[exhibit_tag_list]
Browse カテゴリ作成POST /spotlight/:slug/searches
トップページ編集PATCH /spotlight/:slug/home(SirTrevor JSON)
外観PATCH /spotlight/:slug/appearance

searches#create は「そのときのリクエストパラメータをまるごと検索条件として保存する」実装です(searches_controller.rb:119-130)。つまり search[title] と一緒に絞り込み条件を送れば、それがそのまま Browse カテゴリになります。

POST /:exhibit/solr/update は壊れている

「外部インデクサ連携の例」として JSON を受けるエンドポイントが用意されているのですが、現 HEAD では 500 になります。

solr_controller.rb:53doc.to_solr.merge(...).merge(other_field_data) としている一方、to_solr は uniqueKey をシンボル :id で返します(solr_document.rb:94)。JSON 由来の "id"(文字列)と別キーとして共存するため、to_json"id" が 2 回出力され、Solr が Document contains multiple values for uniqueKey field で弾きます。

管理 UI の「JSON upload」フォームも同じ経路なので、この版では JSON 投入は使えません。失敗しても sidecar レコードだけ先に作られるので、試したら掃除が要ります。

無言で失敗するファセット

一番ひやりとしたのがこれです。

タグを条件にした Browse カテゴリを作ろうとして、Solr のフィールド名をそのまま渡しました。

f[exhibit_iiif-classics_tags_ssim][]=物語   # ← 効かない

エラーになりません。302 が返り、カテゴリも作成されます。ところが出来上がったカテゴリの件数が全部 15(全件)になっていて、そこで初めて気づきました。

正解は Blacklight の設定キーのほうです。

f[exhibit_tags][]=物語

Blacklight は blacklight_config.facet_fields に登録されたキーでしか f[...] を解釈せず、知らないキーは黙って捨てます。確認は exhibit.blacklight_config.facet_fields.keys で一発です。

# Solr のフィールド名
"exhibit_iiif-classics_tags_ssim"
# Blacklight の設定キー ← f[...] にはこちらを使う
"exhibit_tags"

検索結果が寂しいのは設定漏れ

15 点入れて検索結果を見たら、タイトルが並ぶだけでした。

原因は index_fields の既定値です。すべてのフィールドは enabled: true, show: true だが listnil なので、アイテム詳細には全項目が出るのに検索結果には何も出ない、という状態が初期値になっています。

メタデータ設定画面でチェックを入れれば解決します。ただしフィールドは IIIF マニフェスト由来なので機関ごとにばらつきます(この展示では 49 種類できました)。選ぶ基準は「収録資料のうち何点に入っているか」が実際的で、Solr に聞いて決めました。

フィールドカバー率
Attribution14/15
Call Number13/15
Creator10/15
Publication Date9/15

機関をまたいで資料を集めると、項目が揃わないのはむしろ普通です。

なお BlacklightConfiguration は保存済みハッシュを既定値へ merge する実装なので、変更したいフィールドだけ送れば残りは既定のままで済みます(blacklight_configuration.rb:136-140)。

テーマは実質存在しない

exhibits.stanford.edu を見て「ああいう見た目にできるのか」と思っていたのですが、素の Spotlight にテーマ切り替えはありません

config.exhibit_themes = ['default']   # lib/spotlight/engine.rb:282

テーマ選択の UI 自体、current_exhibit.themes.many? が真のときしか描画されません。Stanford の展示が美しいのは、彼らが Spotlight を組み込んだ自前の Rails アプリsul-dlss/exhibits)に独自 SCSS を当てているからです。

そこで、その CSS を移植できるか試しました。sul-dlss/exhibitsApache License 2.0 なので帰属表示のうえで再利用できます(ただし Apache 2.0 §6 は商標を許諾しないので、ブランドバーやワードマークはそのまま持ってきません)。

移植できたのは汎用的な 3 ファイル、計 440 行だけです。

ファイル判断
spotlight_overrides.scss(228 行)Spotlight 標準の markup が対象。ほぼそのまま効く
blacklight_overrides.scss(118 行)同上
bootstrap_overrides.scss(94 行)Bootstrap 変数の上書き
component_library_overrides.css✗ ブランドバー・カーディナル(商標)
layout.scss.site-navbar など Stanford 独自 markup 前提

バージョンの相性は良好でした(こちら Spotlight 5.3.0 / Blacklight 8.12.3 / Bootstrap 5.3.8 に対し、Stanford は ~> 5.2 / ~> 8.0 / ^5.3.3)。ビルドパイプラインも同一です。

移植で踏んだ 3 つ

--stanford-* の値は上流リポジトリに存在しません。 SCSS が参照している --stanford-black --stanford-fog-light など 6 個の CSS 変数の定義は、Stanford のコンポーネントライブラリ側にあります。同名の変数に中立的な値を入れるシムを一枚書いて、上流ファイルを 1 行も書き換えずにコンパイルできるようにしました。

yarn build:css だけでは 1px も変わりません。 イメージビルド時に rake assets:precompile 済みなので public/assets/application-<digest>.css が存在し、development でも propshaft はそちらを配信し続けます。CSS は正しく再ビルドされているのに、before/after のスクリーンショットがバイト単位で同一になり、しばらく原因が分かりませんでした。precompile をやり直して app を再起動するまでが 1 セットです。

CSS だけ移植しても「相手のコンテンツ設計」は来ません。 blacklight_overrides.scss は検索結果に min-height: 100px.document-counter { display: none } を当てています。Stanford は結果 1 件ごとに複数のメタデータ行を出す設定なのでこれで詰まって見えるのですが、メタデータを出していないこちらに当てると通し番号が消えて空白だけの背の高い行になり、明確に劣化しました。

これは後日談があって、前節のメタデータ設定を入れたら min-height は自然に埋まり、むしろ行高が揃って読みやすくなりました。移植した CSS が劣化に見えるときは、CSS ではなく自分側のコンテンツ設計が上流と違うことを先に疑うべきでした。

NDL の IIIF が描画されない

収録した 15 点のうち 12 点は国立国会図書館デジタルコレクションです。ここで 2 つ問題が出ました。

サムネイルが出ない

Spotlight::Resources::IiifManifest#add_thumbnail_urlmanifest['thumbnail'] の存在を前提にしています(iiif_manifest.rb:61-64)。NDL の IIIF v2 マニフェストには manifest / canvas どちらにも thumbnail プロパティが無いので、一覧のサムネイルが一切出ません。Bodleian・BSB・Yale は出ます。

先頭 canvas の Image API から生成するよう上書きして解決しましたが、ここでも 2 つ踏みました。

Spotlight は !w,h を前提にした箇所があります。 これは IIIF Image API level2 の機能(sizeByConfinedWh)で、NDL は level1(supportsregionByPctsizeByWh のみ)なので HTTP 500 が返ります。厄介なのは、Solr には URL が入るので索引上は「サムネイルあり」に見えることです。level1 で確実に通るのは幅指定の 400, でした。

先頭 canvas は表紙です。 和古書は 1 コマ目が題箋・帙で、しかも撮影台の灰色とカラーチャートが写り込みます。一覧が灰色の撮影台だらけになったので、「全体の 2 割目のコマを取り、さらに pct:8,4,84,80 で余白を落とす」という経験則にしました。本番の展示ならキュレーターが 1 点ずつ選べるようにすべきところです。

ビューアが真っ白 — そして誤診

もっと厄介だったのがこちらです。アイテム詳細を開くと info.json は 28 件すべて 200 で返るのに、タイル画像を一度も要求せずビューアが空白のまま止まりました。CORS も 429 も関係ありません(access-control-allow-origin: * があり、タイル単体を叩くと 200)。

調べていくと、NDL の info.jsontiles のキー名が仕様と違うことに気づきます。

// NDL
"tiles": [{ "width": 1024, "height": 1024, "scaleFactor":  [1,2,4,8,16,32] }]   // ← 単数
// Yale / Bodleian(IIIF Image API 2.1 準拠)
"tiles": [{ "width": 512,  "scaleFactors": [1,2,4,8,16,32,64] }]                // ← 複数

OpenSeadragon のソースを見ると scaleFactors(複数)を読んでいます。

// openseadragon.js:14418
// N.B. 2.0 renamed scale_factors to scaleFactors
// openseadragon.js:14437
for (var sf = 0; sf < this.tiles[t].scaleFactors.length; sf++) {

単数形なら undefined.length になる。描画されないのはこれだ——と結論しかけました。これが誤診でした。

引っかかったのは、作業初日に撮っていたスクリーンショットです。そこでは同じ NDL 資料のビューアが問題なく描画できていましたscaleFactor が原因なら当時も描画できないはずです。

そこで Spotlight を介さず、OpenSeadragon 5.0.1 に NDL の info.json を直接与えて比べました。

// a: NDL 原文のまま(scaleFactor 単数)
// b: scaleFactors へ直したもの
a → tile-loaded 1
b → tile-loaded 1

どちらもタイルを読み込みます。 仕様非準拠であること自体は事実ですが、描画されない理由ではありませんでした。

では真因は何だったか。少なくとも一つは自分の書いたコードでした。録画スクリプトに「ビューアが描画されるまで待つ」ヘルパを入れていたのですが、これが未描画を誤判定して 3 回リロードし、最後のリロード直後の未描画状態で撮影に入っていました。リロードをやめてポーリングに変えたところ、Bodleian の資料はあっさり描画されました。NDL については検証環境を落としてしまったため未解決のままです。

教訓としては、外部サービスの非準拠を見つけると、それが原因だと決めつけやすいということです。仕様違反は実在したので説明としてもっともらしく、ソースの該当行まで示せてしまう。実際には自分のコードが自分の首を絞めていました。初日のスクリーンショットという反証が手元に残っていなければ、そのまま公開していたと思います。

描画判定に使えた手

切り分けで効いたのは canvas の中央ピクセルを読む方法です。タイルはすべてクロスオリジンなので、一度でも描画されると canvas が tainted になり getImageData が SecurityError を投げます。つまり「例外が出たら成功」で、未描画のあいだは例外にならず alpha=0 が返ります。

try {
  const d = c.getContext('2d').getImageData(c.width>>1, c.height>>1, 1, 1).data;
  return d[3] !== 0;          // まだ描かれていない
} catch { return true; }      // tainted = 描画済み

ビューアの描画完了を機械的に待ちたいときに便利です。

上流に日本語ロケールを出した

管理画面は英語のままですが、公開側の文字列だけは既存の de / es / fr / it / zh と同じ 20 キーが翻訳されています。日本語がなかったので PR を出しました

作業自体は小さく、config/locales/spotlight.ja.yml(20 キー)と lib/spotlight/engine.rb への 1 行追加だけです。jaconfig.i18n_locales に登録しないと config.i18n.available_locales の許可リストに入らないので、ファイルを置くだけでは効きません。

ただし CI を落としました。 ロケール一覧を en.ymllocales: マップにも足す必要があることを見落としていて、t("locales.ja") が引けずに translation missing: en.locales.ja を返していました。既存ロケールとのキー一致は検証していたのに、テストを一度も走らせずに出したのが原因です。

反省してローカルで CI を回せるようにしたのですが、そこでも 3 つ踏みました。

コンテナ内の /spotlight/engine は git リポジトリではありません(Dockerfile が COPY するだけ)。git stash / git checkout が黙って失敗するので、「変更前と比較したつもりで実際は比較できていない」状態に一度陥りました。

spec は Solr を localhost:8983 に期待します。 WebMock が allow_localhost なので、ホスト名 solr:8983 を渡すと WebMock::NetConnectNotAllowedError で大量に落ちます。socat TCP-LISTEN:8983,fork,reuseaddr TCP:solr:8983 で localhost へ写像して解決しました。

変更の有無にかかわらず約 23 件は失敗します(Solr へ書き込む系が WebMock に阻まれる)。自分の変更が原因かどうかは必ず変更前との件数比較で判断する必要があり、実測では baseline / 変更後とも 800 examples, 23 failures で同一でした。

壊れていた状態を意図的に再現したところ、ローカルなら 1 秒で検出できていました

the extra elements were: [["<span class="translation_missing"
  title="translation missing: en.locales.ja">Ja</span>", :ja]]
7 examples, 1 failure

結局どうだったか

素の Spotlight は素っ気ないですが、マストヘッド・展示サムネイル・メタデータ設定・Feature ページを入れれば、大学図書館のデジタル展示として通用する見た目にはなります。しかもマストヘッドもサムネイルも、アップロードせずに収集済みの IIIF 画像から切り出して設定できます(featured_image_paramsiiif_tilesourceiiif_region を受け付ける)。ここは IIIF ベースの運用と噛み合っていて良くできていると思いました。

一方で、今回踏んだ落とし穴はどれもエラーにならず、黙って期待と違う結果になる類でした。ファセットのキー違いも、!w,h の level2 前提も、precompile 済み CSS の配信も、症状は「なんとなくおかしい」だけです。IIIF は「対応している」と書いてあっても level によって使える機能が変わるので、info.json を実際に読んで確かめるのが近道でした。

そしてビューアの件で書いたとおり、外部の非準拠を見つけたときほど、自分のコードを先に疑うべきでした。仕様違反は実在し、ソースの該当行も示せたのに、原因ではなかったのです。

  • 検証対象: projectblacklight/spotlight bd8d5b7(Spotlight 5.3.0 / Blacklight 8.12.3 / Rails 8.1 / Ruby 4.0.5)
  • 収録: 実在の IIIF マニフェスト 15 点(NDL 12 / Bodleian / BSB / Yale)

操作動画

本記事で組んだ展示を使って、Spotlight の機能を一通り操作した解説動画も作りました(9 章・約 30 分)。Playwright による自動操作と Azure TTS のナレーションで生成しています。