【初心者向け】国立国会図書館のLinked Open DataからSPARQLでデータを抽出してみた

先日SPARQLで国立国会図書館のLinked Open Data (LOP)からデータを抽出する機会がありました。
個人的に面白いと思ったのですが、SPARQL初心者で今後使わないと忘れてしまいそうなので、自分の備忘録用、また初めて国立国会図書館LOPを利用する方向けに書きました。

本記事は以下の前提で書いています。
- SQLの基本的な書き方は抑えている
- Linked Dataについてはすでに知っている

## 国立国会図書館のLinked Open Dataとは
- 国立国会図書館が提供する書籍や記事論文などのメタデータをもとに提供している
- 1つのリソースに対して↓のような情報が与えられている
（例）カルボン酸：https://id.ndl.go.jp/auth/ndlsh/00565061

## 実行したクエリ
＜抽出したかった条件＞
`skos:inScheme=Topical Terms`の`uri`, `xl:prefLabel`, `xl:altLabel`, `skos:broader`の一覧を抽出したい

```sparql:sqarql
PREFIX xl: <http://www.w3.org/2008/05/skos-xl#>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT ?uri ?label ?altlabel ?broaderlabel WHERE {
      ?uri xl:prefLabel [ xl:literalForm ?label ].
      ?uri xl:altLabel [ xl:literalForm ?altlabel ].
      OPTIONAL {
        ?uri skos:broader [ rdfs:label ?broaderlabel].
      }
      ?uri skos:inScheme <http://id.ndl.go.jp/auth#topicalTerms> .
}
```

### 混乱したこと
私はこちらのサイト上でクエリ実行をしていたのですが
https://id.ndl.go.jp/information/sparql/
`COUNT`やサブクエリが使えず「クエリが間違ってるのかな」と悪戦苦闘していました。
しかしこれはSPARQL1.0を使用していたことが原因でした（汗）
SPARQL1.0: https://id.ndl.go.jp/information/sparql/
SPARQL1.1: https://id.ndl.go.jp/information/sparql-11/

SPARQL1.1では実行できるので、正しい方のリンクになっているか確認してから実行しましょう。


## クエリの詳細
### PREFIX: 省略形を定義するところ

```sparql:sqarql
PREFIX xl: <http://www.w3.org/2008/05/skos-xl#>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
```
- `xl`=`http://www.w3.org/2008/05/skos-xl#`ということ。
`SELECT`の中でURLを記載してもいいが、PREFIXに記載しておくことで`xl`と記載するだけで呼び出すことができる。
- リンクはNamespaceドキュメントで、例えばリソースのページに記載のある`xl:prefLabel`とは何か？が定義されているところ。

### SELECT文の基本的なルール
```sparql:sqarql
SELECT ?uri ?label ?altlabel ?broaderlabel WHERE {
      ?uri xl:prefLabel [ xl:literalForm ?label ].
      ?uri xl:altLabel [ xl:literalForm ?altlabel ].
      OPTIONAL {
        ?uri skos:broader [ rdfs:label ?broaderlabel].
      }
      ?uri skos:inScheme <http://id.ndl.go.jp/auth#topicalTerms> .
}
```
- SPARQLでは`?`で始まるものが変数と呼ばれる
- `SELECT`の後はSQL同様、どんな項目を表示したいかを記載する
- `SELECT * `とした場合は定義した変数全てが表示される
- `WHERE`以降は主語-述語-目的語（トリプルという）の順番で記載する
例）`?uri skos:inScheme <http://id.ndl.go.jp/auth#topicalTerms>`
主語: `?uri`
述語: `skos:inScheme`
目的語: `http://id.ndl.go.jp/auth#topicalTerms`
（=`?uri`は`skos:inScheme`というプロパティを持ち、その値が`topicalTerms`）と解釈できる
- トリプルの終わりには`.`をつける（`OPTIONAL`など`}`で終わる場合はつけなくてもエラーにはならない）
- `[]`はブランクノードといい省略形（例でみた方がわかりやすいです）
例）`?uri skos:broader [ rdfs:label ?broaderlabel]`は以下と同義
    ```sparql:sparql
    ?uri skos:broader ?broader
    ?broader rdfs:label ?broaderlabel
    ```
- `OPTIONAL`はあれば表示する、なければ表示しなくてもいい条件を記載する際に用いる

## 参考にした資料・動画
- https://youtu.be/OKbo8PrQpwQ?si=15T1mJ1B2nxoSK-d
- https://id.ndl.go.jp/information/wp-content/uploads/2023/05/api-spec.pdf

## 【番外編】SPARQLWrapperを使った場合
私はpythonを主に使うので、`SPARQLWrapper`というライブラリを使ってjupyterでもデータを抽出しました。一応その時に使ったコードも記載しておきます。
```python:python
from SPARQLWrapper import SPARQLWrapper, JSON
import pandas as pd

# SPARQLエンドポイント
sparql = SPARQLWrapper("http://id.ndl.go.jp/auth/ndla/sparql")  # NDLのエンドポイント
sparql.setReturnFormat(JSON) # 今回はJSONからデータフレームに整形

# クエリテンプレート
query_template = """
PREFIX xl: <http://www.w3.org/2008/05/skos-xl#>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT ?uri ?label ?altlabel ?broaderlabel WHERE {
      ?uri xl:prefLabel [ xl:literalForm ?label ].
      ?uri xl:altLabel [ xl:literalForm ?altlabel ].
      OPTIONAL {
        ?uri skos:broader [ rdfs:label ?broaderlabel].
      }
      ?uri skos:inScheme <http://id.ndl.go.jp/auth#topicalTerms> .
}
"""

# データ取得
offset = 0
all_results = []

while True:
    # クエリを設定
    sparql.setQuery(query_template + f" OFFSET {offset} LIMIT 1000")  # 1回で1000件まで取得できるので1000件ずつ取得する

    # クエリ実行
    results = sparql.query().convert()

    bindings = results["results"]["bindings"]
    
    if len(bindings) == 0:
        break  # これ以上データがない場合、ループを終了
    
    all_results.extend(bindings)
    offset += 1000  # 次の1000件を取得
    print(f'offset: {offset}')

# データをPandasデータフレームに変換
df = pd.DataFrame([
    {
        "uri": res["uri"]["value"],
        "label": res["label"]["value"],
        "altlabel": res.get("altlabel", {}).get("value", None),
        "broaderlabel": res.get("broaderlabel", {}).get("value", None) if "broaderlabel" in res else None,
    }
    for res in all_results
])

# 取得件数を表示
print(f"取得件数: {len(df)}")
df.head()
```