PythonとExcelを連携してWebサイトから欲しいデータをスクレイピング

## スクレイピングの目的

まずコーディングというか、ちょっとしたアプリレベルでもその技術を使って何を実現したいかというのは重要です。

私は主に投資信託（以下、投信）による投資をしているのですが、近年は投信の商品も粒ぞろいで良い商品がどしどし販売されており、ついつい目移りしてしまいます。

「自分が購入している商品は本当に良い商品なのか」
「もっとコストが安く、利益が出やすい商品があるのではないか」

そんなことを**年に一度**は考えてしまいます。

それで証券サイトの投信ページで検索して商品を比較するのですが、基準価額やらシャープレシオやら信託報酬管理費やら見なければならない項目が多く、ある程度検索条件を絞ってはいるものもどうしても5～10くらいは見比べたくなります。

**ああ、表形式で一覧にざっと情報をまとめたものが欲しい…！**

これが今回のスクレイピングの目的です。

## 環境と対象サイト
OS：Windows
プログラミング言語：Python (3.8)
ソフトウェア：Excel (2016)
エディタ：Visual Studio Code

対象となるサイトの一例
楽天証券の各商品ページ（私が楽天証券を主に使用しているため）
https://www.rakuten-sec.co.jp/web/fund/detail/?ID=JP90C000BRT6

他の商品であってもページの構造は同じ。

スクレイピングには**BeautifulSoup**、Excelへの連携は**Openpyxl**を使用しました。
これらのライブラリを選んだ理由は単に検索してみた感じ、これらのライブラリを使用した記事が多かったから。
初学者は情報量が多いところから入るのが鉄則。（Pythonはほぼやったことがない）

公式ドキュメント
**BeautifulSoup**
https://www.crummy.com/software/BeautifulSoup/bs4/doc/
**Openpyxl**
https://openpyxl.readthedocs.io/en/stable/tutorial.html

## 事前準備
事前にExcelファイルを用意しました。
![WS000056.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130149/d4c88b7b-eec5-945f-5928-ab824db8fb15.jpeg)


項目は左から
・ファンド名
・證券会社
・分類（国内株式なのか先進国株式なのかみたいな）
・基準価額
・純資産額（億）
・前回純資産額（億）
・純資産増減分
・直近分配金
・買付手数料
・管理費用率（信託報酬とか事務手数料とかのコスト）
・URL

としました。
ここで項目の意味などは本記事に全く関係ないので割愛します。
要は投信のスペックを比較するための項目だと思ってください。
本当はもっといろいろあります。

ちなみに「前回純資産額（億）」と「純資産増減分」は事前に数値と関数を設定しております。
「純資産額（億）」と「前回純資産額（億）」の差をとりたいので。

「URL」も事前にわかっているので最初から記載しておきます。

ちなみに今回取得するデータは**ログイン等の必要のない公開されている情報のみ**とします。
私がどの商品をいくらで何口購入したかといった情報は取得しません。

目的はあくまで金融商品そのものの比較なので。

## スクレイピング
まずはBeautifulSoupを使用する準備から。

```python:fund.py
import requests, bs4
```

今回は楽天証券内の複数のURLにアクセスする想定なので、URLを引数にもつメソッドを事前に定義してしまいます。

```python:fund.py
# 楽天証券
def GetRakutenFund(url):
    res = requests.get(url)
    res.raise_for_status()
    soup = bs4.BeautifulSoup(res.text, "html.parser")
```

取得したい項目はもう決めているのでクラスも定義してしまいます。

```python:fund.py
# ファンド情報クラス
class FundInfo:
    def __init__(self):
        self.name = ''
        self.company = ''
        self.category = ''
        self.baseprice = ''
        self.assets = 0
        self.allotment = 0
        self.commision = ''
        self.cost = 0
```

**GetRakutenFundメソッドでスクレイピングした情報をFundInfoインスタンスに格納する**という構成にします。

それではこのサイトから欲しい情報を取得するための情報を取得します。
https://www.rakuten-sec.co.jp/web/fund/detail/?ID=JP90C000BRT6

ベタですが開発者ツールを駆使して要素を特定していきます。
その結果、下記のような構造であることがわかりました。

| 項目名      |       クラス名 
|:-----------|------------------:
| ファンド名        |              fund-name 
| 分類           |            fund-type
| 基準価額            |              value-01
| 純資産額        |              value-02 
| 直近分配金           |            value-02 
| 買付手数料            |              no-fee
| 管理費用率            |              クラス名なし

基本的にはクラス名が一意であれば簡単にデータを取れますが、今回はそうではないようです。
純資産額と直近分配金は同じクラス名を使用していますし、
管理費用率はクラス名がありませんでした。

なので今回はクラス名で特定できない場合は**1つ上の要素をとってcontentsの配列にとる**みたいなことをしました。
![キャプチャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130149/19a9957e-c506-ee75-d917-7b1f45d7b6b0.png)

