【Python】JSONファイルに登録されたWebサイトのページを一括表示するツールを作成したこと＆引っ掛かったところについて

#1.この記事について
###きっかけ
ECサイトや作品投稿サイトなどで同じ検索タグのページを一気に開くGUI型のツールが欲しかった。

※chromeなどではブックマークでフォルダにまとめて入れて一挙に開くことは可能だが、
変更したり追加したりするのはjsonとかのほうが手早いし、そのためにブックマークの編集を頑張るのもめんどくさかった。
あとはTkinter実装の再練習も含めて。

######※スクレイピングは用いていません（今後連携することも考えていますが...）。
######※したがってseleniumも用いていません。

######[※最新のツールとソースコードはGitHubに上げてます（後述）](#5終わりに)

#2.やりたいこと
・（jsonクラスは以前に作ったものを改良＆共通部品化）
・Tkinterのコンボボックスのハンドリングクラスを実装＆共通部品化
・mainメソッドでGUIを設定し、データを読み取り、GUIで選択されたwebページを開く

#3.使用したツール・環境
・Windows10
・Python 3.7.0
・開発はPycharm

#4.作成したコードと解説
##外観
GUIはこんな感じ。
![クイックブラウジング 2020_01_07 22_26_14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/541790/56150e8d-3a88-eafe-757b-2ace0d67ba1b.png)

##Jsonファイル


```json:webSiteDetails.json
{
  "WebSiteA": {
    "OnlyOpenPage": true,
    "PageSet": [
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com"
    ]
  },
  "WebSiteB": {
    "OnlyOpenPage": true,
    "PageSet": [
      "http://foobar.com",
      "http://foobar.com"
    ]
  },
  "WebSiteC": {
    "OnlyOpenPage": true,
    "PageSet": [
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com"
    ]
  },
  "WebSiteD": {
    "OnlyOpenPage": true,
    "PageSet": [
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com"
    ]
  },
  "WebSiteE": {
    "OnlyOpenPage": true,
    "PageSet": [
      "http://foobar.com",
      "http://foobar.com",
      "http://foobar.com"
    ]
  }
}

```

「OnlyOpenPage」プロパティを付与したのは、
今後スクレイピング機能実装も視野に入れた場合に
ただ開くだけ、と
スクレイピング処理付与、の
区別を付けたかったから。


##Jsonハンドリングクラス

```py:JsonHandler.py
import json


class JsonHandler:
    def __init__(self, jsonPath):
        # jsonファイル読み込み
        self.file = open(jsonPath, 'r', encoding="utf-8_sig")
        self.jsonData = json.load(self.file)

    # 一次ネストのjsonデータ取得
    def getParam_OneNest(self, args):
        return self.jsonData[args]

    # 一次ネストのjsonデータ一覧取得
    def getAll_OneNest(self):
        # list = []
        # for d in self.jsonData.keys():
        #     list.append(d)
        list = [d for d in self.jsonData.keys()]
        return list

    # 二次ネストのjsonデータ取得
    def getParam_TwoNest(self, args1, args2):
        return self.jsonData[args1][args2]

    def __del__(self):
        # ファイル閉じる
        self.file.close()

```

