ツタヤの在庫検索が使いづらいので、PythonでどうにかするGUIアプリ作ってみた

# はじめに
こんにちは。はなたと申します。元々はてなブログをやっていたのですが、プログラミングに関する記事はqiitaで公開しようということで、書いてみようと思います。


## ツタヤの在庫検索システム
突然ですが皆さん、TSUTAYAの在庫検索システムを利用したことがありますか？
https://store-tsutaya.tsite.jp/item.html

これはCDやDVDの在庫を、店舗を指定して調べることができるスグレモノです。
しかし１つだけ欠点があります。。。
このシステム、在庫がある店だけを絞り込むことができません。そのため、在庫がある店を探し出すには、全ての店のページを見る必要があります。
5店舗ぐらい調べてみるものの、どこも取扱していないから「この商品はレンタル終わったんだ…」って諦めることが多々あります。

というわけで今回は、在庫がある店の情報のみを抽出し表示するGUIアプリを作ってみました。pyinstallerでexe化もしました。長い記事になりそうなので、最初に動作の様子を貼っておきます。こんな感じです↓

## 完成GUIプログラムの実際の動作
![gui_do.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405491/4bb7b01a-95ae-d26f-f8ac-f91127604bb3.gif)
意外と便利ですよね。どうですか？
プログラムの概要はスクレイピングのコードと一緒に後ほど解説します。
GUI部分は…あまり良く分かっておらず、見よう見まねで書いてみたので触れません。

# コードの説明
コードの説明をしていきます。頑張って細かく書くつもりですが、たぶん途中で雑になります。予めご了承ください。
完成したコードはgithubにて公開しております。
https://github.com/hatena-hanata/Search_zaiko_app/tree/develop
## ライブラリ
```python:main_app.py
from tkinter import *
from tkinter import messagebox
from tkinter import ttk
from bs4 import BeautifulSoup
import requests
from selenium import webdriver
import time
import threading
```
GUI部分はtkinterを使用しています。
スクレイピングですが、今回は目的のページがJavaScriptでレンダリングされていたので、seleniumを利用します。ブラウザはPhantomJSを使用しました。
ただし、PhantomJSは更新が終了してしまい、現在使用が推奨されていないので、他のヘッドレスブラウザを使用したほうが良いと思います。
threadingはスクレイピングを並列処理させるためです。

## スクレイピングの流れ
このアプリの一番の根幹をなす、スクレイピング部分について記述します。
在庫検索には「商品ID」「販売形式（CD or DVD）」「都道府県ID」が必要になります。都道府県IDについては、ひとまず関東地方の4件だけにしています。

### 1. PhantomJSを起動して商品名を取得
```python:main_app.py
    def scraping(self, item_id, item_type, prefecture_id):
        # ---ブラウザの起動---
        self.print_msg('ブラウザを起動しています…')
        driver = webdriver.PhantomJS()
        driver.implicitly_wait(10)  # 要素が見つかるまで10秒待機
        self.print_msg('ブラウザを起動しました')

        # ---商品名の取得（商品IDの正誤検知）---
        self.print_msg('商品名を取得しています…')
        item_url = 'https://store-tsutaya.tsite.jp/item/{0}/{1}.html'.format(item_type, item_id)
        driver.get(item_url)
        item_html = driver.page_source
        item_soup = BeautifulSoup(item_html, 'html.parser')
        if item_soup.find('div', id='errorBlock') is not None:
            self.print_msg('商品ID、販売形式をもう一度確認してください')
            driver.quit()  # ブラウザを閉じる
            self.start_btn['state'] = NORMAL
            return

        item_title = item_soup.find('div', class_='header').find('h2').find('span').string
        self.print_msg('タイトル：{}　の在庫を検索します'.format(item_title))
```
入力された商品ID・販売形式から、商品ページのURLを開きます。こんなページです。![20190203193558.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405491/50212d33-0d44-68e6-a66f-24539d2ab671.png)
URLのうち、赤い丸で囲まれているところから、商品IDと販売形式が分かります。
そしてこのページから商品名を取得し、表示させます。

ぶっちゃけ商品名取得はそこまで重要ではなく、入力された商品IDのバリデーションチェックを行うのが目的です。もし移動したURL先がエラーページであれば、その旨を表示してスクレイピングを中止します。

### 2. 店舗一覧ページに移動、掲載ページ数を求める
上の画像の青丸で囲まれているボタンを押して、都道府県を指定したページに移動します。
総店舗数しか分からず、何回ページ送りすれば良いかわからないので、総店舗数から掲載ページ数を求めいています。
ここのコード、ダサいですね。いつか修正します。

