Claude CodeでCrossref APIを引き、参考文献DOIの実在と撤回を点検する
参考文献リストのDOIをCrossref REST APIにまとめて照会し、実在しないDOIと撤回・訂正のある論文を表にする手順です。DataCiteなど別機関のDOIの扱いも載せます。
撤回された論文を、そうと知らずに参考文献へ入れたまま投稿する。この事故は、投稿前にDOIを機械的に照会すれば防げます。Crossref REST APIは、DOIから論文のメタデータを返し、撤回や訂正の情報もそこに載せています。
この記事では、参考文献のDOI一覧をCrossref APIに照会し、「Crossrefに無いDOI」と「撤回・訂正のある論文」を表にする小さなPythonスクリプトを組みます。Claude Codeには、スクリプトの実行と結果表の読み取り、怪しいDOIの切り分けを任せます。書式の整形はScholar Sidekickコネクタの記事、本文の引用と文献リストの対応は卒論の引用点検の記事が受け持ちます。ここで扱うのは、リストに載せた1件ごとの「存在」と「現在の状態」です。
撤回の情報はCrossrefのどこに載っているか
Crossrefは2023年9月にRetraction Watchのデータベースを取得し、公開しています。データは平日ごとに更新され、出版社サイトの確認などで集めた撤回が入っています。訂正や懸念表明(expression of concern)も含まれますが、網羅性は撤回ほど高くありません。
取り出し方は2つです。
Retraction Watchデータの2つの入口
REST API
DOIごとの応答に、更新の情報が入っています。投稿前の点検のように、数十件のDOIを引く用途に向きます。
CSVデータセット
gitリポジトリのCSVを手元にクローンし、git pull で更新します。大量の論文を突き合わせるときは、APIを叩き続けずに手元で照合できます。
APIの応答では、撤回や更新の情報が update-to に入ると公式ページにあります。ここで見落としやすい点があります。update-to は「更新する側」、つまり撤回通知そのものの応答に付く項目です。撤回された論文の側を引いたときに見るのは updated-by です。実際に撤回論文として知られるLancetの1998年の論文(10.1016/S0140-6736(97)11096-0)を引くと、次の形で返りました。
curl -s "https://api.crossref.org/v1/works/10.1016/S0140-6736(97)11096-0?mailto=you@example.org" \
| python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['message']['updated-by'], indent=1))"応答の updated-by には2件が入っていました(一部を抜粋)。
[
{"DOI": "10.1016/s0140-6736(04)15715-2", "type": "correction",
"source": "retraction-watch", "updated": {"date-parts": [[2004, 3, 6]]}},
{"DOI": "10.1016/s0140-6736(10)60175-4", "type": "retraction",
"source": "retraction-watch", "updated": {"date-parts": [[2010, 2, 6]]}}
]type が更新の種類、DOI が通知側のDOI、source が情報源です。source は publisher か retraction-watch のどちらかになると公式ページにあります。通知側のDOI(10.1016/s0140-6736(10)60175-4)を引くと、今度は update-to に元論文のDOIが入っています。逆引きが2方向とも成り立つので、通知のDOIだけが手元にある場合も元論文を辿れます。
この論文は、タイトルの先頭に RETRACTED: が付いて返ってきました。ただし、これはこの論文について確かめた事実です。すべての撤回論文がタイトルで判別できるとは限らないので、判定には updated-by を使います。
点検の流れ:正規化、一括照会、切り分け
照会は次の順で進めます。
DOI一覧から点検表までの4段
- 1
DOIを正規化する
https://doi.org/やdoi:の接頭辞を外し、小文字にそろえます。Crossrefの応答のDOIは小文字で返ってくるので、突き合わせの前にそろえておきます。 - 2
20件ずつ一括で照会する
filter=doi:A,doi:B,...で複数のDOIを1回の呼び出しにまとめます。1件ずつ引くより呼び出しが減ります。 - 3
応答に無いDOIを拾う
Crossrefに登録が無いDOIは、一括照会の結果に出てきません。単体で引くと404が返ります。
- 4
応答にあるDOIは updated-by を見る
空なら更新情報なし、1件以上あれば撤回・訂正などの印として表に出します。
呼び出しを減らす理由は公式の案内にもあります。tips and tricksのページには、多数のレコードを分析するなら、DOIごとに引くよりフィルタで引くほうがほぼ常に効率的、とあります。
点検スクリプト
標準ライブラリだけで動くスクリプトです。CROSSREF_MAILTO に自分の連絡先を入れて使います。
import csv, json, os, re, sys, time, urllib.parse, urllib.request, urllib.error
BASE = "https://api.crossref.org/v1"
MAILTO = os.environ.get("CROSSREF_MAILTO", "you@example.org")
HEADERS = {"User-Agent": f"doi-check/0.1 (mailto:{MAILTO})"}
CHUNK = 20
def get(path, **params):
params["mailto"] = MAILTO
url = f"{BASE}/{path}?" + urllib.parse.urlencode(params, safe=":,/()*")
for attempt in range(5):
try:
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code == 429:
time.sleep(2 ** attempt)
continue
raise
raise RuntimeError("retry exhausted")
def norm(doi):
doi = doi.strip().lower()
return re.sub(r"^(https?://(dx\.)?doi\.org/|doi:)", "", doi)
def agency(doi):
try:
url = f"{BASE}/works/{urllib.parse.quote(doi, safe='/()')}/agency?mailto={MAILTO}"
with urllib.request.urlopen(urllib.request.Request(url, headers=HEADERS), timeout=30) as r:
return json.load(r)["message"]["agency"]["label"]
except (urllib.error.HTTPError, json.JSONDecodeError):
return ""
dois = [norm(l) for l in open(sys.argv[1]) if l.strip()]
found = {}
for i in range(0, len(dois), CHUNK):
part = dois[i:i + CHUNK]
r = get("works", filter=",".join(f"doi:{d}" for d in part),
select="DOI,title,updated-by", rows=CHUNK)
for it in r["message"]["items"]:
found[it["DOI"].lower()] = it
out = csv.writer(sys.stdout)
out.writerow(["doi", "status", "detail"])
for d in dois:
it = found.get(d)
if not it:
out.writerow([d, "not-in-crossref", agency(d) or "no agency"])
continue
ups = it.get("updated-by", [])
status = "flagged" if ups else "ok"
detail = ";".join(f'{u["type"]}:{u["DOI"]}({u["source"]})' for u in ups)
out.writerow([d, status, detail])DOIを1行1件で書いたテキストを渡します。
CROSSREF_MAILTO=you@example.org python3 check_dois.py dois.txt > report.csv次の4件を渡して実行すると、CSVはこうなりました(実行日は2026年10月27日)。
| DOI | status | detail |
|---|---|---|
| 10.1016/s0140-6736(97)11096-0 | statusflagged | detailcorrectionとretraction(どちらもretraction-watch由来) |
| 10.1038/nature14539 | statusok | detailなし |
| 10.1000/doesnotexist123 | statusnot-in-crossref | detailno agency |
| 10.5281/zenodo.3233821 | statusnot-in-crossref | detailDataCite |
3行目と4行目は、どちらもCrossrefの一括照会には出てきません。違いは agency で分かります。3行目はどの登録機関にも存在しないDOIで、4行目はDataCiteが登録したDOIです。DataCiteのDOIはCrossrefに無くて当然なので、「実在しない」と扱ってはいけません。このスクリプトは、Crossrefに無いDOIにだけ /works/{DOI}/agency を追加で引いて、登録機関を表に添えます。実在しないDOIでは、この問い合わせも404(本文は Resource not found. のテキスト)になるため、no agency と表示しています。
レート制限とmailtoの付け方
Crossrefは登録なしで使えますが、名乗り方で制限が変わります。
| 利用の形 | 識別の方法 | レート制限 | 同時リクエスト |
|---|---|---|---|
| Public | 識別の方法なし | レート制限5 | 同時リクエスト1 |
| Polite | 識別の方法mailto パラメータか agent ヘッダーにメールアドレスを入れる | レート制限10 | 同時リクエスト3 |
| Plus | 識別の方法APIキー(有償のMetadata Plus) | レート制限150 | 同時リクエスト制限なし |
単位は応答ヘッダーの x-rate-limit-interval で確かめられます。上のPolite経由の実行では、x-rate-limit-limit: 10、x-rate-limit-interval: 1s、x-concurrency-limit: 3、x-api-pool: polite-single が返りました。1秒あたり10リクエストが目安です。
上限を超えると429が返ります。スクリプトは429で待って再試行します。公式は手動でブロックする前に連絡すると説明しており、その連絡先が mailto です。ブロックされたときは403が返ります。数十件のDOIなら20件ずつの数回で終わるので、制限に近づくことはまずありません。何百件もの文献を回すときは、結果を手元に保存して同じ問い合わせを繰り返さないようにします。
Claude Codeに任せる範囲と権限
スクリプトの実行と結果の読み取りは任せられますが、「この論文は引用から外すべき」という判断までは任せません。撤回された論文でも、撤回の事実を論じる文脈なら正当に引用します。CLAUDE.mdには、その線を書いておきます。
## 参考文献の点検
- DOI一覧は refs/dois.txt。点検は `python3 check_dois.py refs/dois.txt` で行う
- status が flagged のDOIは、type(retraction / correction など)と通知のDOIを表で報告する
- not-in-crossref は agency まで確認し、DataCite等の登録は「別機関のDOI」と分けて報告する
- 参考文献リストの書き換えはしない。外す・残すの判断は人が行うCLAUDE.mdは、セッションをまたいで効かせる指示を書くファイルです。権限は、.claude/settings.json で実行を許す範囲を絞ります。
{
"permissions": {
"allow": [
"Bash(python3 check_dois.py *)",
"WebFetch(domain:api.crossref.org)"
]
}
}Bash(python3 check_dois.py *) は、このスクリプトのコマンドだけを許可します。* は、コマンド名の後ろに引数が続く形に対応します。WebFetch(domain:api.crossref.org) は、Claude自身がAPIを引いて確かめたいときの許可です。スクリプトの実行だけで足りるなら、こちらは入れなくて構いません。
件数が多いときはCSVを手元に置く
学位論文やレビュー論文のように参考文献が数百件になるなら、APIを何度も引くよりCSVの照合が向きます。Retraction WatchのデータはGitリポジトリで公開されていて、クローンして使います。
git clone https://gitlab.com/crossref/retraction-watch-data
cd retraction-watch-data && git pull # 更新するときCSVでDOIの照合に使う列は OriginalPaperDOI(撤回された論文のDOI)です。RetractionNature にRetraction、Correction、Expression of concern、Reinstatementのどれかが入り、Reason に撤回理由が複数入ります。理由は、APIの応答には出てこない情報です。DOIが空のときは、空欄か unavailable が入ると公式にあります。Claude Codeには「OriginalPaperDOI が dois.txt に含まれる行の RetractionNature と Reason を表にして」と頼めば、点検表に撤回理由の列を足せます。
つまずきやすい点
落とし穴
撤回通知のDOIを参考文献に入れている
撤回通知そのもの(Retraction—… で始まる記録)は、update-to を持つ側です。通知のDOIをリストに入れてしまうと、updated-by が空で「ok」と判定されます。update-to が付いたDOIは、元論文のDOIに差し替えるか、通知を引用する意図なのかを確かめます。
- 訂正と撤回は別物:
typeがcorrectionだけの論文は、訂正が出ているものの撤回ではありません。上の例のLancet論文のように、両方付く論文もあります。表ではtypeを分けて出します。 - 更新が反映されるまでの遅れ: Retraction Watchのデータは平日ごとに更新されます。撤回の直後に引くと、
updated-byがまだ空のことがあります。投稿の直前にも、もう一度走らせます。 - プレプリントやデータセットのDOI: arXivやZenodoのDOIはCrossrefではなくDataCiteなどの登録です。
not-in-crossrefに出ても、DOI自体は実在します。撤回情報も、Crossref経由では引けません。 - タイトルの照合までは行わない: DOIが実在しても、リスト上の書誌情報(タイトルや著者)と一致するとは限りません。一致の確認が必要なら、
titleもCSVに出して目で突き合わせます。
OpenAlexやORCIDとの使い分け
参考文献の存在と撤回を見るのは、このCrossrefの点検です。論文の被引用数や参考文献の広がりから読む順を決めるなら、OpenAlex APIの記事が入口になります。研究者本人の業績リストを取り込むなら、ORCID Public APIの記事です。投稿前の流れでは、まず書式を整え、次にこの点検でDOIを洗い、最後に本文の引用との対応を見る順がつながります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。