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

参加した国際会議の事務局から、発表資料を Zenodo のコミュニティに登録できます、という案内が届きました。スライド・ポスター・論文・データ・コードが対象で、任意参加、登録すると DOI(Digital Object Identifier、永続的な識別子)が付く、という内容です。

ブラウザからアップロードすれば済む話ではあるのですが、メタデータを画面で手入力すると、何をどう入れたのかが手元に残りません。同じ作業は毎年発生しますし、共著者や題目の表記を後から見返したくなります。そこで REST(Representational State Transfer)API 経由で行い、メタデータを JSON ファイルとして手元に残す形にしました。

作業自体は 30 分ほどで終わる想定でしたが、実際には API 仕様の調査と、API とは無関係な 2 つの足止めに時間を使いました。以下はその記録です。

全体の流れ

Zenodo は InvenioRDM というリポジトリソフトウェアの上で動いています。登録は 4 ステップに分かれます。

Zenodo への登録が 4 ステップに分かれることを示した図。左から順に、1. ドラフト作成(メタデータだけ登録、POST /api/records → 201)、2. ファイル投入(枠→本体→確定 の3手、POST/PUT/POST .../draft/files/… → 201/200/200)、3. 提出先の指定(どのコミュニティか、PUT .../draft/review → 200)、4. 提出(この先はコミュニティ側、POST .../actions/submit-review → 202)の 4 つの箱が矢印で連結されている。下段には「レコードの状態(GET .../draft の status)」という見出しの下に各ステップ時点での状態が並び、1 と 2 の時点では draft、3 の後は draft_with_review、4 の後は in_review となる。図の下部に、公開と DOI 発行はこの後にコミュニティ側が承認した時点であること、承認前なら取り下げも編集もできること、「アップロードが終わった」=「提出できた」ではなく 3 と 4 を忘れるとドラフトのまま残ること、ファイル投入だけが 3 手に分かれており名前で枠を作り本体を送り最後に確定することが記されている。

ファイル投入だけが 3 手に分かれています。まず名前だけを登録して枠を作り、その枠に本体を転送し、最後に確定します。

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '[{"key":"slides.pdf"}]' "$API/records/$RECID/draft/files"
curl -X PUT --upload-file slides.pdf -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/octet-stream" "$API/records/$RECID/draft/files/slides.pdf/content"
curl -X POST -H "Authorization: Bearer $TOKEN" "$API/records/$RECID/draft/files/slides.pdf/commit"

注意が要るのは、ここまでやっても提出は完了していないことです。ステップ 3 と 4 を忘れると、自分にしか見えないドラフトのまま残ります。締切がある案内でこれをやると、本人は出したつもりで先方には何も届いていない、という状態になります。

提出のエンドポイントを、リファレンスではなく実装から見つけてしまった

Zenodo 自身の API ドキュメント(developers.zenodo.org)が扱っているのは旧 deposit API(/api/deposit/depositions)で、いま使われている /api/records 系は載っていません。ヘルプページのほうには画面操作の手順(「Submit for review」ボタンを押す)はありますが、対応する API の説明はありません。

InvenioRDM のリファレンスに移って Drafts and Records のページを見ると、ドラフトの作成・ファイル投入・公開(コミュニティを介さず直接公開する publish アクション)までは載っています。ところが「ドラフトをコミュニティに提出する」部分が見当たりません。Requests のページも見ましたが、こちらにあるのは既存のリクエストを承認・却下する操作でした。

そこで、Zenodo を呼び出しているクライアント実装のソースを読みました。R のパッケージ zen4R に該当のメソッドがあり、次の 2 本を叩いていることが分かりました。

PUT  /api/records/{id}/draft/review
     {"receiver": {"community": "<community-uuid>"}, "type": "community-submission"}

POST /api/records/{id}/draft/actions/submit-review
     {"payload": {"content": "<メッセージ>", "format": "html"}}   ※ メッセージは任意

community に入れるのはコミュニティの UUID です。試した限りでは、スラッグ(URL に出る短い名前)では受け付けられませんでした。UUID は認証なしで引けます。