この画像はtbl-fund-summaryというクラスで括られています。
その中からさらにvalue-02というクラス名の要素を抜き出しました。

```python:fund.py
fundsummary = soup.find("table", attrs={"class", "tbl-fund-summary"})
elements = fundsummary.find_all("span", attrs={"class", "value-02"})
fundinfo.assets = elements[0].text
fundinfo.allotment = elements[1].text
```

elements[0]が純資産額、elements[1]が直近分配金という風に特定できました。

同じ要領で管理費用率も特定します。
![キャプチャ2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130149/767de154-dddd-f541-7656-9b55d9e00a63.png)

この項目はli要素のtrust-feeというクラスの中にtd単品でありました。

```python:fund.py
costs = soup.find("li", attrs={"class", "trust-fee"})
elements = costs.find_all("td")
fundinfo.cost = elements[0].text
```

最終的にGetRakutenFundメソッドはこんな処理をします。

```python:fund.py
# 楽天証券
def GetRakutenFund(url):
    res = requests.get(url)
    res.raise_for_status()
    soup = bs4.BeautifulSoup(res.text, "html.parser")

    fundinfo = FundInfo()
    # ファンド名、分類
    fundinfo.name = soup.select_one('.fund-name').text
    fundinfo.company = '楽天'
    fundinfo.category = soup.select_one('.fund-type').text
    # 基準価額、純資産、直近分配金
    fundsummary = soup.find("table", attrs={"class", "tbl-fund-summary"})
    elemnt = fundsummary.select_one('.value-01')
    fundinfo.baseprice = elemnt.text + elemnt.nextSibling
    elements = fundsummary.find_all("span", attrs={"class", "value-02"})
    fundinfo.assets = elements[0].text
    fundinfo.allotment = elements[1].text
    # 買付手数料、信託報酬等の管理費
    fundinfo.commision = soup.select_one('.no-fee').text
    costs = soup.find("li", attrs={"class", "trust-fee"})
    elements = costs.find_all("td")
    fundinfo.cost = elements[0].text

    return fundinfo
```

この辺スクレイピングに詳しい方であればもっとスマートな記述方法があるはずですね。

で、メソッドの呼び出し元。メイン処理のファイルと分けたのでfund.pyをfundとしてimportしています。

```python:main.py
nam = fund.GetRakutenFund('https://www.rakuten-sec.co.jp/web/fund/detail/?ID=JP90C000BRT6')
```

## Excelへの連携
欲しい情報をFundInfo型のインスタンスとして取得することができました。
このデータをExcelに流し込みます。

Openpyxlを使いたいのでまずはpipなどからインストールしてください。
インストールしたらimport文を書きます。

```python:exceloperator.py
import openpyxl
```

そしてExcel処理を実行するメソッドを定義します。

```python:exceloperator.py
def WriteExcel(fund_info_list):
```

今まで書いていなかったけど今回情報を取得したいURLは**4つ**ありました。
なのでFundInfoのインスタンス4つをリスト(fund_info_list)に格納して、Excel処理を実行するメソッドに引数として引き渡し、ループで処理を行いたいと思います。

まずは事前に準備したExcelを読み込みます。
そして処理を行いたいワークシートを取得します。今回の場合は「ファンド」シートが対象です。

```python:exceloperator.py
# rはエスケープシーケンスを無視
wb = openpyxl.load_workbook(r'Excelファイルのパス')
ws = wb['ファンド']
```

パスを引数に指定した時、Windows環境だとバックスラッシュ等が良くないぽいです。
rをつけてやるとエスケープシーケンスを無視してくれるとのこと。

後はリストにぶち込んだFundInfoの各項目を対応するセルに設定していくだけです。
今回の私の場合は6列目と7列目は前回確認時との差分をとるための項目なのでデータの更新は行いません。
配列に詰めるやり方があるっぽい感じもありましたが、とりあえず愚直に一つずつ設定する方法を取りました。

```python:exceloperator.py
row = 2
for fund in fund_info_list:
    col = 1
    # 6列目、7列目は更新対象外
    ws.cell(column=col, row=row, value=fund.name)
    col += 1
    ws.cell(column=col, row=row, value=fund.company)
    col += 1
    ws.cell(column=col, row=row, value=fund.category)
    col += 1
    ws.cell(column=col, row=row, value=fund.baseprice)
    col += 1
    ws.cell(column=col, row=row, value=float(fund.assets.replace(',', '')))
    col += 3
    ws.cell(column=col, row=row, value=int(fund.allotment))
    col += 1
    if fund.commision == 'なし':
        ws.cell(column=col, row=row, value=0)
    else:
        ws.cell(column=col, row=row, value=fund.commision)
    col += 1
    ws.cell(column=col, row=row, value=fund.cost)
    row += 1
```