以前作成したもの（※）を少し変えた。
一次ネストのデータのみをまとめて取得する必要があったため。
[[※：https://qiita.com/dede-20191130/items/65b0f4c3c2b5c7f97546]](https://qiita.com/dede-20191130/items/65b0f4c3c2b5c7f97546)

```
list = [d for d in self.jsonData.keys()]
```

ここはリスト内包形式。

##Tkinterコンボボックスのハンドリングクラス

```py:TkHandler_Combo_1.py
import tkinter as tk
import tkinter.ttk as ttk


class TkHandler_Combo_1:
    """GUIの共通部品。コンボボックスを作成"""

    def __init__(self, rootTitle='タイトル', rootGeometry='640x480'):
        # ルートフレームの作成
        self.root = tk.Tk()
        self.root.title(rootTitle)
        # rootウィンドウの大きさを引数の値に
        self.root.geometry(rootGeometry)
        # クローズイベント設定
        self.root.protocol("WM_DELETE_WINDOW", self.onClosing)
        self.frame = None
        self.label = None
        self.combo = None
        self.button = None
        self.isDestroyed = False

    def createFrame(self):
        # フレームの作成
        self.frame = ttk.Frame(self.root, width=self.root.winfo_width() - 20, height=self.root.winfo_height() - 20,
                               padding=10)
        self.frame.pack()

    def createLabel(self, myText="選択してください"):
        # ラベルの作成
        self.label = ttk.Label(self.frame, text=myText, padding=(5, 5))
        self.label.pack()

    def createCombo(self, myWidth=None, myState='readonly', myValue=None, ):
        # widthの設定
        myWidth = myWidth or self.frame.winfo_width() - 20
        # コンボボックスの作成
        self.combo = ttk.Combobox(self.frame, width=myWidth, state=myState)
        self.combo["values"] = myValue
        # デフォルトの値を(index=0)に設定
        self.combo.current(0)
        # コンボボックスの配置
        self.combo.pack(padx=5, pady=5, fill=tk.X)

    def createButton(self, myText="実行", myFunc=None):
        """引数に関数をとる"""

        # 関数の設定
        myFunc = myFunc or (lambda: self.dummy())

        # ボタンの作成
        self.button = tk.Button(text=myText, command=lambda: myFunc(self.combo.get()))

        # ※↑↑最初はこのように実装していた
        # if myFunc:
        #     self.button = tk.Button(text=myText, command=lambda: myFunc(self.combo.get()))
        # else:
        #     self.button = tk.Button(text=myText, command=lambda: self.dummy())

        # ボタンの配置
        self.button.pack(padx=5, pady=5, )

    def dummy(self, arg=''):
        pass

    def mainLoop(self):
        self.root.mainloop()

    def onClosing(self):
        self.root.destroy()
        self.isDestroyed = True

    def __del__(self):
        if not self.isDestroyed:
            self.root.destroy()

```

###解説

```
# クローズイベント設定
        self.root.protocol("WM_DELETE_WINDOW", self.onClosing)
```

実行ボタンを押さずにウィンドウを閉じたときに呼ばれるメソッドを設定する構文。
onClosingで閉じる前にフレームを消しておいて、
デストラクタ（\__del__関数）が動作するときにエラーが起きないようにする。


```
    def createCombo(self, myWidth=None, myState='readonly', myValue=None, ):
        # widthの設定
        myWidth = myWidth or self.frame.winfo_width() - 20
```

myWidthに対しての引数の指定があればその値を持ち続けるし、
もしデフォルト引数Noneのままならば、フレームのwidth値よりも20小さくする値が入る。

orの使い方についてはこちらの記事を参考にさせていただきました。
[https://blog.pyq.jp/entry/python_kaiketsu_181016](https://blog.pyq.jp/entry/python_kaiketsu_181016)

なぜ直接デフォルト引数に「self.frame.winfo_width() - 20」をいれなかったかというと、
自分自身のクラスが持つフィールドのメンバを引数に取れないようだ。
もし可能な方法があるとご存知の方がいたら教えてほしいです。

###引っ掛かったところ：関数を引数にとる（高階関数）ときの動作

```
    def createButton(self, myText="実行", myFunc=None):
        """引数に関数をとる"""

        # 関数の設定
        myFunc = myFunc or (lambda: self.dummy())

        # ボタンの作成
        self.button = tk.Button(text=myText, command=lambda: myFunc(self.combo.get()))

        # ※↑↑最初はこのように実装していた
        # if myFunc:
        #     self.button = tk.Button(text=myText, command=lambda: myFunc(self.combo.get()))
        # else:
        #     self.button = tk.Button(text=myText, command=lambda: self.dummy())
```

Tkinterのボタンオブジェクトは押下されたときのイベントとして
command引数に関数を代入するようになっている。

そのため、createButtonメソッドを呼ばれた時点で
呼び出し側に関数を用意しておかないといけない。

しかし、共通部品化するにあたり、
関数を特に定めないときのためにデフォルト引数を用意しておきたかった。

####デフォルト引数の定め方をどうするか？

最初はこうしてみた。


```
def createButton(self, myText="実行", myFunc=lambda: pass):
```

これは動作しなかった。
引数にlambda関数を設定するのは難しいらしい。

そこで、クラス内にダミーメソッドdummyを定義し
それを代入する方針で行こうとした。

ここで、上述したように「自分自身のクラスが持つフィールドのメンバを引数に取れない」ので、
次のように書いた。


```
    def createButton(self, myText="実行", myFunc=None):
        """引数に関数をとる"""

        # if myFunc:
        #     self.button = tk.Button(text=myText, command=lambda: myFunc(self.combo.get()))
        # else:
        #     self.button = tk.Button(text=myText, command=lambda: self.dummy())
```

これでも良かったけど、ちょっと冗長だし
二回もbuttonフィールドを定義する式を記述するのはメンテの苦労も二倍。

よって、次のように書き直した。


```
    def createButton(self, myText="実行", myFunc=None):
        """引数に関数をとる"""

        # 関数の設定
        myFunc = myFunc or (lambda: self.dummy())

        # ボタンの作成
        self.button = tk.Button(text=myText, command=lambda: myFunc(self.combo.get()))

```

##Mainメソッド


```py:QuickBrowsing.py
import os
import subprocess
import sys

# モジュール検索パスを再設定
# ダブルクリックによる起動にも対応できるようにする
sys.path.append(os.getenv("HOMEDRIVE") + os.getenv("HOMEPATH") + r"\PycharmProjects\CreateToolAndTest")
from Commons.JsonHandler import JsonHandler
from Commons.TkHandler_Combo_1 import TkHandler_Combo_1
import webbrowser
from time import sleep

# global
jsonHandler = None
siteList = None
tkHandler = None
siteName = None


def Main():
    global jsonHandler
    global siteList
    global tkHandler
    global siteName
    # 項目取得
    jsonHandler = JsonHandler(
        r'C:\Users\dede2\PycharmProjects\CreateToolAndTest\Tool_Python/QuickBrowsing/webSiteDetails.json')
    siteList = jsonHandler.getAll_OneNest()
    # フォーム表示
    tkHandler = TkHandler_Combo_1('クイックブラウジング', '640x200')
    tkHandler.createFrame()
    tkHandler.createLabel('一括表示をするWebサイトを選択してください。')
    tkHandler.createCombo(myValue=siteList)
    tkHandler.createButton(myFunc=getSiteName)
    tkHandler.mainLoop()

    # サイトが設定されていなければ正常終了
    # webページが登録されていなければ正常終了
    if siteName == None or not jsonHandler.getParam_TwoNest(siteName, 'OnlyOpenPage'):
        exit(0)

    # webサイトを順番に開く
    subprocess.Popen("start chrome /new-tab www.google.com --new-window", shell=True)
    sleep(1)
    browser = webbrowser.get(r'"' + os.getenv(r'ProgramFiles(x86)') + \
                             r'\Google\Chrome\Application\chrome.exe" %s')
    for url in jsonHandler.getParam_TwoNest(siteName, 'PageSet'):
        browser.open(url)


def getSiteName(argName=''):
    global tkHandler
    global siteName
    tkHandler.onClosing()
    siteName = argName


if __name__ == '__main__':
    Main()

```

###解説

```
siteList = jsonHandler.getAll_OneNest()
省略
tkHandler.createCombo(myValue=siteList)
```

jsonから、サイト名の見出しだけ取得して
ドロップダウンリストの項目とする。


```
tkHandler.createButton(myFunc=getSiteName)
省略
def getSiteName(argName=''):
    global tkHandler
    global siteName
    tkHandler.onClosing()
    siteName = argName
```

GUIのボタン押下したら、
グローバル変数に値を入れて、保持するようにしている。

###引っ掛かったところ：新しいウィンドウでchromeを開く方法

調べたら、webbrowserモジュールのopen_newメソッドで
新しいウィンドウで開けるとの記載が。

**しかし、動かしてみたらそうはいかず、**
**もともとあったウィンドウに新しいタブとして開かれてしまう。**

####理由

どうやら、このような仕様らしい。
[https://docs.python.org/ja/3/library/webbrowser.html](https://docs.python.org/ja/3/library/webbrowser.html)

>webbrowser.open_new(url)
>可能であれば、デフォルトブラウザの新しいウィンドウで url を開きますが、そうでない場合はブラウザのただ１つのウィンドウで url を開きます。

>webbrowser.open_new_tab(url)
>可能であれば、デフォルトブラウザの新しいページ(「タブ」)で url を開きますが、そうでない場合は open_new() と同様に振る舞います。

もともとchromeが開かれていたらそのウィンドウのタブとして
開いてしまうと予想。

####対策


```
# webサイトを順番に開く
    subprocess.Popen("start chrome /new-tab www.google.com --new-window", shell=True)
    sleep(1)
    browser = webbrowser.get(r'"' + os.getenv(r'ProgramFiles(x86)') + \
                             r'\Google\Chrome\Application\chrome.exe" %s')
    for url in jsonHandler.getParam_TwoNest(siteName, 'PageSet'):
        browser.open(url)
```

subprocess.Popen関数で新しいウィンドウでchromeブラウザを開き、
一秒待機したあと、
そのウィンドウに選択したサイトの全ページをまとめて開くことにした。

####seleniumを使えば...
もっとエレガントに実装できるかもしれない。


#5.終わりに

最新のツールとソースコードはこちらに上げています。↓
[https://github.com/dede-20191130/CreateToolAndTest/tree/master/Tool_Python/QuickBrowsing](https://github.com/dede-20191130/CreateToolAndTest/tree/master/Tool_Python/QuickBrowsing)

なにか補足がありましたらコメントください。



#6.[20200111]追記：機能追加
##A. 内容
GUIに、処理ボタン押下後もツールを終了しないためのチェックボックスを実装した。

##B. 追加点

###a. Tkinterコンボボックスのハンドリングクラス

継承により次のような子クラスを作成した。
（関数・変数へのキャメル記名方式はpython標準ではないようなので
スネーク記名方式を使用するようにした。）

```py:TkHandler_Combo_1_CanKeep.py
import tkinter as tk

# Common parts in my GitHub
# https://github.com/dede-20191130/CreateToolAndTest
from Commons.TkHandler_Combo_1 import TkHandler_Combo_1


class TkHandler_Combo_1_CanKeep(TkHandler_Combo_1):
    """
    処理ボタン押下後もツールを終了しないためのチェックボックスを実装
    """

    def __init__(self, rootTitle='タイトル', rootGeometry='640x480'):
        super(TkHandler_Combo_1_CanKeep, self).__init__(rootTitle, rootGeometry)
        self.keeping_check = None
        self.bl = None

    def create_keep_check(self, text='check box', is_initial=True):
        self.bl = tk.BooleanVar(value=is_initial)
        self.keeping_check = tk.Checkbutton(self.frame, text=text, variable=self.bl)
        self.keeping_check.pack()

```

####解説

```
        self.bl = tk.BooleanVar(value=is_initial)
        self.keeping_check = tk.Checkbutton(self.frame, text=text, variable=self.bl)
```

BooleanVarはWidget変数。
[参考：https://suzutaka-programming.com/tkinter-variable/](https://suzutaka-programming.com/tkinter-variable/)

>-textvariableや-variableオプションに指定することのできる変数。

>動的にラベルの文字列を変更したり、入力した値を取得出来たりする。

###b. Mainメソッドとその周りの関数

次のように書き直し。

```py:QuickBrowsing.py
import os
import subprocess
import sys

# モジュール検索パスを再設定
# ダブルクリックによる起動にも対応できるようにする
sys.path.append(os.getenv("HOMEDRIVE") + os.getenv("HOMEPATH") + r"\PycharmProjects\CreateToolAndTest")
# Common parts in my GitHub
# https://github.com/dede-20191130/CreateToolAndTest
from Commons.JsonHandler import JsonHandler
from Commons.TkHandler_Combo_1_CanKeep import TkHandler_Combo_1_CanKeep
import webbrowser
from time import sleep

# global
jsonHandler = None
siteList = None
tkHandler = None
siteName = None


def Main():
    global jsonHandler
    global siteList
    global tkHandler
    global siteName
    # 項目取得
    jsonHandler = JsonHandler(
        r'C:/Users/UserName/MyFolder/Foo.json')
    siteList = jsonHandler.getAll_OneNest()
    # フォーム表示
    tkHandler = TkHandler_Combo_1_CanKeep('クイックブラウジング', '640x200')
    tkHandler.createFrame()
    tkHandler.createLabel('一括表示をするWebサイトを選択してください。')
    tkHandler.createCombo(myValue=siteList)
    tkHandler.create_keep_check('実行後終了しない場合はチェックしてください。')
    tkHandler.createButton(myFunc=get_name_and_open)
    tkHandler.mainLoop()


def get_name_and_open(argName=''):
    global tkHandler
    global siteName
    if not tkHandler.bl.get():
        tkHandler.onClosing()
    siteName = argName
    open_in_order()


def open_in_order():
    global jsonHandler
    global siteName
    # サイトが設定されていなければ正常終了
    # webページが登録されていなければ正常終了
    if siteName == None or not jsonHandler.getParam_TwoNest(siteName, 'OnlyOpenPage'):
        exit(0)

    # webサイトを順番に開く
    subprocess.Popen("start chrome /new-tab www.google.com --new-window", shell=True)
    sleep(1)
    browser = webbrowser.get(r'"' + os.getenv(r'ProgramFiles(x86)') + \
                             r'\Google\Chrome\Application\chrome.exe" %s')
    for url in jsonHandler.getParam_TwoNest(siteName, 'PageSet'):
        browser.open(url)


if __name__ == '__main__':
    Main()

```

####解説


```
    if not tkHandler.bl.get():
        tkHandler.onClosing()

```

GUI上で自由にチェックボックスをオンオフできる。
オンの時に実行ボタンを押下したらoncloseメソッドを呼ばない。