curl -s "https://zenodo.org/api/communities/<community-slug>" | jq -r '.id'

実行してみると、期待どおり 200 と 202 が返りました。

ここまで書いてから気づいたのですが、これらは リファレンスに載っています。Drafts and Records とは別に Reviews という独立したセクションがあり、上のペイロード例とほぼ同じものが、ステータスコード付きで書かれています。ページ自体は 2022 年から変わっていないので、探し方の問題でした。個別のページから辿るのではなく、リファレンスの索引でセクション一覧を先に見ていれば、実装を読む必要はありませんでした。

とはいえ、実装を読んだことが無駄だったわけでもありません。リファレンスの community の値は {community_id} としか書かれておらず、そこにスラッグではなく UUID を入れるという点は、実装を読むか実際に叩くまで確定できませんでした。

メタデータを推測で埋めない

メタデータは JSON 1 枚(metadata.json)にまとめ、それをそのまま POST /api/records の本文にしました。項目の値をどこから持ってきたかを整理すると次のようになります。

メタデータの各項目をどの情報源から埋めたかを示した図。中央に metadata.json の箱があり、「これ 1 枚がレコードの正本」と書かれている。左側から 4 本の矢印が入っており、それぞれ、提出済み原稿(TEI = Text Encoding Initiative 形式の XML)から題目・概要・キーワード、語彙 API から resource_type と licenses、所属組織 API から ROR ID(研究機関の識別子)による機関の一意化、同系統のコミュニティの既存レコードの書き方に倣うこと、を得ている。右側からは ORCID 公開 API(研究者識別子の公開 API)から 1 本の矢印が入り、同姓同名を業績によって判別することを示している。その下には矢印を持たない注記の箱があり、認証トークンは 1Password で扱い、メタデータとは別に管理することが書かれている。図の下部に、推測で埋めた項目はゼロにすること、埋められない項目は空のままにして後から直せる形で残すこと、既存レコードを読むと仕様書に載っていない「その分野での慣習的な書き方」が分かること、が記されている。

語彙 ID は API で確認する

資料の種別やライセンスは自由文字列ではなく、決められた語彙の ID です。推測せずに引きます。

curl -s "https://zenodo.org/api/vocabularies/resourcetypes?q=presentation" | jq -r '.hits.hits[].id'
curl -s "https://zenodo.org/api/vocabularies/resourcetypes?q=conference" | jq -r '.hits.hits[].id'
curl -s "https://zenodo.org/api/vocabularies/licenses?q=cc-by-4.0" | jq -r '.hits.hits[0].id'

発表スライドは presentation、会議論文は publication-conferencepaper でした。ライセンスの検索は部分一致なので、cc-by-4.0 で引いても派生ライセンスを含めて 25 件返ります。完全一致が先頭に来るので先頭だけを採っています。

所属機関は識別子で入れる

ROR(Research Organization Registry)は研究機関の識別子で、Zenodo の所属組織 API が返します(ROR 以外のスキームの識別子が混ざることもあります)。ID で指定すると、表示名や別名(大学病院や附置研究所など、名前の似た別組織)との取り違えを防げます。

curl -s "https://zenodo.org/api/affiliations?q=%22University%20of%20Tokyo%22" | jq -r '.hits.hits[] | [.id, .name] | @tsv'

このクエリでは、大学本体のほかに大学病院・財団・別法人の大学が並びます。名前の一致だけで選ぶと間違えるところです。

会議情報は既存レコードの書き方に合わせる

Zenodo には会議情報を入れる独自フィールド(custom_fieldsmeeting:meeting)があります。仕様は分かっても「その学会ではどう書かれているか」は分かりません。過去の同系統のコミュニティのレコードを実際に引いて、書式を揃えました。

curl -s -H "Accept: application/vnd.inveniordm.v1+json" "https://zenodo.org/api/records/<id>" | jq '.custom_fields'
{
  "meeting:meeting": {
    "title": "…(会議の正式名称)",
    "acronym": "…",
    "dates": "…",
    "place": "…",
    "url": "…",
    "session": "…",
    "session_part": "…"
  }
}

