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

本記事ではアカウント名や ID をプレースホルダで表記しています。例:

  • PythonAnywhere ユーザ名: <pa-user> (= https://<pa-user>.pythonanywhere.com/)
  • Firebase プロジェクト ID: <firebase-pid>
  • PythonAnywhere API トークン: <PA_TOKEN>
  • GitHub Pages 配信先ドメイン: <gh-user>.github.io

自身の環境では、すべて自分の値に置き換えて読んでください。本文中ではこれらの値を環境変数で参照します。

本記事では、JSONkeeper のサーバ側を自前で立てる ところに絞って、選定の過程と実際の手順を残します。Viewer の閲覧専用ミラーを GitHub Pages に置いた話 (codh-mirror) は別記事に分けてあり、本記事は「ミラーから先、保存系まで自前で動かす」ための後段です。

TL;DR

  • JSONkeeper は IllDepence/JSONkeeper (Flask + SQLAlchemy + Firebase Admin SDK) として MIT ライセンスで公開されている小さな Flask アプリ。mp.ex.nii.ac.jp/api/curation/json で運用されていた実体もこれ。
  • アップストリームの requirements 上の Flask 1.0.2 / apscheduler 3.5.3 は Python 3.10 以降で起動エラー になる (collections.MutableMapping 削除 / pkg_resources 削除)。Flask 2.0.3 + apscheduler 3.10 以上に差し替えるだけで通る。
  • PythonAnywhere の 無料プラン (Beginner) で十分動く。実害がある制約は (1) コンソールの CPU 100 秒/日、(2) 外向き HTTP(S) ホワイトリスト、(3) SSH 不可。一方、Web リクエスト処理は CPU 上限の対象外なので、運用上はほぼ問題ない。
  • 無料プランでも HTTP API 経由でほぼすべての操作 (ファイル UL/DL / Web アプリ作成・設定 / リロード / コンソール操作) が可能。ブラウザを使うのは「API トークン発行」と「コンソールの初回アタッチ」の 2 回だけ。
  • Firebase 認証は、Web SDK の config (apiKey 等、公開しても OK) を Viewer 側 authFirebase.js に書き、サーバ秘匿のサービスアカウント鍵を JSONkeeper の [firebase] セクションから参照する形に分けて配置する。
  • ハマりどころ: PA consoles/{id}/send_input/ への curl で & がフォームデータ区切りとして食われるので、--data-urlencode を使うこと。2>&1 のような bash 構文をそのまま送ると 2> で止まる。

出発点

項目
Viewer の配信元GitHub Pages (https://<gh-user>.github.io/codh-mirror/iiif-curation-viewer/) — Wayback 取得物の静的ホスト
Viewer の認証内部で Firebase JS SDK v5 を使う authFirebase.js プラグイン (オリジナルの codh-81041 プロジェクトがハードコード)
Viewer の保存先index.js 内 5 箇所に curationJsonExportUrl: 'https://mp.ex.nii.ac.jp/api/curation/json' (現在エンドポイントは応答しない)
課題Firebase 側は authDomain が <gh-user>.github.io を承認していないのでログイン不可。保存先 API も不通なので、ログイン突破しても保存できない

つまり「閲覧専用」までは GitHub Pages へのミラーで成立しているが、Viewer の Curation 編集ワークフローを動かすには (1) Firebase プロジェクトの差し替え(2) JSONkeeper の自前運用 が必要、というのが出発点です。

着地点

項目
Firebase プロジェクト自前の <firebase-pid> を使い、Authorized domains に <gh-user>.github.io を追加
JSONkeeperPythonAnywhere Beginner プラン (https://<pa-user>.pythonanywhere.com/) で常駐。Firebase Admin SDK でサーバサイド ID トークン検証
Viewer 側authFirebase.jsfirebaseConfig を自前プロジェクトに、index.jscurationJsonExportUrl を PA の URL に書き換え
デプロイ手段HTTP API + curl のみ。ブラウザ操作は (1) PA の API トークン発行、(2) PA の Bash コンソール初回アタッチの 2 回だけ
撤収オリジナル URL に戻す場合は index.js を 1 行 sed で mp.ex.nii.ac.jp に戻し、PA Web アプリを 1 コマンドで削除可能

1. なぜ JSONkeeper なのか — 互換性の検証

Viewer 側のエクスポートプラグイン (icv.exportJsonKeeper.js) は、サーバが満たすべき仕様を強く前提にしています。exportJsonKeeper.js:79-180 付近を読むと、ざっくり以下:

項目要件
エンドポイントcurationJsonExportUrl 1 本 (例: https://<host>/api)
新規保存POST, Content-Type: application/ld+json, 本文は Curation JSON 文字列
既存上書きPUT, Content-Type: application/ld+json (URL は POST 時に返ってきた個別 ID URL)
削除DELETE
認証X-Firebase-ID-Token: <Firebase ID Token> (Firebase モード時)。匿名 POST は許可可
公開設定X-Unlisted: true/false (POST のみで有効)
成功レスポンスLocation ヘッダに保存先 URL — Viewer はこれを ?curation=<resLocation> に乗せてリダイレクト
CORS配信ドメインからのクロスオリジン許可 + カスタムヘッダ許可

これを満たすサーバを書くだけなら Express でも FastAPI でも 100 行程度ですが、mp.ex.nii.ac.jp で運用されていた実体は IllDepence/JSONkeeper (MIT, Flask) であることが、JSONkeeper ソフトウェア紹介ページIIIF Curation Platform 概要ページ (Wayback Machine 経由) で明示されています。担当者として Tarek Saier (Albert Ludwigs University of Freiburg、国立情報学研究所インターンシップ生) が併記されており、同氏が GitHub で公開している実装がそのまま採用されたという関係です。config.ini に書く rewrite_types のデフォルトに http://codh.rois.ac.jp/iiif/curation/1#Curation がハードコードされている専用処理まで含めて、Viewer のためにある実装と言えます。

[json-ld]
rewrite_types = http://codh.rois.ac.jp/iiif/curation/1#Curation,
                http://iiif.io/api/presentation/2#Range

これにより POST された JSON-LD の @typecr:Curation のとき、@id を保存先 URL に書き換えてくれます。Viewer のフロー上は不可欠な振る舞いです。

2. ホスティング先の選定 — 無料枠の現実

選定時に検討した無料枠ホスティング (2026 年時点):

サービスJSONkeeper 適合性注意点
PythonAnywhere Beginner◎ Flask + SQLite が一発、デフォルト永続ディスク 512MB、HTTPS デフォルトSSH 不可・CPU 100 秒/日・外向き HTTP(S) ホワイトリスト
Oracle Cloud Always Free◎ 性能・自由度ともに最強VPS 運用の手間 (Nginx + certbot + gunicorn + systemd)
Fly.io2024 年以降「Free allowance」は残るが新規はクレカ必須、実質ペイアズユーゴー
Render Freeスリープあり、無料プランに永続ディスクなし。Postgres 無料も 90 日期限化
Hugging Face Spaces (Docker)永続ストレージは月額有料アドオン
Cloud Run + Firestore×SQLAlchemy 依存の JSONkeeper を Firestore に書き換える改造が必要
Vercel / Netlify / Workers×サーバレス、SQLite 自前永続が不可 (Workers + D1 へ載せ替えれば別の話)

PA Beginner の制約 3 つのうち、実害があるのは:

  • CPU 100 秒/日: 初回 pip install で枯渇しやすい。Web リクエスト処理時間はカウント外なので、運用上は問題なし
  • 外向き HTTP(S) ホワイトリスト: Firebase Admin SDK の ID トークン検証で securetoken.googleapis.com (公開鍵フェッチ) への到達が必要。PA のデフォルトホワイトリストに Google API 系が広く入っているため、本記事の構成では通った。通らないホストは PA support にメールで申請すれば 1〜2 営業日で追加される運用

「SSH 不可」については、本記事の主題でもあるとおり HTTP API でほぼ代替可能 なので実害なし。

3. アップストリーム JSONkeeper を現代 Python で動かす

requirements.txt のピン (抜粋):

Flask==1.0.2
apscheduler==3.5.3
Werkzeug==2.0.2
SQLAlchemy==1.3.23
Jinja2==3.0.3
itsdangerous==2.0.1
firebase_admin==2.12.0
PyLD==1.0.3

これらは 2018 年あたりの組み合わせで、最終 push は 2023 年初です。Python 3.11 で python3 -c "from jsonkeeper import create_app; create_app()" を試すと、次の 2 つで止まります:

ImportError: cannot import name 'MutableMapping' from 'collections'

Flask 1.0.2 の flask/sessions.pyfrom collections import MutableMapping を使っており、3.10 で collections.abc に移されたため動かない。

ModuleNotFoundError: No module named 'pkg_resources'

apscheduler 3.5.3 の apscheduler/__init__.pyfrom pkg_resources import get_distribution, DistributionNotFound を使っているが、3.12+ では setuptools が標準で入らず pkg_resources が無い。

修正は 2 つだけ:

pip install -r requirements.txt
pip install 'Flask==2.0.3' 'apscheduler>=3.10'

Flask==2.0.3 は他のピン (Werkzeug 2.0.2 / Jinja2 3.0.3 / itsdangerous 2.0.1) と整合する最古の 2 系。これでローカル (macOS, Python 3.11) でも PythonAnywhere (Python 3.11) でも起動を確認できました。

ローカル起動と smoke test:

$ python3.11 -m venv venv && source venv/bin/activate
$ pip install -r requirements.txt 'Flask==2.0.3' 'apscheduler>=3.10'
$ python3 run.py
 * Running on http://127.0.0.1:5000/

# 別ターミナル
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"hello":"local"}' http://127.0.0.1:5000/api
HTTP/1.0 201 CREATED
Location: http://localhost:5000/api/211490b0-1060-47f6-8498-a4279f2dee36
...
{"hello":"local"}

4. Firebase 側の準備

Web SDK 用 config (apiKey 等) は公開しても認証の本当のゲートは Firebase Console の Authorized domains 側にあるため、コードに直書きで構いません。一方、サーバサイドの サービスアカウント鍵 JSON は完全な秘密情報 なので扱いを分けます。

用途入手場所保存場所秘匿性
Web SDK configFirebase Console → 「ウェブアプリ追加」Viewer リポの authFirebase.js公開して OK (リポにコミット可)
Service account JSONFirebase Console → プロジェクト設定 → サービスアカウント → 「新しい秘密鍵の生成」PA 上の ~/JSONkeeper/firebase-adminsdk.json のみ絶対に公開しない。Git にもチャットにも貼らない

加えて Firebase Console → Authentication → Settings → Authorized domains<gh-user>.github.io を追加します。これを忘れると Viewer のログインポップアップが「unauthorized domain」で死にます (オリジナルの codh-81041 プロジェクトでは nakamura196.github.io などは未登録のため、ここがミラー運用の最大の躓きポイントです)。

5. PythonAnywhere の API トークンを取る

ブラウザでの作業はここから 2 回だけです。1 回目: API トークン発行。

  1. https://www.pythonanywhere.com/account/#api_token を開く
  2. 「Create a new API token」をクリック
  3. 表示されたトークンをコピーしておく (一度閉じると再表示できないので注意)

以降、ローカル端末でこのトークンを環境変数に入れます:

export PA_USER=<pa-user>
export PA_TOKEN=<PA_TOKEN>
export PA_API="https://www.pythonanywhere.com/api/v0/user/${PA_USER}"
export PA_DOMAIN="${PA_USER}.pythonanywhere.com"

疎通テスト (読み取り専用、CPU を消費しない):

$ curl -sS -H "Authorization: Token ${PA_TOKEN}" "${PA_API}/cpu/" | jq
{
  "daily_cpu_limit_seconds": 100,
  "daily_cpu_total_usage_seconds": 0.0,
  "next_reset_time": "2026-05-13T12:39:29"
}

daily_cpu_limit_seconds: 100 が見えれば、無料プランで認証できています。

6. PA 上で git clone + venv + pip install を API で叩く

PA の無料プランには SSH がありませんが、Consoles API で同等の操作ができます。コンソールを API で作成 → ブラウザで一度だけ開いてアタッチ (これが 2 回目のブラウザ操作) → 以降は send_input + get_latest_output でやり取りします。

6-1. コンソール作成

curl -sS -H "Authorization: Token ${PA_TOKEN}" \
  -X POST "${PA_API}/consoles/" \
  -d "executable=bash" \
  -d "arguments=" \
  -d "working_directory=/home/${PA_USER}"
# => { "id": 46738749, "console_url": "/user/<pa-user>/consoles/46738749/", ... }

返ってきた id を控えます (環境変数 CID とします)。

6-2. コンソールをブラウザで一度だけ開く

curl -sS -H "Authorization: Token ${PA_TOKEN}" \
  "${PA_API}/consoles/${CID}/get_latest_output/"
# => { "error": "Console not yet started.  Please load it (or its iframe) in a browser first" }

無料プランでは「コンソールが API で作成された段階」と「ブラウザで一度開かれてアタッチされた段階」が別物で、未アタッチの間は send_input / get_latest_output がエラーを返します。そこで、コンソール URL (https://www.pythonanywhere.com/user/<pa-user>/consoles/<CID>/) を一度ブラウザで開いて、プロンプトが見えたら閉じる だけで OK。コマンドは打たなくて良いです。

ブラウザを閉じた後で再度 get_latest_output を叩けば、Loading Bash interpreter... <pa-user>$ のような出力が見えて、アタッチ完了が確認できます。

6-3. bootstrap スクリプトをファイル経由で送る

「API でコマンドを送る」とは具体的には send_input への POST で文字列を流すことなのですが、ここでハマりどころが 1 つあります。

ハマりどころ: & がフォームデータ区切りとして食われる

最初、こう書きました:

curl ... -X POST "${PA_API}/consoles/${CID}/send_input/" \
  -d $'input=bash ~/bootstrap.sh 2>&1\n'

結果、コンソール側には bash ~/bootstrap.sh 2> までしか届かず、> 継続プロンプトに刺さってその後の 9 分間 CPU を消費せずただ待機していました。原因は curl の -d (application/x-www-form-urlencoded) で & が複数フォームフィールドの区切りとして解釈されるため、input=bash ~/bootstrap.sh 2>1\n の 2 フィールドに分かれてしまい、2 つ目は無名なので捨てられた、という挙動でした。

bash -c 経由でも printf でも回避できません。正しいのは --data-urlencode を使う こと。

curl ... -X POST "${PA_API}/consoles/${CID}/send_input/" \
  --data-urlencode "input=bash ~/bootstrap.sh
"

(末尾に Enter のための改行 1 つ。2>&1 は不要 — Bash がアタッチされた TTY 上では stdout / stderr 両方が get_latest_output に乗ってきます。)

ハングしたコンソールを救出するときは、Ctrl-C を送ります:

curl ... -X POST "${PA_API}/consoles/${CID}/send_input/" --data "input=%03%0A"

%03 が Ctrl-C (バイト 0x03)、%0A が LF。これを送ると ^C が出てプロンプトに戻ります。

bootstrap.sh 本体

ローカルで以下を書いて、Files API で PA に送ります。

#!/bin/bash
set -e
cd ~
rm -rf JSONkeeper
git clone https://github.com/IllDepence/JSONkeeper.git
cd JSONkeeper
python3.11 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
pip install 'Flask==2.0.3' 'apscheduler>=3.10'
python -c "from jsonkeeper import create_app; app=create_app(); print('==BOOTSTRAP DONE==', len(list(app.url_map.iter_rules())), 'routes')"
# ファイル送信は multipart-form の content フィールドに @path 指定
curl -sS -f -H "Authorization: Token ${PA_TOKEN}" -X POST \
  -F "content=@/tmp/bootstrap.sh" \
  "${PA_API}/files/path/home/${PA_USER}/bootstrap.sh"

そしてコンソールに実行を指示:

curl -sS -H "Authorization: Token ${PA_TOKEN}" -X POST \
  "${PA_API}/consoles/${CID}/send_input/" \
  --data-urlencode "input=bash ~/bootstrap.sh
"

完了マーカーをポーリングする小さなループ:

URL_OUT="${PA_API}/consoles/${CID}/get_latest_output/"
START=$(date +%s)
until curl -sS -H "Authorization: Token ${PA_TOKEN}" "$URL_OUT" \
        | grep -q "==BOOTSTRAP DONE=="; do
  NOW=$(date +%s)
  [ $((NOW - START)) -gt 540 ] && { echo TIMEOUT; break; }
  sleep 20
done

筆者の環境では git clone + pip install で 191 秒 (実時間)、CPU 消費は 30.7 秒でした。100 秒上限のうち、pip install の重さで言うと firebase_admin + google-cloud-* 一式が大きいですが、無料プランでも十分通せます。

7. 設定ファイル 3 つを Files API でアップロード

ローカルに以下 3 ファイルを用意して、Files API で PA に送ります。

7-1. config.ini

[environment]
db_uri = sqlite:////home/<pa-user>/JSONkeeper/keep.db
server_url = https://<pa-user>.pythonanywhere.com
log_file = /home/<pa-user>/JSONkeeper/jk_log.txt

[api]
api_path = api

[firebase]
service_account_key_file = /home/<pa-user>/JSONkeeper/firebase-adminsdk.json

[json-ld]
rewrite_types = http://codh.rois.ac.jp/iiif/curation/1#Curation,
                http://iiif.io/api/presentation/2#Range

[activity_stream]
collection_endpoint = as/collection.json
activity_generating_types = http://codh.rois.ac.jp/iiif/curation/1#Curation,
                            http://iiif.io/api/presentation/2#Range

db_uri のスラッシュ 4 つは SQLAlchemy 流の絶対パス表現 (sqlite:/// + /home/... のスラッシュ 1 つで sqlite:////home/...)。server_url末尾スラッシュなし、スキーマ + ホストまで。

7-2. wsgi_pythonanywhere.py (JSONkeeper 配下に置く実体)

import sys, os

PROJECT_DIR = '/home/<pa-user>/JSONkeeper'
if PROJECT_DIR not in sys.path:
    sys.path.insert(0, PROJECT_DIR)
os.chdir(PROJECT_DIR)

from jsonkeeper import create_app
application = create_app()

7-3. PA のデフォルト WSGI ファイル (/var/www/<pa-user>_pythonanywhere_com_wsgi.py)

import sys
sys.path.insert(0, '/home/<pa-user>/JSONkeeper')
from wsgi_pythonanywhere import application  # noqa

PA は Web アプリを作成すると /var/www/<domain-with-underscores>_wsgi.py を自動生成し、そこから application を import します。今回は薄いラッパーを書いて、実体の wsgi_pythonanywhere.py を読み込む形に分けました (実体側を Project Directory にあるファイルとして編集可能にするため)。

アップロードコマンド

BASE="${PA_API}/files/path"
# Firebase service account JSON (秘密、ローカル → PA、Git は経由しない)
curl -sS -f -H "Authorization: Token ${PA_TOKEN}" -X POST \
  -F "content=@/path/to/<firebase-pid>-firebase-adminsdk.json" \
  "${BASE}/home/${PA_USER}/JSONkeeper/firebase-adminsdk.json"

# config.ini
curl -sS -f -H "Authorization: Token ${PA_TOKEN}" -X POST \
  -F "content=@/local/path/config.ini" \
  "${BASE}/home/${PA_USER}/JSONkeeper/config.ini"

# wsgi_pythonanywhere.py
curl -sS -f -H "Authorization: Token ${PA_TOKEN}" -X POST \
  -F "content=@/local/path/wsgi_pythonanywhere.py" \
  "${BASE}/home/${PA_USER}/JSONkeeper/wsgi_pythonanywhere.py"

8. Web アプリ作成 → WSGI 差し替え → リロード

ここからは Webapps API。Beginner プランの場合、ドメイン名は強制的に <pa-user>.pythonanywhere.com です。

# 作成
curl -sS -H "Authorization: Token ${PA_TOKEN}" -X POST "${PA_API}/webapps/" \
  -d "domain_name=${PA_DOMAIN}" \
  -d "python_version=python311"
# => { "status": "OK", "webapp_config_path": "..." }

# PA のデフォルト WSGI を自前ラッパーに差し替え
curl -sS -f -H "Authorization: Token ${PA_TOKEN}" -X POST \
  -F "content=@/local/path/pa_default_wsgi.py" \
  "${PA_API}/files/path/var/www/${PA_USER}_pythonanywhere_com_wsgi.py"

# source_directory + virtualenv_path を webapp に設定
curl -sS -H "Authorization: Token ${PA_TOKEN}" -X PATCH \
  "${PA_API}/webapps/${PA_DOMAIN}/" \
  -d "source_directory=/home/${PA_USER}/JSONkeeper" \
  -d "virtualenv_path=/home/${PA_USER}/JSONkeeper/venv"

# リロード
curl -sS -H "Authorization: Token ${PA_TOKEN}" -X POST \
  "${PA_API}/webapps/${PA_DOMAIN}/reload/"
# => { "status": "OK" }

9. スモークテスト

# ルート — JSONkeeper の生存と保存中の件数
$ curl -sS -i "https://${PA_DOMAIN}/"
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: *
...
{"message":"Storing 0 JSON documents."}

# 匿名 POST → 201 + Location
$ curl -sS -i -X POST -H 'Content-Type: application/json' \
       -d '{"hello":"pa"}' "https://${PA_DOMAIN}/api"
HTTP/1.1 201 CREATED
Location: https://<pa-user>.pythonanywhere.com/api/c3881e3f-26ed-4a2d-92f3-bfca76ce0826
Access-Control-Allow-Origin: *
...
{"hello":"pa"}

# 同じ URL で GET → 200 + 元の JSON
$ curl -sS "https://<pa-user>.pythonanywhere.com/api/c3881e3f-26ed-4a2d-92f3-bfca76ce0826"
{"hello":"pa"}

Firebase Admin SDK の初期化を JSONkeeper 側のログで確認:

$ curl -sS -H "Authorization: Token ${PA_TOKEN}" \
       "${PA_API}/files/path/home/${PA_USER}/JSONkeeper/jk_log.txt" | tail -10
[2026-05-12 13:00:41]   starting
[2026-05-12 13:00:41]   using Firebase
[2026-05-12 13:00:58]   posted/put document:
[2026-05-12 13:00:58]       is JSON-LD: False
[2026-05-12 13:00:58]       received/has a dereferencable @id: False
[2026-05-12 13:00:58]       is unlisted: False
[2026-05-12 13:00:58]       access token: 

using Firebase が出ていれば、サービスアカウント鍵から kunshujo-c (= <firebase-pid>) のクレデンシャルでクライアントが組み立てられた状態です。エラー時はここに Firebase config error: ... 系のメッセージが出ます。

CORS は JSONkeeper 標準で Access-Control-Allow-Origin: * を全レスポンスに付与する作りなので、Viewer 側で追加設定は要りません (jsonkeeper/subroutines.py:419add_CORS_headers)。プリフライトの Access-Control-Request-Headers を反射する実装なので X-Firebase-ID-Token のようなカスタムヘッダも自動で通ります。

10. Viewer (codh-mirror) 側の差し替え

最後に Viewer 側を 2 ファイル書き換えます。

authFirebase.js

var firebaseConfig = {
    apiKey: '<firebase-web-api-key>',
    authDomain: '<firebase-pid>.firebaseapp.com',
    projectId: '<firebase-pid>',
    storageBucket: '<firebase-pid>.appspot.com',
    messagingSenderId: '<sender-id>',
    appId: '<app-id>'
};

Firebase 側で authDomain は古い *.firebaseapp.com 形式、storageBucket も同様に *.appspot.com 形式に揃えるのがおすすめです。Firebase Console が最近表示する *.firebasestorage.app 形式は Web SDK v5 系では認識されないことがあるため。

index.jscurationJsonExportUrl

cd <codh-mirror-repo>
sed -i.bak \
  "s|https://mp.ex.nii.ac.jp/api/curation/json|https://<pa-user>.pythonanywhere.com/api|g" \
  iiif-curation-viewer/index.js
rm iiif-curation-viewer/index.js.bak

index.js 内には 5 箇所同じ URL があるので、まとめて置換します。GitHub Pages に反映するには git commit + git push

11. トラブルシューティング

実際に詰まったところ / 詰まりうるところを残します。

症状原因対処
API 経由のコンソールに send_input してもエコーが返らない無料プランはブラウザでアタッチするまで送信不可コンソール URL をブラウザで一度開いてプロンプトを表示するだけで OK
コンソールに送ったコマンドの一部が消える (2>&1&1 が落ちる)curl の -d& がフォーム区切りとして解釈される--data-urlencode "input=..." を使う。Ctrl-C は --data "input=%03%0A"
from collections import MutableMapping で ImportErrorFlask 1.0.2 が Python 3.10+ で非互換pip install 'Flask==2.0.3'
No module named 'pkg_resources'apscheduler 3.5.3 が pkg_resources 依存、3.12+ で標準で入らないpip install 'apscheduler>=3.10'
Web アプリが 502 / Something went wrongWSGI import エラーPA Web タブの Error log を curl で取得: curl -sS -H "Authorization: Token ${PA_TOKEN}" "${PA_API}/files/path/var/log/${PA_DOMAIN}.error.log"
POST が 403 で「Token verification failed」外向き whitelist で securetoken.googleapis.com 等が通っていないPA support に「Please add securetoken.googleapis.com to the free-tier whitelist for backend Firebase Admin SDK token verification」とメール
ブラウザの Viewer 側でログインポップアップが「unauthorized domain」Firebase Console の Authorized domains に GitHub Pages ドメインが未登録Authentication → Settings → Authorized domains に <gh-user>.github.io を追加
pip install で CPU 100 秒に当たって中断無料プランの 1 日あたり CPU 上限翌日 (UTC) リセット後に同じ pip install を再実行。pip cache が効くので 2 回目は短い
PyLD が 400 Bad Request: No valid JSON-LD provided (this can be due to a context that can not be resolved) を返すJSON-LD の @context を JSONkeeper が外向き解決しようとして失敗 (codh.rois.ac.jp/iiif/curation/1/context.json のような外部 context URL が解決できない場合)Content-Type: application/json に落とすか、@context をフルインライン化するか、PyLD 側のドキュメントローダにキャッシュを噛ませる

12. 撤収手順 (オリジナル URL への切り戻し)

# Viewer 側の URL を本家に戻す
sed -i.bak \
  "s|https://<pa-user>.pythonanywhere.com/api|https://mp.ex.nii.ac.jp/api/curation/json|g" \
  <codh-mirror-repo>/iiif-curation-viewer/index.js

# PA Web アプリを停止 + 削除 (任意)
curl -sS -X DELETE -H "Authorization: Token ${PA_TOKEN}" \
  "${PA_API}/webapps/${PA_DOMAIN}/"

config.inifirebase-adminsdk.json も同時に消すなら Files API の DELETE で個別に消去できます。

13. なぜ「塩漬け」にしているのか

筆者は本記事の構成で PA 上の JSONkeeper を動作確認まで終えた後、Cloudflare Workers + D1 で書き直した別実装に本流を切り替えました。理由は次の通り:

PA (Flask アップストリーム)Workers + D1
無料枠の実用性CPU 100 秒/日 (リクエスト処理は対象外だが、依存更新時の pip install でぶつかる)100k requests/日 + D1 5GB / 5M reads / 100k writes/日
レイテンシPA の web ワーカーは欧米リージョン (日本から 200ms 前後)エッジ (Tokyo/Osaka 含む) で日本から 10〜30ms
コールドスタート長時間アイドルでありうるエッジで常時 hot
外向き通信ホワイトリスト経由制限なし
永続性PA の SQLite ファイル (アカウント生存中)D1 (Cloudflare 管理、自動レプリケーション)
アップストリーム互換性◎ 完全 (X-Unlisted / /<id>/status / Activity Stream / GC / Range サブ URL)△ Viewer が使うコア (POST/GET/PUT/DELETE/userdocs/CORS/JSON-LD @id rewrite/Firebase) のみ。X-Unlisted や Activity Stream のページネーションは未実装

Viewer のエクスポートフローは PUT/POST + Location + JSON-LD @id 書き換え + Firebase ID トークン検証 だけで成立するため、Workers + D1 でも十分。運用負荷の低い側に寄せる判断をしました。

ただし、「JSONkeeper のアップストリーム実装をそのまま動かす」こと自体に価値がある ケース (Activity Stream を別のシステムが購読する想定、X-Unlisted の挙動を厳密に再現したい、アーカイブ目的) は PA 側のほうが向いています。本記事の構成は、そのような用途のためにもそのまま残しておけるので、今は PA 側のデプロイを「塩漬け」状態で生かしたまま、本流は Workers 側、という二系統運用にしています。

14. セキュリティ後片付け

PA の API トークンはアカウント全体に対する強い権限を持ちます。デプロイ作業が一段落したら、運用フェーズに入る前にトークンを失効させて新規発行し直し、ローカル端末以外には保持しない運用にしておくのが安全です:

  1. https://www.pythonanywhere.com/account/#api_token を開く
  2. 「Revoke API token」をクリック
  3. 「Create a new API token」で新規発行

サービスアカウント JSON も同様に、誤って意図しない場所に露出した疑いがあれば Firebase Console のサービスアカウント画面で該当鍵を削除 + 新規生成 → PA 側のファイル差し替え → Web アプリリロード、で無効化できます。

おわりに

「IIIF Curation Viewer の保存先がアクセス不能になっている」という小さな問題に対して、外部依存をなくして自前で動かす道筋を残しておきたかった、というのが本記事の動機です。

得られたものは大きく 3 点:

  • アップストリーム JSONkeeper は 2 行の pip コマンドの追加だけで 現代 Python (3.11) で動く
  • PythonAnywhere Beginner プランは「SSH 不可」「CPU 100 秒/日」というニュアンスの強い制約があるが、HTTP API 中心の運用と「リクエスト処理は CPU 上限の対象外」というルールを組み合わせることで、小〜中規模の Flask アプリの長期常駐先として十分機能する
  • ブラウザ操作を「API トークン発行」と「Bash コンソールの初回アタッチ」の 2 回だけに限定できれば、デプロイ全体を curl でスクリプト化できる。これは「再現可能性」「他人に渡しやすさ」の観点で大きい

オリジナル URL の復旧の見込みが立った段階で、この PA 上の JSONkeeper も含めて codh-mirror リポジトリ全体をアーカイブ化する予定です。それまでは「自前で立てたミニ JSONkeeper を生かしておく」 + 「本流は Cloudflare Workers + D1 の自作再実装に寄せる」 二系統運用で凌ぎます。

同じく「保存系も動かしたい」と思っている方の参考になれば幸いです。