本記事は生成 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 を追加 |
| JSONkeeper | PythonAnywhere Beginner プラン (https://<pa-user>.pythonanywhere.com/) で常駐。Firebase Admin SDK でサーバサイド ID トークン検証 |
| Viewer 側 | authFirebase.js の firebaseConfig を自前プロジェクトに、index.js の curationJsonExportUrl を 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 の @type が cr: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.io | △ | 2024 年以降「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.py が from collections import MutableMapping を使っており、3.10 で collections.abc に移されたため動かない。
ModuleNotFoundError: No module named 'pkg_resources'
apscheduler 3.5.3 の apscheduler/__init__.py が from 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 config | Firebase Console → 「ウェブアプリ追加」 | Viewer リポの authFirebase.js | 公開して OK (リポにコミット可) |
| Service account JSON | Firebase 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 トークン発行。
- https://www.pythonanywhere.com/account/#api_token を開く
- 「Create a new API token」をクリック
- 表示されたトークンをコピーしておく (一度閉じると再表示できないので注意)
以降、ローカル端末でこのトークンを環境変数に入れます:
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:419 の add_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.js の curationJsonExportUrl
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 で ImportError | Flask 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 wrong | WSGI 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.ini や firebase-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 トークンはアカウント全体に対する強い権限を持ちます。デプロイ作業が一段落したら、運用フェーズに入る前にトークンを失効させて新規発行し直し、ローカル端末以外には保持しない運用にしておくのが安全です:
- https://www.pythonanywhere.com/account/#api_token を開く
- 「Revoke API token」をクリック
- 「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 の自作再実装に寄せる」 二系統運用で凌ぎます。
同じく「保存系も動かしたい」と思っている方の参考になれば幸いです。
