対話型LINE BOTの作り方004 (上場企業の決算日を答える)

#概要

[前回](https://qiita.com/mahiroaug/items/1b8ca9f5dd605f6134e4)、株価情報を取得する機能を実装したわけだが、現実問題として「そんなの、わざわざBOTに聞かなくても、株アプリ使ったほうが便利じゃね」というツッコミを受けることは時間の問題であり、自身も御多分に漏れず某ポートフォリオ管理アプリを日常的に使っているわけで、今更BOTに株価をピンポイントで教えてもらいたいというニーズを持つには至らないわけである。
そこで改めて原点に立ち返って、どんな機能が欲しいかを自問し、より実用性のある機能を模索してみた。

「そうだ、決算(予定)日を一括で把握できたら便利じゃん」

多くの銘柄を保有している場合、いちいち個別銘柄の決算、半期決算、四半期決算などの予定を日々把握しておくことは結構手間なのである。これを一元的に収集して可視化できるような機能があれば便利だし、なによりBOT本来の在り方らしくて良いと思う。

というわけで今回は、WEBサイトから情報をピンポイントで抜き出して分析するスキルの強化を目的としたスクレイピングテクニックを極めていきたい。


##システム構成

LINEアプリから銘柄コードを含む起動コードを投入(①)するとBOTがWEBサイトにアクセスし、直近の決算発表(予定)日を調査し回答(②)する。
個別銘柄指定もしくは事前にポートフォリオを設定しておき複数銘柄の一括取得も想定する。

![システム構成004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484220/11302562-8f91-f429-db32-c00101bd0374.png)



#1.対象WEBサイトの選定

[株予報](https://kabuyoho.ifis.co.jp/)というサイトを利用させていただく。
個別銘柄のページを開いたとき、下図に示す位置に直近の決算発表(予定)日が表示される。
決算発表済であればその旨が日付とともに表示され、
これから発表される予定であれば発表予定日として日付とともに表示してくれている。
この位置に対応するHTMLソースは222行目から始まる`"header_main"`クラスである。
この配下に欲しい情報が全て含まれていることがわかる。

![HTML BODY.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484220/b97d893d-3dc8-1edf-1878-f0a9776c4bbf.png)

よって今回のミッションは下記の3工程のスキルを理解すれば実現できそうである。

  1. HTMLソースの取得
  2. パース、`<div class="header_main">`タグの抽出
  3. 文字列整形

で、結論を先に書いておくと
　1. については`requests`
　2. については`BeautifulSoup`
　3. については`re`
をそれぞれ活用することで非常にスマートなコーディングが実現する。


#2.requestsによるHTMLソースの取得

requestsはチャットボット実装の際にパッケージ導入済みなので詳細は割愛する。
コーディング例は下記の通り

```python:(スクリプト実行例)requestsの使い方
(botenv2) [botenv2]$ python
Python 3.6.7 (default, Dec  5 2018, 15:02:16) 
>>> import requests

#HTMLソース取得
>>> r = requests.get('https://kabuyoho.ifis.co.jp/index.php?action=tp1&sa=report_top&bcode=4689')

#内容確認
>>> print(r.headers)
{'Cache-Control': 'max-age=1', 'Content-Encoding': 'gzip', 'Content-Type': 'text/html; charset=UTF-8', (省略)
>>> print(r.encoding)
UTF-8
>>> print(r.content)
(省略)
```

という感じで特に解説は必要ないだろう。
r.contentの中にBody部が丸ごと格納されているので、後は煮るなり焼くなり、
目的とするHTMLタグをキーにして情報を引っ張ってくればよい。


#3.BeautifulSoup(パーサー)の導入

有名なパッケージで、既にいろんなパーサーを実装しているようだ。
導入してみたところ、これだけで今回の目的の90%は達成できてしまった。。
かつてC言語をポチポチいじくっていた時代と比べると隔世の感である。

```bash:BeautifulSoupインストール
(botenv2) [botenv2]$ pip install BeautifulSoup4
```


```python:(スクリプト実行例@続き)BeautifulSoupの使い方
>>> from bs4 import BeautifulSoup

#Body部をparserで解析
>>> soup = BeautifulSoup(r.content, "html.parser")
```

試しにheader_mainクラスを表示してみる。

```python:
>>> print(soup.find("div", class_="header_main"))
```

```html:実行結果

<div class="header_main">
<div class="stock_code left">4689</div>
<div class="stock_name left">Ｚホールディングス</div>
<div class="block_update right">
<div class="title left">
                                                                        決算発表済
                                                        </div>
<div class="settle left">
                                                                        2Q
                                                        </div>
<div class="date left">
                                                                                                   2019/11/01
                                                                                                </div>
<div class="float_end"></div>
</div>
<div class="float_end"></div>
</div>
```

すごい。便利すぎて震えが止まらない。


#3.文字列整形

残るは不要な文字列を削除していくだけである。
HTMLタグは不要なので、textメソッドを利用する。


```python:(スクリプト実行例@続き)テキスト抽出
>>> s = soup.find("div", class_="header_main").text
>>> print(s)
4689
Ｚホールディングス


                                                                        決算発表済
                                                 

                                                                        2Q
                                                 

                                                                                                   2019/11/01
                                                                                         




>>> 
```

タグが一掃されたが、まだ謎の間隙が大量に残っている。
これがスペースなのかメタ文字なのかわからず、一瞬ハマった。
そういうときはバイト型で表示することで実体が見えてくる。

```python:(参考)文字コード確認
>>> s.encode()
b'\n4689\n\xef\xbc\xba\xe3\x83\x9b\xe3\x83\xbc\xe3\x83\xab\xe3\x83\x87\xe3\x82\xa3\xe3\x83\xb3\xe3\x82\xb0\xe3\x82\xb9\n\n\n\t\t\t\t\t\t\t\t\t\xe6\xb1\xba\xe7\xae\x97\xe7\x99\xba\xe8\xa1\xa8\xe6\xb8\x88\n\t\t\t\t\t\t\t\n\n\t\t\t\t\t\t\t\t\t2Q\n\t\t\t\t\t\t\t\n\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t2019/11/01\n\t\t\t\t\t\t\t\t\t\t\t\t\n\n\n\n'
```

要は/nや/tを除去すればいいのだね。
容赦なくカンマへと置換して葬り去りましょう。

```python:(スクリプト実行例@続き)メタ文字除去
>>> import re
>>> s = re.sub(r'[\n\t]+', ',', s)
>>> print(s)
,4689,Ｚホールディングス,決算発表済,2Q,2019/11/01,
```

仕上げに前後の邪魔なカンマを除去すれば

```python:(スクリプト実行例@続き)
>>> s = re.sub(r'(^,)|(,$)','', s)
>>> print(s)
4689,Ｚホールディングス,決算発表済,2Q,2019/11/01
```

おーいい感じ。このままCSVやdataframeにも変換できそうです。

ちなみに存在しない銘柄コードを取得した場合に上記処理を経ると下記の文字コードが残る。

```python:存在しない銘柄コードの場合
>>> print(s)
b'\xc2\xa0'
```

この`\xc2\xa0`はUnicodeにおけるNO-BREAK SPACEを意味しており、HTMLでは`&nbsp`に相当する。
この文字コードが含まれたままだとその後の処理に支障を来たすため可能であれば除去する方が望ましい。
(webページのスクレイピングにおいてはよく遭遇する問題のようです。)
>(参考) [【Python3】スクレイピング中に[\xa0]に遭遇した時の対処法](https://tech-knygt51.com/python-xa0-deal/)

```python:&nbspの除去
s = re.sub(r'[\xc2\xa0]','', s)
```




#4.BOTアプリに実装

以上の処理を整理して関数化したものがこちら。

```python:getSettledata.py
import requests
from bs4 import BeautifulSoup
import re
import logging
logger = logging.getLogger('getSettledata')

source = 'https://kabuyoho.ifis.co.jp/index.php?action=tp1&sa=report_top&bcode='

#決算日取得関数 (引数が空の場合は4689(ZHD)のデータを参照する)
def get_settleInfo(code="4689"):

  #クローリング  
  try:
    logger.debug('read web data cord = ' + code) #logging
    r = requests.get(source + code)
  except:
    logger.debug('read web data ---> Exception Error') #logging
    return None, 'Exception error: access failed'

  #スクレイピング
  soup = BeautifulSoup(r.content, "html.parser")
  settleInfo = soup.find("div", class_="header_main").text
  settleInfo = re.sub(r'[\n\t]+', ',', settleInfo) #メタ文字の除去
  settleInfo = re.sub(r'(^,)|(,$)','', settleInfo) #行頭行末のカンマ除去
  settleInfo = re.sub(r'[\xc2\xa0]','', settleInfo) #&nbsp(\xc2\xa0)問題の処置
  logger.debug('settleInfo result = ' + settleInfo) #logging

  if not settleInfo:
    settleInfo = 'そんな銘柄ないよ～'

  return settleInfo

if __name__ == '__main__':
  print(get_settleInfo())
```

メインプログラムの方にはいつも通り起動コードの識別による条件分岐処理を追記する。
`SETTLEVIEW_LIST_CORD`にあらかじめ自分のポートフォリオを作っておくことで一括取得の対象となる。


```python:chatbot.py(★追記)##既存機能部分については変更ないため割愛
# -*- Coding: utf-8 -*-

from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse
from django.shortcuts import render
from datetime import datetime
from time import sleep
import requests
import json
import base64
import logging
import os
import random
import log.logconfig
from utils import tools
import re
from .getStockdata import get_chart
from .getSettledata import get_settleInfo

logger = logging.getLogger('commonLogging')

LINE_ENDPOINT = 'https://api.line.me/v2/bot/message/reply'
LINE_ACCESS_TOKEN = ''

###
###　割愛
###

SETTLEVIEW_KEY = ['決算','settle'] #★追記
SETTLEVIEW_LIST_KEY = ['決算リスト'] #★追記
SETTLEVIEW_LIST_CORD = ['4689','3938','4755','1435','3244','3048'] #★追記

@csrf_exempt
def line_handler(request):

    #exception
    if not request.method == 'POST':
      return HttpResponse(status=200)

    logger.debug('line_handler message incoming') #logging
    out_log = tools.outputLog_line_request(request) #logging
    request_json = json.loads(request.body.decode('utf-8'))

    for event in request_json['events']:
      reply_token = event['replyToken']
      message_type = event['message']['type']
      user_id = event['source']['userId']

      #whitelist
      if not user_id == LINE_ALLOW_USER:
        logger.warning('invalid userID:' + user_id) #logging
        return HttpResponse(status=200)

      #action
      if message_type == 'text':
        if:
        ###
        ###　割愛
        ###

        elif any(s in event['message']['text'] for s in SETTLEVIEW_KEY): #★追記
          action_data(reply_token,'settleview',event['message']['text']) #★追記

        else:
        ###
        ###　割愛
        ###

    return HttpResponse(status=200)

def action_res(reply_token,command,):
    ###
    ###　割愛
    ###

def action_data(reply_token,command,value):

    #株価チャート
    ###
    ###　割愛
    ###

    ####################################################### ★追記ここから
    #決算情報
    elif command == 'settleview':
      logger.debug('get_settleInfo on') #logging

      #ポートフォリオ銘柄の一括取得     
      if any(s in value for s in SETTLEVIEW_LIST_KEY): 
        logger.debug('get_settleInfo LIST') #logging

        results = []
        for cord in SETTLEVIEW_LIST_CORD:
          results.append(get_settleInfo(cord))

        logger.debug('get_settleInfo LIST ---> ' + '\n'.join(results)) #logging
        response_text(reply_token,'\n'.join(results))

      #個別銘柄取得
      else:
        cord = re.search('[0-9]+$', value)
        logger.debug('get_settleInfo cord = ' + cord.group()) #logging

        result = get_settleInfo(cord.group())

        if result[0] is not None:
          response_text(reply_token,result)
        else:
          response_text(reply_token,result[1])
    ####################################################### ★追記ここまで

def response_image(reply_token,orgUrl,preUrl,text):
    ###
    ###　割愛
    ###

def response_text(reply_token,text):
    payload = {
      "replyToken": reply_token,
      "messages":[
        {
          "type": 'text',
          "text": text
        }
      ]
    }
    line_post(payload)

def line_post(payload):
    url = LINE_ENDPOINT
    header = {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + LINE_ACCESS_TOKEN
    }
    requests.post(url, headers=header, data=json.dumps(payload))
    out_log = tools.outputLog_line_response(payload) #logging
    logger.debug('line_handler message -->reply') #logging

def ulocal_chatting(event):
    ###
    ###　割愛
    ###
```

以上で完成。

```bash:line_botを起動
(botenv2) [line_bot]$ gunicorn --bind 127.0.0.1:8000 line_bot.wsgi:application
```

LINEアプリから書式に沿ったメッセージを投下すると結果が返ってくる。

![sc003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484220/8450cc45-8d19-2f2e-f8b8-b94c7fcb153a.png)

一括取得したい場合は`決算リスト`と入力する。

![sc004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484220/a79003f7-c864-ca6d-bcde-1580a6453c4a.png)

6銘柄をシリアルに処理して実測およそ1秒。想像以上に高速処理されていて感動したが、あまりWEBサイトに迷惑になるといけないので、頻繁にアクセスしないよう、ほどほどに使っていこうと思う。
今回はここまで。