【Python】スクレイピング tableのパース

# 概要

BeautifulSoupでテーブルをパースしてpandas.DataFrame形式で取得。
できるだけ様々な状況に対応できるように...配慮してます。

# サンプル
[Wikipediaの東京都のページ](https://ja.wikipedia.org/wiki/%E6%9D%B1%E4%BA%AC%E9%83%BD#%E5%9C%B0%E7%90%86:~:text=%E6%9D%B1%E4%BA%AC%E9%83%BD%E5%86%85%E5%90%84%E5%9C%B0%E3%81%AE%E6%B0%97%E8%B1%A1%E3%83%87%E3%83%BC%E3%82%BF%EF%BC%88%E5%87%BA%E5%85%B8%EF%BC%9A%E6%B0%97%E8%B1%A1%E5%BA%81%E3%83%BB%E6%B0%97%E8%B1%A1%E7%B5%B1%E8%A8%88%E6%83%85%E5%A0%B1%EF%BC%89)にちょうど良い複雑なテーブルがありました。

![スクリーンショット 2023-09-30 20.17.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614119/daf4d5a9-7ad1-3e4b-e0a8-2e4b54117eda.png)

ページから該当のテーブルを探します。
```python
from bs4 import BeautifulSoup
import wikipedia

wikipedia.set_lang("ja")

page = wikipedia.page("東京都", auto_suggest=False)
soup = BeautifulSoup(page.html(), 'html.parser')
tables = soup.findAll("table")

for i, value in enumerate(tables):
    if value.caption:
        print(i, value.caption.get_text(strip=True))
```
実行結果
```
9 東京都内各地の気象データ（出典：気象庁・気象統計情報）
20 
```

```tables[9]```が該当テーブルです。

# テーブルの取得

下記の関数で取得します。
```python
import numpy as np
import pandas as pd

def parse_table(table, set_index=False, set_columns=False):
    tr_lst = table.findAll("tr") # trタグを全て取得

    # tableのサイズ(n,m)を取得し、np.arrayを作成
    m = sum([int(t.get("colspan", 1)) for t in tr_lst[0].findAll(("th", "td"))])
    n = 0
    while n < len(tr_lst):
        tr = tr_lst[n]
        rowspan = int(tr.find(("th", "td")).get("rowspan", 1))
        n += rowspan

    nptable = np.zeros([n,m], dtype=object)

    # 各セルの値を取得
    v = 0 
    for tr in tr_lst:
        h = 0  
        for t in tr.findAll(("th", "td")):
            # すでに値が格納されていればスキップ
            while nptable[v, h]:
                h += 1
            
            # colspan, rowsapnを考慮してテーブルに値を格納
            colspan = int(t.get("colspan", 1))
            rowspan = int(t.get("rowspan", 1))
            nptable[v:v+rowspan, h:h+colspan] = t.get_text(strip=True) or "-" # Nullは"-"に変換

            h += colspan
        v += 1

    # デフォルトではindex, columnsを設定せず終了
    if not (set_index or set_columns):
        return pd.DataFrame(nptable)

    # indexとcolumnsの階層を算出
    idx_layer, cols_layer = 0, 0 
    for tr in tr_lst:
        if tr.find("td"):
            idx_layer = sum([int(tr.get("colspan", 1)) for t in tr.findAll("th")]) 
            break 
        cols_layer += 1 

    # 適宜indexとcolumnsを設定する
    if set_index and set_columns:
        idx = pd.MultiIndex.from_arrays(nptable[cols_layer:, 0:idx_layer].T)
        cols = pd.MultiIndex.from_arrays(nptable[0:cols_layer,idx_layer:])
        return pd.DataFrame(nptable[cols_layer:, idx_layer:], index=idx, columns=cols)

    if set_index:
        idx = pd.MultiIndex.from_arrays(nptable[:, 0:idx_layer].T)
        return pd.DataFrame(nptable[:, idx_layer:], index=idx)

    if set_columns:
        cols = pd.MultiIndex.from_arrays(nptable[0:cols_layer,:])
        return pd.DataFrame(nptable[cols_layer:, :], columns=cols)

```
実行してみます。
``` python
parse_table(tables[9])
```
![スクリーンショット 2023-09-30 20.27.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614119/a7850f81-e406-5faf-14e4-74d1e68a30c7.png)

インデックスやカラムを設定すると次のようにmultiindex形式で返ってきます。

```python
parse_table(tables[9], set_index=True, set_columns=True)
```
![スクリーンショット 2023-09-30 20.28.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614119/a0c59f19-88e9-9f49-6525-82badb622326.png)

# 補足説明
処理について簡単に記します。適宜状況に応じて変更されたし。
## 1 テーブルサイズの取得
予めテーブルのサイズを事前に取得すると分かりが良いです。

trタグが各行を表し、その子要素としてth、tdタグが存在します。thはindex及びcolumnsに相当します。
まずtrタグをリストとして取得します。
```python
tr_lst = table.findAll("tr") # trタグを全て取得
```

セル同士を結合する時には、行方向にはrowspan、列方向にはcolspanで結合するセルの数が指定されます。
次の箇所で１行目、すなわち１つ目のtrタグを取得してcolspanの値を足し合わせることで列数を計算します。```t.get("colspan", 1)```として、colspan属性が存在しなければ1とすれば辻褄が合います。
```python
m = sum([int(t.get("colspan", 1)) for t in tr_lst[0].findAll(("th", "td"))])
```
行数もほぼ同様で、かくtrタグの一つ目のthもしくはtdタグのrowspanの値を足し合わせて計算しています。
```python
n = 0
while n < len(tr_lst):
    tr = tr_lst[n]
    rowspan = int(tr.find(("th", "td")).get("rowspan", 1))
    n += rowspan
```
行数と列数が分かったので、テーブルの値を保持する値が全て0の```np.array```を作成します。テーブルの値の形式は様々なので```dtype=object```としています。
```python
nptable = np.zeros([n,m], dtype=object)
```

## 2 indexとcolumnsの取得
ここではindexが左側に、columnsが上部にまとまって存在していることを仮定しています。

上述の例のように、indexとcolumnsは階層的にもできるので、それぞれの層数を取得します。後の処理最後にテーブルの左部と上部から層数分だけindexとcolumnsとして取り出します。

tdタグが一つも存在しない行はcolumnsであると判断できます。そのような行数を```cols_layer += 1```でカウントしていきます。

そしてindexの層の数はcolumns以外の行の中で最も

上述の例のように、indexやcolumnsが階層的になっている場合があります。


```python
idx_layer, cols_layer = 0, 0 # indexとcolumnsの階層
for tr in tr_lst:
    if tr.find("td"):
        idx_layer = max(idx_layer, len(tr.findAll("th"))) # th要素の数の最大値をindexの階層とする
    else:
        cols_layer += 1 # td要素がない場合行数をcolumnsの階層とする
```

# まとめ
これまでスクレイピングする中で出会った諸々のパターンに対応できるように書いてみました。