過去のレコードを見ると、dates は日付を範囲で繋ぐもの、月名を先に置くもの、数字だけをピリオドで区切るものと表記が揺れていました。使われるキーの語彙は共通ですが、7 つが常に揃うわけではなく、題目と URL だけ、といったレコードも珍しくありません(このほかに identifiers というキーもあります)。日付欄が自由文字列であることも、実物を見て初めて分かります。

同姓同名は業績で判別する

著者の ORCID(Open Researcher and Contributor ID、研究者の識別子)は公開 API で検索できますが、姓名と所属だけでは同姓同名を取り違える可能性があります。候補の業績一覧を引いて、分野が一致することを確認してから採用しました。

curl -s -H "Accept: application/json" "https://pub.orcid.org/v3.0/<orcid>/works" | jq -r '.group[0:5][]["work-summary"][0].title.title.value'

概要は提出済み原稿から原文のまま引く

最初は自分で要約を書きましたが、公式に提出した原稿の文章がそのまま使えます。投稿システムからダウンロードできる提出パッケージ(拡張子は独自ですが実体は ZIP で、中身は TEI = Text Encoding Initiative 形式の XML と、Word・HTML・画像)から、本文冒頭の段落を抜き出しました。要約を書き起こすより、著者本人が確定させた文章を使うほうが妥当です。

このとき、提出パッケージ内の題目と、手元の企画書の題目が冠詞ひとつ分だけ違っていることにも気づきました(AAn)。正式な提出物のほうに合わせています。

落とし穴1: 応答が 2 種類ある

登録後に内容を確認しようとして、しばらく混乱しました。

curl -s -H "Authorization: Bearer $TOKEN" "https://zenodo.org/api/records/<id>/draft" | jq '.metadata.subjects'

これが null を返します。キーワードは確かに登録したはずなのに、読み出すと空に見える。

原因は、Zenodo が旧 Zenodo 形式(creators{name, affiliation, orcid} の平たい形)と InvenioRDM 形式(creators[].person_or_org の入れ子)の 2 系統のシリアライズを持っていて、Accept を指定しないと前者で返ってくることでした。書き込みは InvenioRDM 形式なのに、読み出しは旧形式が既定という組み合わせです。

curl -s -H "Accept: application/vnd.inveniordm.v1+json" -H "Authorization: Bearer $TOKEN" "https://zenodo.org/api/records/<id>/draft" | jq '.metadata.subjects'

こちらなら書いたとおりに返ります。もう少し正確に言うと、旧形式の metadata.subjects は統制語彙用の枠で、自由文で入れたキーワードは旧形式では metadata.keywords のほうに現れます。同じ名前のキーが別の意味で存在するので、subjects を見て空だから登録失敗、と読んでしまいます。書き込みと読み出しで形式が違うので、誤診しやすいところです。確認用のスクリプトにも Accept を付けておくのが安全でした。

落とし穴2: zsh で local path=... と書くと PATH が壊れる

スクリプトを書き終えて実行したところ、こうなりました。