あと気をつけたのは純資産額(assets)と直近分配金(allotment)は数値型として扱いたいので数値変換してセルに設定。
純資産額は1000区切りのカンマが入る可能性もあるのでカンマを取り除く処理を入れている。
買付手数料はサイトでは「なし」という表記なのだが（ぶっちゃけ私が買うのはそれonly）「なし」よりは手数料0円と扱う方が何かと楽なのでここで変換している。

ああ、インクリメントが欲しい…(C#erのぼやき）

最後はきちんと保存。
開いたファイルのパスを指定すれば上書き保存してくれる。

```python:exceloperator.py
wb.save(r'Excelファイルのパス')
```

## コード全容
綺麗じゃないのは初心者なので多めに見てください。

```python:main.py
import fund, exceloperator

# メイン関数
# ＜購入・換金手数料なし＞ニッセイＴＯＰＩＸインデックスファンド
nam = fund.GetRakutenFund('https://www.rakuten-sec.co.jp/web/fund/detail/?ID=JP90C000BRT6')
# たわらノーロード　先進国株式
am_one = fund.GetRakutenFund('https://www.rakuten-sec.co.jp/web/fund/detail/?ID=JP90C000CMK4')
# ｅＭＡＸＩＳSlim 新興国株式インデックス
emax_emarging = fund.GetRakutenFund('https://www.rakuten-sec.co.jp/web/fund/detail/?ID=JP90C000F7H5')
# ｅＭＡＸＩＳ Ｓｌｉｍ 米国株式（Ｓ＆Ｐ５００）
emax_sp500 = fund.GetRakutenFund('https://www.rakuten-sec.co.jp/web/fund/detail/?ID=JP90C000GKC6')

# EXCELへ書き込み
fund_info_list = [nam, am_one, emax_emarging, emax_sp500]
exceloperator.WriteExcel(fund_info_list)
```

```python:fund.py
# BeautifulSoup4を使用したスクレイピング
import requests, bs4

# ファンド情報クラス
class FundInfo:
    def __init__(self):
        self.name = ''
        self.company = ''
        self.category = ''
        self.baseprice = ''
        self.assets = 0
        self.allotment = 0
        self.commision = ''
        self.cost = 0
    
# 楽天証券
def GetRakutenFund(url):
    res = requests.get(url)
    res.raise_for_status()
    soup = bs4.BeautifulSoup(res.text, "html.parser")

    fundinfo = FundInfo()
    # ファンド名、分類
    fundinfo.name = soup.select_one('.fund-name').text
    fundinfo.company = '楽天'
    fundinfo.category = soup.select_one('.fund-type').text
    # 基準価額、純資産、直近分配金
    fundsummary = soup.find("table", attrs={"class", "tbl-fund-summary"})
    elemnt = fundsummary.select_one('.value-01')
    fundinfo.baseprice = elemnt.text + elemnt.nextSibling
    elements = fundsummary.find_all("span", attrs={"class", "value-02"})
    fundinfo.assets = elements[0].text
    fundinfo.allotment = elements[1].text
    # 買付手数料、信託報酬等の管理費
    fundinfo.commision = soup.select_one('.no-fee').text
    costs = soup.find("li", attrs={"class", "trust-fee"})
    elements = costs.find_all("td")
    fundinfo.cost = elements[0].text

    return fundinfo
```

```python:exceloperator.py
# openpyxlを使用したExcel操作
import openpyxl

def WriteExcel(fund_info_list):
    # rはエスケープシーケンスを無視
    wb = openpyxl.load_workbook(r'Excelファイルのパス')
    ws = wb['ファンド']
    
    row = 2
    for fund in fund_info_list:
        col = 1
        # 6列目、7列目は更新対象外
        ws.cell(column=col, row=row, value=fund.name)
        col += 1
        ws.cell(column=col, row=row, value=fund.company)
        col += 1
        ws.cell(column=col, row=row, value=fund.category)
        col += 1
        ws.cell(column=col, row=row, value=fund.baseprice)
        col += 1
        ws.cell(column=col, row=row, value=float(fund.assets.replace(',', '')))
        col += 3
        ws.cell(column=col, row=row, value=int(fund.allotment))
        col += 1
        if fund.commision == 'なし':
            ws.cell(column=col, row=row, value=0)
        else:
            ws.cell(column=col, row=row, value=fund.commision)
        col += 1
        ws.cell(column=col, row=row, value=fund.cost)
        row += 1

    wb.save(r'Excelファイルのパス')
```

## スクレイピング実行後のExcelファイル
![WS000055.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130149/d1cb50db-119b-b741-bbae-72fca1c5dc8c.jpeg)


## 感想
初めてまともにPythonのコードを書いたかもしれない。
望んだ動きが実現できて良かった。文法とかまだまだだなぁ…。

本職が業務系である以上、仕事で使っている技術って書きづらいんだよなあ…。
ぶっちゃけ書けるネタが仕事上にはあまりない。

ExcelもPythonとの連携を強めていくみたいなこと言ってたし。（xlwingsを使う方が良かったのかな？）
実はこっそりとExcel大好きなのでExcel使えるのであれば使い続けたい。（願望）