Claude Media
Claude CodeでUnpaywall APIを引き、機関リポジトリの登録漏れを洗う

Claude CodeでUnpaywall APIを引き、機関リポジトリの登録漏れを洗う

研究室のDOI一覧をUnpaywall APIに通し、OA区分とリポジトリ版の有無から機関リポジトリへの登録候補を洗い出す手順です。自機関の登録かどうかの見分け方と、権限設定も載せています。

研究室や学科の論文リストを手元に持っていても、機関リポジトリにどれだけ載っているかは別の話です。登録漏れを洗うには、論文ごとに「どこかに無料の本文があるか」「それは自機関のリポジトリか」を見る必要があります。Unpaywall APIは前者を、DOIを渡すだけで返してくれます。

この記事では、DOI一覧をAPIに通して判定CSVを作る小さなPythonスクリプトを置き、Claude Codeに実行と結果の整理を任せるところまでを扱います。判定の分かれ目になる項目と、自機関分の見分け方も書きます。

Unpaywallの応答で、登録漏れの判定に使う項目

Unpaywall APIは、DOIを1本渡すとその論文のオープンアクセス(OA)の状況と書誌情報をJSONで返します。エンドポイントは GET /v2/:doi で、リクエストには末尾に ?email= で自分のメールアドレスを付けます。

登録漏れの判定に効くのは、次の項目です。

項目内容判定での使い道
oa_status内容gold / hybrid / bronze / green / closed のいずれか判定での使い道公開の形の大づかみな分類
is_oa内容OAの本文がある(best_oa_location が null でない)と true判定での使い道本文がどこにも無い論文の抽出
has_repository_copy内容host_type が repository の場所が1つでもあれば true判定での使い道リポジトリ版の有無
oa_locations[].host_type内容publisher か repository判定での使い道公開先の種別
oa_locations[].version内容submittedVersion / acceptedVersion / publishedVersion判定での使い道登録できる版の見当
oa_locations[].url内容本文またはランディングページのURL判定での使い道自機関のリポジトリかの判定

host_type が repository になる公開先には、プレプリントサーバーも含まれます。has_repository_copy が true でも、それが自機関のリポジトリとは限りません。

ここが落とし穴です。スキーマの項目表には、どの機関のリポジトリかを示す項目が載っていません。そこで、oa_locations の url のホスト名を自機関のリポジトリのドメインと突き合わせて見分けます。たとえば機関リポジトリが repo.example.ac.jp なら、そのドメイン(またはサブドメイン)のURLを持つ場所が1つでもあれば、登録済みとみなします。

先に決めておく3つの条件

スクリプトを動かす前に、次の3点を確認します。

  • メールアドレス: すべての呼び出しに ?email=YOUR_EMAIL が要ります。実在する自分のアドレスを使います
  • 呼び出し回数: 1日10万回以内に収めるよう案内されています。研究室の数百本から数千本なら十分に余裕があります
  • 対象のDOI: UnpaywallはCrossrefのDOIにだけ対応します。DataCiteのDOI(データセットなど)は結果が返らないことがあります

APIのバージョンは現行のv2だけが対応です。論文タイトルで検索する /v2/search は2026年9月18日に廃止され、410 Gone を返します。タイトルしか手元にない論文は、DOIを引き当てる段階を別に用意します。OpenAlexで引く手順はOpenAlex APIの記事にあります。

数百件までなら、APIを書かずに済む方法もあります。Simple Query Toolに最大500件のDOIを貼ると、OA状況のCSVとJSON Linesが手に入ります。件数が多い、定期的に回したい、自機関分を機械判定したい、のどれかに当たるときにAPIの出番です。

判定スクリプトの全体

次のスクリプトは、DOI一覧のテキストを読み、論文ごとに判定を付けたCSVを書き出します。メールアドレスと自機関のリポジトリのドメインは、環境変数から読みます。ファイルに直書きしないのは、リポジトリにアドレスが残らないようにするためです。

import csv, json, os, sys, time
import urllib.error, urllib.parse, urllib.request
from urllib.parse import urlparse
 
EMAIL = os.environ.get("UNPAYWALL_EMAIL", "")
OUR = [h.strip().lower()
       for h in os.environ.get("OUR_REPO_HOSTS", "").split(",") if h.strip()]
 
 
def fetch(doi):
    q = urllib.parse.quote(EMAIL)
    d = urllib.parse.quote(doi.lower(), safe="/")
    url = f"https://api.unpaywall.org/v2/{d}?email={q}"
    try:
        with urllib.request.urlopen(url, timeout=30) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        return {"_error": e.code}
 
 
def in_our_repo(rec):
    for loc in rec.get("oa_locations") or []:
        host = urlparse(loc.get("url") or "").hostname or ""
        if loc.get("host_type") == "repository" and any(
                host == h or host.endswith("." + h) for h in OUR):
            return True
    return False
 
 