token: 1Password (op://…)
host: https://zenodo.org
api:5: command not found: curl

curl が見つからない、と言われます。同じシェルの外側では command -v curl/usr/bin/curl を返しますし、単体のスクリプトからも問題なく動きます。

原因は、API 呼び出しをまとめた関数の第 2 引数の名前でした。

api() {
  local method=$1 path=$2 data=${3:-}   # ← path が PATH を壊す
  curl -sS -o "$RESP" -w '%{http_code}' -X "$method" "$API$path"
}

zsh では pathPATH に連動する特殊変数(配列側の別名)です。local を付けても連動は切れないので、local path=/user/records と書いた瞬間、その関数の中では PATH/user/records になります。結果として curl が探せなくなり、「コマンドが見つからない」というエラーになります。

変数名を endpoint に変えるだけで解決しました。cdpath fpath manpath なども同じ性質を持つ(man zshparam<S> の印が付いているもの)ので、汎用的な名前を使いたくなる箇所ほど注意が要ります。なお local -h path=... のように -h を付けると特殊変数としての性質を隠せますが、読み手に意図が伝わりにくいので、名前を変えるほうが素直だと思います。

なお、この症状は zsh -x で追わないと分かりにくいです。トレースを出すと、curl の行に到達しているのにコマンドが解決できていないことがはっきり見えます。ただしトレースには Authorization: Bearer … の実値も出力されるので、ダミーのトークンに差し替えてから実行しました。

落とし穴3: 途中で 1Password CLI だけが使えなくなる

トークンは 1Password に入れ、スクリプトからは op read で取り出しています。作業の途中で、これだけが突然失敗するようになりました。Zenodo API には同じ端末から問題なく繋がるので、ネットワークが落ちているわけではありません。

原因は VPN が張った IPv6 のデフォルト経路が実際には通っていないことで、Zenodo とは関係のない環境側の問題でした。curl は IPv4 に落ちて成功し、op は IPv6 を掴んだまま失敗する、という割れ方をします。切り分けの詳細はVPN が張った IPv6 経路の切り分け記録にまとめました。

秘密情報を外部サービスから取り出す構成にしている以上、こうした経路の問題で作業が止まることは起こり得ます。トークンを平文で置かない方針とのトレードオフとして受け入れる部分です。

トークンを会話にもファイルにも残さない

作業は生成AIとの対話で進めたため、トークンの扱いには通常より気を使いました。

  • 値をチャットに貼らない。貼った時点で会話ログに残り、失効と再発行が必要になります
  • クリップボードから直接 1Password に投入する。op item create … "credential[concealed]=$(pbpaste)" のように、コマンド文字列には値を書かず、シェルに展開させます
  • 作成コマンドの出力は /dev/null に捨て、成否は終了コードで判定する。項目作成の応答に値が含まれる可能性があるためです
  • 投入後は op read … | wc -c で文字数だけ突き合わせる。値そのものは表示しません

トークンが有効かどうかも、値を出さずに確認できます。

T=$(pbpaste | tr -d '[:space:]')
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" 'https://zenodo.org/api/user/records?size=1'
unset T

200 が返ればそのホストで有効なトークン、403 なら使えないトークン(無効・スコープ不足・別環境用など)と判別できます。値を画面に出さずに確認できるのが利点です。

実行結果

最終的なスクリプトは、ドラフト作成・ファイル投入・提出先の指定までを行い、提出だけは別操作にしました。内容を確認してから出したいのと、スクリプトを再実行すると別のドラフトが新規に作られてしまうためです。

token: 1Password (op://…)
host: https://zenodo.org
  ok: token verified
  ok: draft created
  record id: <record-id>
file: slides.pdf ( 26M)
  ok: slot created
  ok: content uploaded
  ok: commit
  ok: review request attached to <community-slug>

draft のみ作成した (未提出)。内容を確認してから提出すること:

提出後は statusin_review に変わります。ここから先はコミュニティ側の承認待ちで、承認された時点で公開され DOI が発行されます。承認前であれば編集も取り下げもできます。

メタデータだけを差し替えたい場合は、PUT /api/records/{id}/draft に同じ JSON を投げ直せば済みます。ファイルには影響しないので、文面を直して何度でも実行できます。今回も、概要を自分の要約から提出済み原稿の原文に差し替える際にこれを使いました。

手元に残ったもの

zenodo/
  metadata.json   メタデータの正本
  upload.zsh      ドラフト作成 → ファイル投入 → 提出先の指定
  update.zsh      既存ドラフトのメタデータ差し替え

来年また同じ案内が来たときは、metadata.json の題目・著者・会議情報を書き換えるところから始められます。画面で入力していたら、この 3 ファイルは残りませんでした。

登録先が Zenodo でなくても、InvenioRDM を採用しているリポジトリであれば同じエンドポイント構成のはずです(各サイトの語彙 ID と独自フィールドは異なります)。