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

東北大学デジタルアーカイブ(touda.tohoku.ac.jp/collection)にあるデルゲ版チベット大蔵経 DB を見ていて、JSON で取得できる経路がないかと気になり、公開 API の有無を一通り確認しました。最終的に OAI-PMH 経由で setSpec ごとに Excel 化するところまで動かせたので、その手順を整理します。スクレイピングは利用しない方針で進めています。

公開されているエンドポイントの一覧

調査した範囲で確認できた状態は次のとおりです(2026-04-30 時点)。

種別エンドポイント状態
OAI-PMHhttps://touda.tohoku.ac.jp/collection/oai公開(3 形式)
IIIF Presentation v3 manifesthttps://touda.tohoku.ac.jp/collection/iiif/scripture/{ID}/manifest.json公開(個別レコード単位)
IIIF Image API v2manifest 内の service URL公開
Sitemaphttps://touda.tohoku.ac.jp/collection/sitemap.xml公開(後述のとおりカバレッジ部分的)
Drupal JSON:API(/jsonapi確認した範囲では未公開
Drupal REST(?_format=json500 が返る
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/librarydatabase/archives がほぼ全量を占めます。

パス件数
database/library66,369
database/archives60,336
database/medlib51
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_dc1.4 KB6DC15 のみ。最小限
jpcoar1.9 KB9jpcoar:publisher 等の構造化、datacite:geoLocation
dcndl_simple2.3 KB13xml: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件数内容
libkano46,217狩野文庫
arckoji34,105個人・関連団体文書
arcreki29,101歴史公文書
arckank22,080学内刊行物
libwasa16,015和算資料
arcstdm7,936「長い 1960 年代」デジタルアーカイブ
arcshas7,875大学関係写真
libshin5,189震災ライブラリーオンライン版
libhonk3,966本館所蔵古典資料
libsose3,144漱石文庫
libakit1,480秋田家史料
tokiwa593常盤大定旧蔵資料
libhonkandoc442本館所蔵古文書
mlmaterials280図書・古文書等
maibun181仙台城跡

宣言だけで取得できないセットは次のとおりです。

  • 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

標準ライブラリの urllibxml.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_dictheader 配下(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 のように同名要素が jaen で並ぶケースがあります。列名に @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 をダウンロードできます