def judge(rec):
    if "_error" in rec:
        return f"要確認: HTTP {rec['_error']}"
    if in_our_repo(rec):
        return "登録済み"
    if rec.get("has_repository_copy"):
        return "候補A: 他のリポジトリに本文あり"
    if rec.get("is_oa"):
        return "候補B: 出版社側の公開のみ"
    return "候補C: OAの場所なし"
 
 
def row(doi, rec):
    best = rec.get("best_oa_location") or {}
    return {"doi": doi, "year": rec.get("year"), "title": rec.get("title"),
            "oa_status": rec.get("oa_status"),
            "best_host_type": best.get("host_type"),
            "best_version": best.get("version"),
            "best_license": best.get("license"), "judgement": judge(rec)}
 
 
def main(src, dst):
    if not EMAIL or not OUR:
        sys.exit("UNPAYWALL_EMAIL と OUR_REPO_HOSTS を設定してください")
    dois = [l.strip() for l in open(src, encoding="utf-8") if l.strip()]
    with open(dst, "w", newline="", encoding="utf-8-sig") as f:
        w = None
        for d in dois:
            r = row(d, fetch(d))
            if w is None:
                w = csv.DictWriter(f, fieldnames=list(r))
                w.writeheader()
            w.writerow(r)
            time.sleep(0.2)
 
 
if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

check_oa.py として保存し、次のように実行します。CSVを utf-8-sig で書くのは、Excelで開いたときに日本語が化けないようにするためです。

export UNPAYWALL_EMAIL="you@your-lab.example"
export OUR_REPO_HOSTS="repo.example.ac.jp"
python3 check_oa.py dois.txt oa_check.csv

判定ロジックの確認は、実在の論文ではなく手作りのダミー応答5件で行いました。自機関のURLを持つもの、他のリポジトリだけのもの、出版社公開だけのもの、OAなしのもの、HTTP 404のものを用意し、それぞれ「登録済み」「候補A」「候補B」「候補C」「要確認」に分かれることを確かめています。実際のAPI応答での挙動は、手元の10件ほどで先に試してから全件に広げてください。

判定の読み方と、候補ごとの次の一手

CSVの judgement 列は、次の5通りです。

判定意味次の一手
登録済み意味自機関ドメインのリポジトリ版がある次の一手対応不要
候補A意味他のリポジトリ(プレプリントサーバー等)に本文がある次の一手版と公開条件を見て、機関リポジトリへの登録を検討
候補B意味出版社の側でだけ公開されている次の一手著者版を登録できるかを出版社の方針で確認
候補C意味OAの本文がどこにも見つからない次の一手著者から最終稿をもらえるか相談
要確認意味APIがエラーを返した次の一手DOIの綴りと、CrossrefのDOIかを確認

優先順位は、手間の少ない順にA、B、Cが自然です。候補Aは本文の所在がすでに分かるので、収集の手間が小さくなります。Unpaywallのライブラリアン向けの案内でも、教員が機関リポジトリに預けないままウェブ上に置いたOA資料を見つけて取り込む使い方が挙げられています。案内には、スウェーデンの全国リポジトリSwePubが7万5千件のOA記録を足して該当記録を3割超増やした例や、カールトン大学の機関リポジトリが1,000件を足して本文の収録を倍にした例が載っています。

best_version 列も見ます。acceptedVersion は査読済みで出版社の体裁が入る前の版、publishedVersion は出版社版(version of record)、submittedVersion は査読前の版です。機関リポジトリに載せてよい版は出版社の許諾条件で変わるため、この列は「どの版が見つかっているか」の目安にとどめ、登録の可否そのものは別途判断します。

best_license が null のときは、OAのライセンスを判定できなかったか、OAのライセンスではなかったことを示します。再配布してよいかの根拠にはなりません。

bronze は、出版社のサイトで無料で読めても、オープンライセンスが付いていない区分です。候補Bのうち、著者版を機関リポジトリに置く意味が出やすいグループです。

誤読しやすい項目と、判定に使わない項目

スキーマ表には、集計の前に押さえておきたい注意書きがあります。

  • oa_locations_embargoed(ベータ): 将来公開されると見込まれる場所の一覧です。ライセンス情報や出版社の遅延OA方針から推定されます。oa_status には影響せず、best_oa_location にもなりません。今回の判定には使わず、エンバーゴ明けを待つ論文の別リストにする使い道があります
  • is_paratext: 目次のような、雑誌の付随的な部分かどうかを示します。研究室の論文リストに混じっていたら、判定対象から外す目印になります
  • oa_date: その場所で本文が最初に公開された日です。場所の種別によって計算方法が違い、すべての場所に入っているわけではありません。登録の時期を判断する根拠には使いません
  • evidence と updated(場所オブジェクト内): どちらも非推奨で、常に文字列 deprecated が入ります。もうすぐ削除される予定なので、読まないようにします

