本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
東北大学デジタルアーカイブ(touda.tohoku.ac.jp/collection)にあるデルゲ版チベット大蔵経 DB を見ていて、JSON で取得できる経路がないかと気になり、公開 API の有無を一通り確認しました。最終的に OAI-PMH 経由で setSpec ごとに Excel 化するところまで動かせたので、その手順を整理します。スクレイピングは利用しない方針で進めています。
公開されているエンドポイントの一覧
調査した範囲で確認できた状態は次のとおりです(2026-04-30 時点)。
| 種別 | エンドポイント | 状態 |
|---|---|---|
| OAI-PMH | https://touda.tohoku.ac.jp/collection/oai | 公開(3 形式) |
| IIIF Presentation v3 manifest | https://touda.tohoku.ac.jp/collection/iiif/scripture/{ID}/manifest.json | 公開(個別レコード単位) |
| IIIF Image API v2 | manifest 内の service URL | 公開 |
| Sitemap | https://touda.tohoku.ac.jp/collection/sitemap.xml | 公開(後述のとおりカバレッジ部分的) |
Drupal JSON:API(/jsonapi) | — | 確認した範囲では未公開 |
Drupal REST(?_format=json) | — | 500 が返る |
| OpenSearch / RSS / Atom / IIIF Search / Solr 直叩き | — | 404 |
サイト基盤は Drupal で動いているようで、?_format=hal_json でリクエストすると application/hal+json の 406 が返ってきました。?_format=json / ?_format=hal_json は Drupal Core の RESTful Web Services(rest)モジュール、/jsonapi は同じく Core 同梱の JSON:API モジュールが提供する仕様で、いずれも Drupal 標準の機能ではあります。ただし Drupal 8.4 以降、これらは既定で無効になっており、管理者が明示的に有効化したうえでエンティティ単位の REST resource と権限設定を行う必要があります。touda の挙動(406 / 500 / /jsonapi 非公開)は、エンドユーザ向けには有効化されていない状態と読めました。バルクで取得したい場合の現実的な経路は OAI-PMH になりそうでした。
Sitemap のカバレッジ
sitemap.xml は sitemapindex 形式で 64 ページに分割されていて、個別 URL の総数は 126,771 件でした。ただし sitemap index 側の <lastmod> は全 64 ページとも 2024-05-30T02:06:54+09:00 で固定されていて、再生成は 2024-05-30 で止まっているように見えます(個別 URL の <lastmod> は 2023〜2024 の日付が混在)。
パス内訳は次のとおりで、database/library と database/archives がほぼ全量を占めます。
| パス | 件数 |
|---|---|
database/library | 66,369 |
database/archives | 60,336 |
database/medlib | 51 |
en/database/*(英語版) | 12 |
database/tibet/* | 0 |
OAI-PMH 合計 178,664 件に対して sitemap 側は 126,771 件にとどまり、また当初の関心だったチベット大蔵経(database/tibet/{ID})の URL は 1 件も含まれていませんでした。サイトマップを ID 抽出経路にする想定は、現状では成り立たないようです。
OAI-PMH の概要
verb=Identify で返ってくる情報です。
baseURL : https://touda.tohoku.ac.jp/collection/
repositoryName : Tohoku University Digital Archives Collection Database
earliestDatestamp : 2013-01-01
deletedRecord : persistent
granularity : YYYY-MM-DDThh:mm:ssZ
Identify / ListMetadataFormats / ListSets / ListRecords / GetRecord の標準動詞は一通り受け付けます。
metadataPrefix の比較
3 形式が利用できます。同一レコード(identifier=10060010000001、libshin の図書)で比較しました。
| prefix | サイズ | 要素数 | 特徴 |
|---|---|---|---|
oai_dc | 1.4 KB | 6 | DC15 のみ。最小限 |
jpcoar | 1.9 KB | 9 | jpcoar:publisher 等の構造化、datacite:geoLocation |
dcndl_simple | 2.3 KB | 13 | xml:lang 付き、dcterms:description/extent/spatial の複数値を保持 |
dcndl_simple は国会図書館 NDL 系の語彙で、注記(dcterms:description)、物理形態(dcterms:extent で「B5判」「168p」のような複数値)、地名の日英並列(dcterms:spatial xml:lang="ja|en")を欠落なく保持してくれます。日本語史料を Excel で扱う用途では、dcndl_simple が情報量の点で扱いやすそうでした。
setSpec の挙動 — 宣言と実取得の差
verb=ListSets は 22 セットを返してきますが、実際に set=... を付けると、うち 7 セットは noRecordsMatch で 0 件になります。
実際に取得できたセット(2026-04-30 時点)です。
| setSpec | 件数 | 内容 |
|---|---|---|
| libkano | 46,217 | 狩野文庫 |
| arckoji | 34,105 | 個人・関連団体文書 |
| arcreki | 29,101 | 歴史公文書 |
| arckank | 22,080 | 学内刊行物 |
| libwasa | 16,015 | 和算資料 |
| arcstdm | 7,936 | 「長い 1960 年代」デジタルアーカイブ |
| arcshas | 7,875 | 大学関係写真 |
| libshin | 5,189 | 震災ライブラリーオンライン版 |
| libhonk | 3,966 | 本館所蔵古典資料 |
| libsose | 3,144 | 漱石文庫 |
| libakit | 1,480 | 秋田家史料 |
| tokiwa | 593 | 常盤大定旧蔵資料 |
| libhonkandoc | 442 | 本館所蔵古文書 |
| mlmaterials | 280 | 図書・古文書等 |
| maibun | 181 | 仙台城跡 |
宣言だけで取得できないセットは次のとおりです。
takayanagi(宣言のみ・0 件)housawa(宣言のみ・0 件)engold(宣言のみ・0 件)canon/derge_scripture/derge_iconography(デルゲ版チベット大蔵経関連 —noRecordsMatch)
調査の動機だったチベット大蔵経 DB は、現時点では OAI-PMH からの取得対象外のようでした。IIIF manifest(/collection/iiif/scripture/{ID}/manifest.json)は ID が分かれば取得できますが、ID 一覧を取得するための公開 API は見当たりませんでした。前述のとおりサイトマップにも database/tibet/* の URL は含まれていなかったため、スクレイピングを避ける範囲では現状 ID 一覧を得る経路を確認できていません。
ブラウザだけで完結させたい場合
同じ touda の OAI-PMH を含む、いくつかのリポジトリ向けに、ブラウザ上で setSpec を選んで CSV をダウンロードできる Web ツールを別途用意しています。
CORS の都合で同梱のプロキシ経由になりますが、Python を回せる環境が手元にない場合や、ひとつのセットを単発で見たい場合はこちらが手軽です。本記事の以降のスクリプトは、Excel での取り扱い(xml:lang サフィックス・複数値の改行連結など)を細かく制御したいときに使えます。
実装 — setSpec ごとに 1 つの xlsx を出力
要件は次のように整理しました。
- API のみ使用(スクレイピングは行わない)
dcndl_simpleで取得resumptionTokenのページングを最後まで追う- 同一要素の複数出現は改行で連結
xml:lang属性は列名サフィックスにする(例:dc:title@ja)setSpecを引数で絞れるようにする
必要なもの
pip install openpyxl
標準ライブラリの urllib と xml.etree.ElementTree で OAI を叩き、openpyxl の write-only モードで Excel に流し込みます。pandas は使っていません。
スクリプト
"""
東北大学 デジタルアーカイブ (touda.tohoku.ac.jp/collection) の
OAI-PMH エンドポイントから setSpec ごとに Excel ファイルを生成する。
pip install openpyxl
python oai_to_xlsx.py # 全セット
python oai_to_xlsx.py libkano libsose # 指定セットのみ
"""
from __future__ import annotations
import re
import sys
import time
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
from openpyxl import Workbook
BASE = "https://touda.tohoku.ac.jp/collection/oai"
PREFIX = "dcndl_simple"
OUT_DIR = Path("xlsx")
SLEEP_SEC = 0.3
TIMEOUT = 60
NS = {"oai": "http://www.openarchives.org/OAI/2.0/"}
XML_LANG = "{http://www.w3.org/XML/1998/namespace}lang"
HARVESTABLE_SETS = [
"maibun", "tokiwa", "libkano", "libwasa", "libsose", "libakit",
"libhonk", "libhonkandoc", "libshin", "arcreki", "arckoji",
"arckank", "arcshas", "arcstdm", "mlmaterials",
]
NS_SHORT = {
"http://purl.org/dc/elements/1.1/": "dc",
"http://purl.org/dc/terms/": "dcterms",
"http://ndl.go.jp/dcndl/terms/": "dcndl",
"http://ndl.go.jp/dcndl/dcndl_simple/": "dcndl_simple",
"http://xmlns.com/foaf/0.1/": "foaf",
"http://www.w3.org/2002/07/owl#": "owl",
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf",
"http://www.w3.org/2000/01/rdf-schema#": "rdfs",
"https://irdb.nii.ac.jp/schema/jpcoar/1.0/": "jpcoar",
"http://datacite.org/schema/kernel-4": "datacite",
}
def short_name(qname: str) -> str:
m = re.match(r"\{([^}]+)\}(.+)", qname)
if not m:
return qname
ns, local = m.group(1), m.group(2)
return f"{NS_SHORT.get(ns, ns)}:{local}"
def fetch(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "oai-harvest/1.0"})
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return r.read()
def harvest_set(set_spec: str) -> list[dict]:
rows: list[dict] = []
params = {"verb": "ListRecords", "metadataPrefix": PREFIX, "set": set_spec}
url = f"{BASE}?{urllib.parse.urlencode(params)}"
page = 0
while url:
page += 1
body = fetch(url)
root = ET.fromstring(body)
err = root.find("oai:error", NS)
if err is not None:
print(f" [{set_spec}] error: {err.get('code')} {err.text}", file=sys.stderr)
return rows
for rec in root.findall("oai:ListRecords/oai:record", NS):
rows.append(record_to_dict(rec))
token_el = root.find("oai:ListRecords/oai:resumptionToken", NS)
total = token_el.get("completeListSize") if token_el is not None else None
print(f" [{set_spec}] page {page} acquired={len(rows)} total={total}")
if token_el is not None and (token_el.text or "").strip():
params = {"verb": "ListRecords", "resumptionToken": token_el.text.strip()}
url = f"{BASE}?{urllib.parse.urlencode(params)}"
time.sleep(SLEEP_SEC)
else:
url = None
return rows
def record_to_dict(rec: ET.Element) -> dict:
out: dict[str, list[str]] = {}
header = rec.find("oai:header", NS)
if header is not None:
out["_status"] = [header.get("status") or ""]
ident = header.find("oai:identifier", NS)
out["_identifier"] = [ident.text or ""] if ident is not None else [""]
ds = header.find("oai:datestamp", NS)
out["_datestamp"] = [ds.text or ""] if ds is not None else [""]
sets = [s.text or "" for s in header.findall("oai:setSpec", NS)]
out["_setSpec"] = sets
metadata = rec.find("oai:metadata", NS)
if metadata is not None:
for wrapper in list(metadata):
for child in list(wrapper):
key = short_name(child.tag)
lang = child.get(XML_LANG)
if lang:
key = f"{key}@{lang}"
value = (child.text or "").strip()
if not value and len(list(child)) > 0:
value = " ".join(
(g.text or "").strip() for g in child.iter() if (g.text or "").strip()
)
if value:
out.setdefault(key, []).append(value)
return {k: "\n".join(v) for k, v in out.items()}
def write_xlsx(set_spec: str, rows: list[dict]) -> Path:
OUT_DIR.mkdir(exist_ok=True)
path = OUT_DIR / f"{set_spec}.xlsx"
columns: list[str] = []
seen: set[str] = set()
for r in rows:
for k in r:
if k not in seen:
seen.add(k)
columns.append(k)
header_cols = [c for c in columns if c.startswith("_")]
body_cols = [c for c in columns if not c.startswith("_")]
columns = header_cols + body_cols
wb = Workbook(write_only=True)
ws = wb.create_sheet(set_spec[:31] or "data")
ws.append(columns)
for r in rows:
ws.append([r.get(c, "") for c in columns])
wb.save(path)
return path
def main(argv: list[str]) -> int:
targets = argv[1:] if len(argv) > 1 else HARVESTABLE_SETS
for s in targets:
if s not in HARVESTABLE_SETS:
print(f"warn: '{s}' is not in the known harvestable list; trying anyway")
print(f"==> {s}")
rows = harvest_set(s)
if not rows:
print(f" [{s}] no records, skipping")
continue
path = write_xlsx(s, rows)
print(f" [{s}] wrote {len(rows)} rows -> {path}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
record_to_dict で header 配下(identifier / datestamp / setSpec / status="deleted" 等)を _ 接頭辞付きの列に、metadata 配下の各要素を prefix:localname[@lang] 列に流し込んでいます。複数値は \n で連結する設計です。
列の作られ方
例として、libshin の 1 件は次のような列構成になります。
_identifier _datestamp _setSpec _status
dc:identifier
dcndl:materialType
dcterms:language
dc:title@ja
dcndl:alternative@ja
dcterms:publisher@ja
dcndl:publicationPlace@ja
dcterms:date
dcterms:extent ← "B5判\n168p" のように 1 セルに複数値
dcterms:spatial@ja
dcterms:spatial@en
dcterms:description@ja ← 注記が複数あれば改行で連結
dcterms:rights
セット全体での列ヘッダは「全レコードに 1 つでも出現した列の和集合」になります。これで dcndl_simple の元情報を欠落なく Excel に持ち込めます。
実行
# 全 15 セット
python oai_to_xlsx.py
# 指定セットのみ
python oai_to_xlsx.py libsose libhonkandoc
xlsx/{setSpec}.xlsx が並びます。
件数の見積もり
合計 178,664 件です。リクエスト間 0.3 秒の sleep を入れて、デフォルトの 100 件/page で取得した場合の概算は次のようになります。
- 最大の
libkanoで 46,217 / 100 = 463 ページ ≒ 約 2.5 分 - 全 15 セット連続で約 10〜15 分
resumptionToken の有効期限は 12 時間ほど確認できているので、途中で止まっても再開可能です(トークンを保存しておけばよい構成)。
ハマりどころ
set=canonが空 —ListSetsの宣言だけでは取得可否が判断できないため、実機でverb=ListIdentifiers&set=...を叩いてcompleteListSizeを確認するのが確実でした。identifierが裸の数値 —oai:プレフィックスのない素のレコード ID(例:10060010000001)が返ります。OAI 識別子の慣習からは外れますが、GetRecord&identifier=...でこの値をそのまま渡せばよさそうでした。xml:langの扱い —dcterms:spatialのように同名要素がjaとenで並ぶケースがあります。列名に@ja/@enを付けて区別しないと値が潰れます。- 削除レコード — 先頭付近に
<header status="deleted"><identifier>test</identifier>のテストデータが含まれていました。_status列で除外できます。 - チベット大蔵経 DB の扱い — 本記事の手法では取得対象外でした。サイトマップにも
database/tibet/*の URL は含まれていなかったため、ID 一覧を取得する経路は現状確認できていません。
まとめ
- 公開されている API は OAI-PMH と IIIF Presentation/Image API が中心でした
- 一覧取得には OAI-PMH を使うのが扱いやすく、metadataPrefix は情報量の点で
dcndl_simpleが適していました ListSetsの宣言と実取得の挙動は一致しないことがあり、canon系(チベット大蔵経)は現状の OAI-PMH からは取得できないようです- 上記スクリプトで setSpec ごとに 1 つの xlsx ファイルを出力できます
- ブラウザだけで完結させたい場合は デジタルアーカイブ ダウンローダー で setSpec ごとの CSV をダウンロードできます