```python:main_app.py
    def scraping(self, item_id, item_type, prefecture_id):
    
    	####
    	
        # 店一覧のページをsoupへ
        self.print_msg('店舗一覧を取得しています…')
        shop_search_url = 'https://store-tsutaya.tsite.jp/tsutaya/articleList?account=tsutaya&' \
                          'accmd=1&arg=https%3A%2F%2Fstore-tsutaya.tsite.jp%2Fitem%2F{0}%2F{1}' \
                          '.html&ftop=1&adr={2}' \
            .format(item_type, item_id, prefecture_id)
        driver.get(shop_search_url)
        html = driver.page_source
        soup = BeautifulSoup(html, 'html.parser')
        self.print_msg('店舗一覧の取得が完了しました')

        # 掲載ページ数を求める
        search_result_num = soup.find(class_='txt_k f_left').string
        total_shop_num = int(search_result_num.split('件')[1].split('全')[-1])
        if total_shop_num % 20 == 0:
            lastpage = total_shop_num / 20
        else:
            lastpage = int(total_shop_num / 20) + 1
```

### 3. ページに掲載されている全店舗のURLを取得する
![20190203194502.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405491/68afd9be-3ce7-611d-78b2-040b1069d404.png)
このページの「在庫を表示する」ボタンを押すと、その店舗での商品の在庫状況が書かれたページに移動します。というわけで、このボタンのリンクを取得します。

```python:main_app.py
    def scraping(self, item_id, item_type, prefecture_id):
    
    	####
    	
        # ページごと処理
        for page in range(1, lastpage + 1):
            # stopボタンが押された
            if self.is_active is False:
                self.print_msg('作業を中断しました')
                return

            # 店舗一覧ページに戻る
            driver.get(shop_search_url)
            
            # ページを更新
            driver.execute_script("ExecuteAjaxRequest('./articleList', 'account=tsutaya&accmd=1&" \
                                  "arg=https%3a%2f%2fstore-tsutaya.tsite.jp%2fitem%2f{0}%2f{1}.html" \
                                  "&searchType=True&adr={2}&pg={3}&pageSize=20&pageLimit=10000&" \
                                  "template=Ctrl%2fDispListArticle_g12', 'DispListArticle'); return false;" \
                                  .format(item_type, item_id, prefecture_id, page))
            # ページのsoupを取得する
            p_html = driver.page_source
            p_soup = BeautifulSoup(p_html, 'html.parser')

            # ページに掲載されている全店舗のURL取得
            table = p_soup.find('div', id='DispListArticle').find('table')
            links = table.find_all('a')
```

### 4. 各店舗の在庫情報を取得する
ようやく在庫情報が書かれた、目的のページに到達します。ここで在庫情報を取得します。
商品の取扱がされていない場合、店舗名は表示されません。

```python:main_app.py
    def scraping(self, item_id, item_type, prefecture_id):
    
    	####
    	
        # ページごと処理
        for page in range(1, lastpage + 1):
        
        	####
        	
            for link in links:
                # stopボタンが押された
                if self.is_active is False:
                    self.print_msg('作業を中断しました')
                    return
                shop_url = link.get('href')
                cnt += self.get_zaiko_info(driver, shop_url)  # 在庫情報を取得
                self.print_msg('{}店舗中　{}店舗の在庫確認が終わりました'.format(total_shop_num, cnt))
                
             
    def get_zaiko_info(self, driver, url):
        # urlを開いてsoupへ
        driver.get(url)
        html = driver.page_source
        soup = BeautifulSoup(html, 'html.parser')

        # 店の名前と在庫情報を取得
        shop_name = soup.find('h3', class_='green clearfix').find('a').string
        zaiko = soup.find('div', class_='state').find('span').string

        # 取扱がない場合1をreturnするだけ、取扱がある場合は在庫情報を表示する
        if '－' in zaiko:
            return 1
        else:
            if '○' in zaiko:
                self.textField.insert('end', '{}では在庫があります'.format(shop_name))
            else:
                self.textField.insert('end', '{}では取扱していますが、現在在庫がありません'.format(shop_name))
            return 1
```

# pyinstallerを利用してexe化してみる
GUI部分は割愛！ひとまず完成したので、pyinstallerを利用してexe化してみます。
予めpipでpyinstallerやプログラムで使用している外部ライブラリをインストールしておく必要があります。今回だとbeautifulsoupやrequestsですね。

コマンドプロンプトにて

```
pyinstaller main_app.py --onefile --noconsole
```
を実行するとexeが作成されます。--onefileオプションを付けたことで、exeのみで動作するようになっています。
ただ、今回のプログラムではPhantomJSを利用します。私の環境ではpathを通しているため、PhantomJSの起動は問題ありませんでした。
配布することを考えたら、プログラムでPhantomJSの場所を指定してあげたほうが良さそうです。

# おわりに
初めてqiitaの記事を書きましたが、すごい疲れました。人にプログラムの説明をするのってすごい大変です。
質問やこのコードダサくね？などあれば気軽にコメントください。豆腐メンタルなので、誹謗中傷は受け付けません。