全件を手元で扱いたいときの選択肢

論文数が非常に多い機関では、DOIを1本ずつ引くより、データ全体を手元に持つほうが早いことがあります。ただし、Unpaywall自身のスナップショットは半年ごとの作成をやめており、同じデータはOpenAlexの月次に近い頻度のスナップショットから取る案内になっています。Data Feed(日次・週次の差分ファイル)は有料で、見積もりは問い合わせで決まります。

研究室や学科の規模なら、ここまでは要りません。1日10万回の目安に収まるうちは、APIで十分です。

Claude Codeに任せる範囲を決める

APIを叩くスクリプトは決定論的に動くので、判定そのものはClaude Codeに考えさせません。任せるのは、スクリプトの実行、結果CSVの集計、登録依頼の下書き、エラー行の原因の切り分けです。

プロジェクトの CLAUDE.md に、次の規約を置いておきます。

## OA登録漏れチェック
- 入力は dois.txt(1行1DOI)。出力は oa_check.csv。
- 実行は `python3 check_oa.py dois.txt oa_check.csv` のみ。
- UNPAYWALL_EMAIL と OUR_REPO_HOSTS は環境変数から読む。値を表示しない。
- 判定列(登録済み/候補A/B/C/要確認)は書き換えない。
- 集計は judgement ごとの件数と、候補Aの一覧を先に出す。
- 要確認の行は、DOIの綴り違いかCrossref以外のDOIかを切り分けて報告する。

権限は .claude/settings.json で絞ります。スクリプトの実行だけを許可し、アドレスを置いた .env の読み取りを拒否する形です。

{
  "permissions": {
    "allow": ["Bash(python3 check_oa.py *)"],
    "deny": ["Read(./.env)"]
  }
}

あとは、Claude Codeに次のように頼みます。

dois.txtにチェックを回して、judgementごとの件数を出して。
候補Aは、DOIと年とbest_versionを表にして。

候補CとBの論文は、著者への依頼文もClaude Codeに下書きさせられます。次のように、使ってよい列と書かないことを先に指定します。

judgementが候補Cの行から、依頼メールの下書きを作って。
入れてよい情報はtitle・year・doiだけ。
出版社の許諾条件は推測で書かず、「確認します」と書いて。

結果を受け取ったら、集計値を鵜呑みにせず、候補Aから3件ほどを選び、url を開いて本文があることを自分の目で見ます。この確認で「登録済み」の判定が自機関のURLに本当に当たっているかも分かります。

つまずきやすいところ

  • HTTP 422: メールアドレスを付けていないか、example.com のような例示用のアドレスを使っています。実在の自分のアドレスを環境変数に入れます
  • 自機関の登録なのに候補Aと出る: リポジトリのURLのホスト名が、OUR_REPO_HOSTS に入れたドメインと違っています。登録先のURLが hdl.handle.net のようなハンドル経由の形で記録されていると、ホスト名が自機関のものになりません。CSVの元になった oa_locations の url を見て、ドメインを足します
  • 登録済みなのに候補Aと出る(逆のずれ): Unpaywallがまだ自機関の登録を拾っていない可能性があります。データは日々変わります。新しく登録した分がすぐ反映されないことは起こりえるため、直近の登録分は一覧に残して後日引き直します
  • 結果が返らないDOI: Crossref以外のDOIは対象外です。データセットや一部のプレプリントのDOIが該当します
  • 旧式のタイトル検索を使っていたスクリプト: /v2/search は廃止済みで 410 Gone を返すので、DOIを引く構成に直します

回し方のコツ

数千件でも、time.sleep(0.2) のまま逐次で回せば、1日の呼び出し上限には収まります。待ち時間が気になるときは並列にするより、まず1回流してCSVを保存し、次回は変更のあったDOIだけを引き直す運用のほうが、呼び出し回数を抑えられます。

月次で回すなら、今回のCSVと前回のCSVを突き合わせ、候補AからCに新しく加わった論文だけを担当者に渡す形が扱いやすくなります。この突き合わせは、Claude Codeに「前回のCSVとの差分を出して」と頼んで、短いスクリプトにさせられます。

まとめ

Unpaywall APIは、論文ごとの公開先と版を返しますが、どの機関のリポジトリかは返しません。自機関分はURLのホスト名で見分け、has_repository_copy だけで「登録済み」と読まないことが、登録漏れ洗い出しの分かれ目です。判定はスクリプトに固定し、Claude Codeには実行と集計、依頼文の下書きを任せると、毎回同じ基準で回せます。

文献の管理側から整えるなら、手元の文献をZoteroから引くZotero Web APIの記事の手順で、DOI一覧そのものを作れます。

この記事を共有:XはてブLinkedIn