【Python3】表のスクレイピング(罫線の位置も考慮)

# はじめに

サイト，またはhtmlファイルにある表をpandasのDataFrameで取得したいとき，わざわざ頑張ってスクリプトを書かなくてもpandasを使うとめちゃめちゃ便利です．

# required
```shell
pip install pandas beautifulsoup4 requests lxml
```

# 綺麗な表のスクレイピング

今回は[wikipediaのPython](https://en.wikipedia.org/wiki/Python_(programming_language))を対象としますが，普通のサイトでしたら表は綺麗なはずです．（ここでいう'綺麗'の意味は後述）

```python
from bs4 import BeautifulSoup
import requests
import pandas as pd

url = 'https://en.wikipedia.org/wiki/Python_(programming_language)'
r = requests.get(url)
soup = BeautifulSoup(r.content, 'lxml') # lxml or html.parser

# テーブルを全てデータフレーム化
df_list = [pd.read_html(str(table))[0] for table in soup.find_all('table')]

print('表の数: %d' % len(df_list))  # 表の数: 8

df_list[2]
```
__[out]__

|||||| 
|:--|:--|:--|:--|:--|
|| Type | mutable | Description | Syntax example |
| 0 | bool | immutable | Boolean value | TrueFalse |
| 1 | bytearray | mutable | Sequence of bytes | bytearray(b'Some ASCII')bytearray(b"Some ASCII... |
| 2 | bytes | immutable | Sequence of bytes | b'Some ASCII'b"Some ASCII"bytes([119, 105, 107... |
| 3 | complex | immutable | Complex number with real and imaginary parts | 3+2.7j |
| 4 | dict | mutable | Associative array (or dictionary) of key and v... | {'key1': 1.0, 3: False} |
| 5 | ellipsisa | immutable | An ellipsis placeholder to be used as an index... | ...Ellipsis |
| 6 | float | immutable | Floating point number, system-defined precision | 3.1415927 |
| 7 | frozenset | immutable | Unordered set, contains no duplicates; can con... | frozenset([4.0, 'string', True]) |
| 8 | int | immutable | Integer of unlimited magnitude[82] | 42 |
| 9 | list | mutable | List, can contain mixed types | [4.0, 'string', True] |
| 10 | NoneTypea | immutable | An object representing the absence of a value. | None |
| 11 | NotImplementedTypea | immutable | A placeholder that can be returned from overlo... | NotImplemented |
| 12 | set | mutable | Unordered set, contains no duplicates; can con... | {4.0, 'string', True} |
| 13 | str | immutable | A character string: sequence of Unicode codepo... | 'Wikipedia'"Wikipedia""""Spanningmultiplelines""" |
| 14 | tuple | immutable | Can contain mixed types | (4.0, 'string', True) |

めちゃめちゃ簡単に表のパースができます

## 参考
そもそものhtmlをpd.read_html()してデータフレームのリストを作ることも可能ですが，一旦find_all('table')してからその文字列をpd.read_html()した方が処理時間が圧倒的に早かったです．

# 綺麗ではない表のスクレイピング

pdfなどを変換ツールを用いて無理やりhtmlに変換した場合，そのままpd.read_html()するとおかしな挙動になります

## 例

```python
html =\
"""
<html>
    <body>
        <table style="border-collapse:collapse" cellspacing="0">
            <tbody>
                <tr style="height:13pt">
                    <td style="width:22pt;border-top-style:solid;border-top-width:1pt;border-left-style:solid;border-left-width:1pt;border-bottom-style:solid;border-bottom-width:1pt">
                        <p class="s5" style="text-indent: 0pt;line-height: 12pt;text-align: center;">特</p>
                    </td>
                    <td style="width:52pt;border-top-style:solid;border-top-width:1pt;border-bottom-style:solid;border-bottom-width:1pt">
                        <p class="s5" style="padding-left: 6pt;text-indent: 0pt;line-height: 12pt;text-align: left;">定 資</p>
                    </td>
                    <td style="width:62pt;border-top-style:solid;border-top-width:1pt;border-bottom-style:solid;border-bottom-width:1pt">
                        <p class="s5" style="text-indent: 0pt;line-height: 12pt;text-align: left;">産 の 種</p>
                    </td>
                    <td style="width:22pt;border-top-style:solid;border-top-width:1pt;border-bottom-style:solid;border-bottom-width:1pt;border-right-style:solid;border-right-width:1pt">
                        <p class="s5" style="padding-right: 5pt;text-indent: 0pt;line-height: 12pt;text-align: right;">類</p>
                    </td>
                    <td style="width:261pt;border-top-style:solid;border-top-width:1pt;border-left-style:solid;border-left-width:1pt;border-bottom-style:solid;border-bottom-width:1pt;border-right-style:solid;border-right-width:1pt">
                        <p class="s5" style="padding-left: 5pt;text-indent: 0pt;line-height: 12pt;text-align: left;">不動産信託受益権</p>
                    </td>
                </tr>
                <tr style="height:13pt">
                    <td style="width:22pt;border-top-style:solid;border-top-width:1pt;border-left-style:solid;border-left-width:1pt;border-bottom-style:solid;border-bottom-width:1pt">
                        <p class="s5" style="text-indent: 0pt;line-height: 12pt;text-align: center;">取</p>
                    </td>
                    <td style="width:52pt;border-top-style:solid;border-top-width:1pt;border-bottom-style:solid;border-bottom-width:1pt">
                        <p class="s5" style="padding-left: 6pt;text-indent: 0pt;line-height: 12pt;text-align: left;">得 予</p>
                    </td>
                    <td style="width:62pt;border-top-style:solid;border-top-width:1pt;border-bottom-style:solid;border-bottom-width:1pt">
                        <p class="s5" style="text-indent: 0pt;line-height: 12pt;text-align: left;">定 年 月</p>
                    </td>
                    <td style="width:22pt;border-top-style:solid;border-top-width:1pt;border-bottom-style:solid;border-bottom-width:1pt;border-right-style:solid;border-right-width:1pt">
                        <p class="s5" style="padding-right: 5pt;text-indent: 0pt;line-height: 12pt;text-align: right;">日</p>
                    </td>
                    <td style="width:261pt;border-top-style:solid;border-top-width:1pt;border-left-style:solid;border-left-width:1pt;border-bottom-style:solid;border-bottom-width:1pt;border-right-style:solid;border-right-width:1pt">
                        <p class="s5" style="padding-left: 5pt;text-indent: 0pt;line-height: 12pt;text-align: left;">平成 <span class="s6">** </span>年 <span class="s6">** </span>月 <span class="s6">** </span>日（予定）</p>
                    </td>
                </tr>
            </tbody>
        </table>
    </body>
</html>
"""
soup = BeautifulSoup(html, "lxml") # lxml or html.parser
# テーブルを全てデータフレーム化
df_list = [pd.read_html(str(table))[0] for table in soup.find_all('table')]

print('表の数: %d' % len(df_list))  # 表の数: 1

df_list[0]
```

__[out]__

|||||||
|--:|:--|:--|:--|:--|:--|
|| 0 | 1 | 2 | 3 | 4 |
| 0 | 特 | 定 資 | 産 の 種 | 類 | 不動産信託受益権 |
| 1 | 取 | 得 予 | 定 年 月 | 日 | 平成 ** 年 ** 月 ** 日（予定） |

変換ツールでhtmlに変換するとこのような文字列分割のバグ(?)がよく起こります．

ただ元のhtmlを[ブラウザ](https://www.dropbox.com/s/xlkf0k2wgsht48l/test.html?dl=0)でみてみるとちゃんと罫線が正しい位置にあることを確認できるかと思います．(リンクは，dropboxにログインしていないとただしく見えないと思います)

htmlをもう一度よくみてみると．

```html
<td style="width:22pt;border-top-style:solid;border-top-width:1pt;border-bottom-style:solid;border-bottom-width:1pt;border-right-style:solid;border-right-width:1pt">
```

のように，'border-hogehoge-style'という文字列が入っています．

このhogehogeがtopだったら上の罫線あり，leftだったら左の罫線ありということになります．

# バグ対応

これを考慮して，

* tdのstyleに'border-left-style'が入ってなかったら，左のtdに文字列をマージしてhtmlを書き換える

というロジックでスクリプトを書くと以下になります


```python
import bs4
from bs4 import BeautifulSoup
import pandas as pd

def correct_table(table: bs4.element.Tag):
    """
    【破壊関数】
    引数のtableタグに左側罫線のないセルがあった場合にのみ動く
        * あった場合は無理やりその左のセルに中の文字列をくっつける
    """
    while(1):
        break_flg = 0
        for i, row in enumerate(table.find_all("tr")):
            for j, cell in enumerate(row.find_all(["td", "th"])):
                if j > 0:
                    """ 各行一番左のセルは確実に左側罫線があるのでそれ以外を考える """
                    # index取得
                    current_cell_index = table.find_all().index(cell)
                    one_left_cell_index = table.find_all().index(row.find_all(["td", "th"])[j - 1]) if j!=0 else None
                    # style取得(もしstyleアトリビュートがなければ'border-left-style'というstyleであるとする)
                    current_cell_style = cell.get("style") or "border-left-style"
                    # colspan取得
                    current_cell_colspan = int(cell.get("colspan")) if cell.get("colspan") else 1
                    one_left_cell_colspan = int(table.find_all()[one_left_cell_index].get("colspan")) if table.find_all()[one_left_cell_index].get("colspan") else 1
                    # セルの左側に罫線があるかどうかの判定
                    left_ruled_line_exist = "border-left-style" in current_cell_style

                    if not left_ruled_line_exist:
                        """ セルの左側に罫線がない場合の処理 """
                        one_left_cell_value = table.find_all()[one_left_cell_index].text
                        table.find_all()[one_left_cell_index]["colspan"] = current_cell_colspan + one_left_cell_colspan
                        table.find_all()[one_left_cell_index].string =\
                            table.find_all()[one_left_cell_index].text + table.find_all()[current_cell_index].text
                        cell.extract() #いまのセルを削除する
                        break_flg = 1
                        break
        if break_flg == 0:
            """ セルの左側に罫線がないセルが一つもなくなったらwhile文からbreakする """
            break
def read_table_from_soup(soup: BeautifulSoup) -> list:
    """ soupから表をパースしてデータフレームのリストを返す """
    [correct_table(table) for table in soup.find_all('table')]
    df_list = [pd.read_html(str(table))[0] for table in soup.find_all('table')]
    return df_list

if __name__ == '__main__':
    # html変数は先ほどと同じものを使う
    soup = BeautifulSoup(html, "lxml") # lxml or html.parser
    df_list = read_table_from_soup(soup)
    print('表の数: %d' % len(df_list))  # 表の数: 1

    df_list[0]
```

__[out]__

|||||||
|--:|:--|:--|:--|:--|:--|
|| 0 | 1 | 2 | 3 | 4 |
| 0 | 特 定 資 産 の 種 類 | 特 定 資 産 の 種 類 | 特 定 資 産 の 種 類 | 特 定 資 産 の 種 類 | 不動産信託受益権 |
| 1 | 取 得 予 定 年 月 日 | 取 得 予 定 年 月 日 | 取 得 予 定 年 月 日 | 取 得 予 定 年 月 日 | 平成 ** 年 ** 月 ** 日（予定） |

このように，罫線の位置で判別してpandas.DataFrameにパースできます

# おわりに

おそらくもっと賢い書き方があると思うのでコメントいただければ修正します。