資産の記録を自動化してみた

 Seleniumって知ってますか?これを使って、以前書いたブログ記事の自動化を試みました。証券会社や銀行のページにログインして、残高等の数値を読み出してGoogle spreadsheetに書き込む一連の動作をPythonのコードで記述しました。prism.jsを使って、コードの右上からcopyもできる様にしましたので、簡単に応用可能かと思います。

以前からWeb操作の自動化が出来る事は知っていましたし、プログラムからGoogle spreadsheetへの書き込みはやっていましたが、金融機関口座情報へのログインパスワードを平文でコード内に埋め込んだり、ファイルから読み取ったりするつもりもなく、かと言って自分で暗号化したりするのも面倒くさいので、読み出すのは手動で満足していました。

パスワード管理ソフト 1 passwordのCLI

私はパスワード管理ソフトとして、1 passwordというソフトウェアを20年近く使っています。1 passwordが出来たばっかりの頃に95ドルで買い切りのファミリーライセンスを購入して長い事使っていたのですが、これ以上新しいバージョンが使えないということで、数年前にサブスクに移行しました。非常に便利だし、5アカウントで年額$66ドルなので、満足して使っています。その 1 passwordが、いつの頃からか、CLIを準備したようです。これにPythonのラッパーが準備されていて、Pypiで取れるようになっています。


これを使えば、原理的には 1 passwordから読み出したパスワードはメモリ上にキープされるだけで、Pythonの変数をdelしてしまえば永続的には保存されない事になります。実際には、jupyterなどでやると、自動的にworkspaceが保存されたりしているのかも知れません。

準備の仕方はここに記述されています。その後、
op = OnePassword(account = "アカウントID")
item = op.get_item("ID")
とすれば、op.get_item()などの関数がすぐに使えます。IDは下のget_password関数に書かれているように、各項目(item)の名称を比較して、一致しているitemからIDを得ています。もっと良いやり方があるかも知れません。

Selenium

Web操作の自動化は、もちろん色々なソフトウェアがあると思いますが、ここではSeleniumを使用しました。ネットで検索すると、使用例は沢山出てきますが、Version 4での例は殆ど見つかりませんでした。

Seleniumも、この辺りを参考に、簡単にinstall出来ます。


金融機関からの情報の読み出し

今回達成したのは、私がメインに使っているSBI証券、Wealthnavi、SBI銀行、三菱UFJ銀行、三井住友銀行、InteractiveBrokers証券、Saxo Bank証券、鎌倉投信の8つの金融機関の口座情報からの資産残高の読み出しです。IB証券とSaxo Bank証券以外はごく普通と思いますので、これらから、他の銀行・証券会社に、簡単に応用可能かと思います。証券口座からは、現金残高と、未実現損益も読み出しています。外国系の証券会社には「取引パスワード」がなく、ログイン時に二段階認証が必要(二段階認証をoffにする事もできるが、しない。)なので、自動化してもスマホを握りしめていないといけません。

要素の探し方

以下のコードにも苦労の跡が残っていますが、必要な要素を探すのが大変です。私のwebの知識は非常に限られており、間違ったやり方である可能性が非常に高いです。ブラウザーはChromeを使用しています。
  1. まず、情報を取得したい金融機関のログインページに行きます。
  2. Chromeでしたら、メニューの表示→開発/管理→デベロッパーツールを選択します。デベロッパーツールのパネルが下(右)に現れます。(パネルの位置はツールのメニューバーの一番右端(バツの左)の「その他」メニュをクリックすると変更できます。)
  3. ツールのメニューバーの一番左端にポインターのアイコンがありますので、それをクリックします。

  4. ログイン画面にマウスをスライドさせると各要素の色が順番に青くなりますので、カーソルを動かして、探したい要素(例えば、ログインIDを入れるボックス)を選択してクリックします。

  5. デベロッパーツールのhtmlが表示されている画面で、対応する要素が選択されますのでマウスをスライドして青くなったコード上で右クリックします。
  6. その要素を別のエディターの画面等にコピーして詳細を確認します。name, id, classなどが設定されているか調べます。

  7. ログインID入力のようなフォーム入力の要素の場合、テスト目的か、利便性のためか、nameやidが設定されています。name, またはidが設定されていればluckyです。
  8. この例の場合、seleniumのfind_element(by = By.NAME, "KEIYAKU_NO")で探す事が出来ます。idしか設定されていない場合は、By.IDで探します。
loginid = driver.find_element(by = By.NAME, value = "KEIYAKU_NO")
loginid.send_keys("ログインID")
同様に、パスワード入力のフォーム入力の要素のidまたはnameを特定してパスワードを入力します。(詳しくはFinancialInstitutionクラスのfill_password()関数を参照)

最後にログインのボタンの要素を特定して、clickします。(各金融機関のクラスのget関数を参照)ページが切り替わって、残高等が読みだせるまで、待ってないといけません。コード中にある、WebDriverWaitというクラスを使って待つこともできるようですが、結局何秒(デフォルトでは0.5秒)に一回か、読み出しに行って確認する、という動作をするようですので、sleep関数を使って10秒待つ事にしました。週に一回程度しか実行しないので。。。

パイソンによるプログラムの例

以下、コード全体を少しずつ、ごく簡単に説明します。カーソルをコードに合わせると右上にコピーボタンが現れます。

始めに必要なモジュールをimportします。one password, selenium, gspreadはpip3等でインストールしておく必要があります。
import os
from pathlib import Path
from datetime import datetime, timedelta, date
import time
from collections import OrderedDict
#
# 1 password
#
from onepassword import OnePassword
#
# selenium
#
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
#
# google spreadsheet
#
import gspread
from oauth2client.service_account import ServiceAccountCredentials
import pytz
from google.cloud import storageゆ
要素→文字列→整数値への変換のユーティリティ関数です。
def elem2int(elem, additional_chars = [], remove_space_after = False):
    #
    # web elementは文字列なので、","やその他の不要な文字を消してから整数に変換。浮動小数点数も整数に変換
    #
    text = elem.text.replace(",","")
    if remove_space_after and (t := text.find(" ")) > 0:
        text = text[:t]
    for a in additional_chars:
        text = text.replace(a, "")
    if "." in text:
        return int(float(text))
    else:
        return int(text)
例外処理のユーティリティ関数です。
def print_except(inst):
    #
    # 例外のタイプ、引数、strをプリント
    #
    print(type(inst))    # the exception type
    print(inst.args)     # arguments stored in .args
    print(inst)          # __str__ allows args to be printed directly,
    return
ベースとなるクラスです。共通な部分はできる限りこのクラスの関数でやっています。とは言ってもget_values, fill_password, get_passwordくらいしか関数はありません。子クラスはシングルトンとして生成することとして、ベースクラスに辞書を持たせています。
class FinancialInstitution():
    #
    # Base class
    #
    fis = OrderedDict()
    @classmethod
    def append(cls, s):
        cls.fis[cls.get_name()] = s

    @classmethod
    def clear(cls):
        cls.fis.clear()

    @classmethod
    def get_name(cls):
        return cls.name

    @classmethod
    def get_all_values(cls):
        v = {}
        for k, f in cls.fis.items():
            v[k] = f.get_values()
        return v

    def __init__(self, op_name = None, debug = False):
        if op_name is None:
            self.op_name = self.name
        else:
            self.op_name = op_name
        self.values = {}
        self.updated = None
        self.driver = None
        self.debug = debug
        self.append(self)
    #
    # This is the main function to get values from the Institution's web site
    #
    def get_values(self, td = timedelta(days = 1), debug = False):
        if self.updated is not None:
            if datetime.today() < self.updated + td:
                return self.values
        self.values.clear()
        #
        # set up the web driver
        #
        self.driver = webdriver.Chrome()
        self.driver.implicitly_wait(10)# 待機時間10秒
        # Setup wait for later
        self.wait = WebDriverWait(self.driver, 10, poll_frequency = 2.0)
        self.get()
        if not debug:
            self.driver.quit()
            self.driver = None
        if any(self.values):
            self.updated = datetime.today()
        return self.values
    #
    # common function to fill username and password columns
    #
    def fill_password(self, username, password, u_name = True, p_name = True):
        pu, url = self.get_password()
        if pu is None:
            return False
        try:
            self.driver.get(url)# ログイン画面にアクセス
            
            u_by = By.NAME if u_name else By.ID
            loginid = self.driver.find_element(by = u_by, value = username)#ユーザーネーム入力欄
            loginid.send_keys(pu["username"])#ユーザーネームを入力

            p_by = By.NAME if p_name else By.ID
            passwd = self.driver.find_element(by = p_by, value = password)#パスワード入力欄
            passwd.send_keys(pu["password"])#パスワードを入力
            
            success = True
        except Exception as inst:
            print_except(inst)
            success = False
        del pu
        return success
    #
    # get username, password and url using 1 password CLI
    #
    def get_password(self, fields=["password", "username"]):
        op = OnePassword(account = "アカウントID")
        li = op.list_items()
        for i in li:
            if i["title"] == self.op_name:
                url = i["urls"][0]["href"]
                pu = op.get_item(i["id"], fields = fields)
                return pu, url
        return None, None
SBI証券用の子クラスです。get関数だけが独自のものです。ログインできたら、My資産のページへ移動して、必要な情報のxpathを使用して値を得ています。

class Sbi(FinancialInstitution):
    name = "SBI証券"
    def get(self):
        if self.fill_password('user_id', 'user_password'):
            try:
                # Store the ID of the original window
                original_window = self.driver.current_window_handle
                original_title = self.driver.title

                # Check we don't have other windows open already
                assert len(self.driver.window_handles) == 1
                #ログイン
                Log_in = self.driver.find_element(by = By.NAME, value = 'ACT_login')#ログインボタンを取得
                Log_in.click()#ログインボタンクリック
                #
                # open My資産 page
                #
                MyAssets = self.driver.find_element(by = By.ID, value = 'my-assets-button')
                MyAssets.click()
            
                time.sleep(10)

                # Wait for the new window or tab
                # self.wait.until(EC.number_of_windows_to_be(2))

                # Loop through until we find a new window handle
                #
                # move to My資産 page
                #
                for window_handle in self.driver.window_handles:
                    if window_handle != original_window:
                        self.driver.switch_to.window(window_handle)
                        break
                if False:
                    print("before title_is('My資産トップ|SBI証券'")
                    # Wait for the new tab to finish loading content
                    self.wait.until(EC.title_is("My資産トップ|SBI証券"))
                    print("after title_is('My資産トップ|SBI証券'")
                
                # balance = self.driver.find_element(by = By.ID, value = "balance")
                # self.wait.until(lambda d : balance.is_displayed())
                # "//*[@id='balance']/div[3]/div[1]/div[1]/div[2]" 資産
                # //*[@id="balance"]/div[3]/div[1]/div[1]/div[2]
                # "//*[@id='balance']/div[3]/div[1]/div[3]/div[2]" 評価損益
                # "//*[@id='balance']/ul/li[2]/div[2]/p" 国内株式
                # "//*[@id='balance']/ul/li[8]/div[2]/p" 預かり金(ドル)
                # "//*[@id='balance']/ul/li[9]/div[2]/p" ハイブリッド預金
                #
                # get values using xpaths
                #
                xpaths = {"資産価値": "//*[@id='balance']/div[3]/div[1]/div[1]/div[2]", 
                          "内未実現損益": "//*[@id='balance']/div[3]/div[1]/div[3]/div[2]",
                          "国内株式": "//*[@id='balance']/ul/li[2]/div[2]/p",
                          "内現金(ドル)": "//*[@id='balance']/ul/li[8]/div[2]/p",
                          "内現金(円)": "//*[@id='balance']/ul/li[9]/div[2]/p"
                         }
                for k, x  in xpaths.items():
                    e = self.driver.find_element(By.XPATH, x)
                    self.values[k] = elem2int(self.driver.find_element(By.XPATH, x), additional_chars = ["\n", "円"])
            except Exception as inst:
                print_except(inst)
        return
Wealthnavi用の子クラスです。get関数だけが独自のものです。ログイン後のページに必要な情報があるので、xpathを使用して値を得ています。

class Wealthnavi(FinancialInstitution):
    name = "Wealthnavi"
    def get(self):
        if self.fill_password('username', 'password'):
            try:
                # ログイン id="login"
                Log_in = self.driver.find_element(by = By.ID, value = 'login')#ログインボタンを取得
                Log_in.click()#ログインボタンクリック

                time.sleep(10)

                xpaths = {"資産価値": "//*[@id='content']/div/div/section[1]/div/div/div[1]/div[1]/dl[1]/dt/span",
                          "内未実現損益": "//*[@id='content']/div/div/section[1]/div/div/div[1]/div[1]/dl[1]/dd[1]/span"
                         }
                for k, x  in xpaths.items():
                    self.values[k] = elem2int(self.driver.find_element(By.XPATH, x), additional_chars = ["¥"])
            except Exception as inst:
                print_except(inst)
        return
SBI銀行用の子クラスです。get関数だけが独自のものです。残高のページに移動して、円預金とドル預金の残高を得ています。クラスで指定すると、複数の要素が取れて来るので、その何番目に値が入っているかを予め調べています。

class Neobank(FinancialInstitution):
    name = "SBI銀行"
    def get(self):
        if self.fill_password('userNameNewLogin', 'loginPwdSet', p_name = False):
            try:
                login = self.driver.find_element(by = By.CSS_SELECTOR, value = ".m-btnEm-l > span")
                login.click()

                self.wait.until(
                    EC.presence_of_element_located((By.CSS_SELECTOR, ".m-icon-ps_balance"))
                )
                #
                # open the balance page
                #
                balance = self.driver.find_element(by = By.CSS_SELECTOR, value = ".m-icon-ps_balance")
                balance.click()

                self.wait.until(
                    EC.presence_of_element_located((By.CLASS_NAME, "footer-pagetop.ng-trigger.ng-trigger-fadeInFast"))
                )

                elements = self.driver.find_elements(by = By.CLASS_NAME,
                                                     value = "m-zandaka-number.ng-tns-c3-3.ng-star-inserted")
                i = 0
                for e in elements:
                    if len(e.text) > 0:
                        i += 1
                        if i == 1:
                            self.values["円預金"] = elem2int(e)        
                        elif i == 3:
                            self.values["ドル預金"] = elem2int(e)
            except Exception as inst:
                print_except(inst)
        return

三井住友銀行用の子クラスです。get関数だけが独自のものです。ログインしたページに口座残高が表示されているので、その値を取ってきます。nameがついていましたので簡単です。

class Smbc(FinancialInstitution):
    name = "三井住友銀行吉祥寺支店"
    def get(self):
        if self.fill_password('accountNo', 'password'):
            try:
                branch = self.driver.find_element(by = By.NAME, value = 'branchNo')#ユーザーネーム入力欄
                branch.send_keys("999") # XXX支店

                login = self.driver.find_element(by = By.CSS_SELECTOR, value = ".btn-type01")
                login.click()

                balance = self.driver.find_element(by = By.ID, value = "ryudoAccountBalance")
                self.values["円預金"] = elem2int(balance)
            except Exception as inst:
                print_except(inst)
        return

三菱UFJ銀行用の子クラスです。get関数だけが独自のものです。ログインしたページに口座残高が表示されているので、その値を取ってきます。こちらはクラス名で要素を特定しています。

class Mufg(FinancialInstitution):
    name = "三菱UFJ"    
    def get(self):
        if self.fill_password('KEIYAKU_NO', 'PASSWORD'):
            try:
                login = self.driver.find_element(by = By.CLASS_NAME, value = "gonext")
                login.click()
        
                balance = self.driver.find_element(by = By.CLASS_NAME, value = "total-amount-unmask")
                self.wait.until(lambda d : balance.is_displayed())
                self.values["円預金"] = elem2int(balance)
            except Exception as inst:
                print_except(inst)
        return
Interactive brokers証券用の子クラスです。get関数だけが独自のものです。スマホアプリによる二段階認証が入ります。結構大変でした。

class Ib(FinancialInstitution):
    name = "IB"
    def get(self):
        if self.fill_password('username', 'password'):
            try:
                login = self.driver.find_element(by = By.CSS_SELECTOR, value = "button.btn")
                login.click()
                time.sleep(10)
                main = self.driver.find_element(by = By.ID, value = "cp-ib-app-main-content")
                self.wait.until(lambda d : main.is_displayed())
            
                buttons = self.driver.find_elements(by = By.CLASS_NAME, value = "_btn.accent.outline.fs6.sm.portfolio-summary__button")
                buttons[-1].click()

                time.sleep(10)

                self.wait.until(
                    EC.presence_of_element_located((By.CLASS_NAME, "end-16"))
                )
                nums0 = self.driver.find_elements(by = By.CLASS_NAME, value = "text-semibold.numeric.before-4.fs5")
                balance = nums0[0]
                self.values["資産価値"] = elem2int(balance)

                nums1 = self.driver.find_elements(by = By.CLASS_NAME, value = "text-semibold.numeric.before-6.fs5")
                gain = nums1[-1]
                self.values["内未実現損益"] = elem2int(gain, additional_chars = ["万"]) * 10000

                nums2 = self.driver.find_elements(by = By.CLASS_NAME, value = "only-l.text-end.inset-8")
                cash = nums2[28]
                self.values["内現金"] = elem2int(cash)
            
            except Exception as inst:
                print_except(inst)
        return
鎌倉投信の子クラスです。get関数だけが独自のものです。xpathで指定しています。
class Kamakura(FinancialInstitution):
    name = "鎌倉投信"
    def get(self):
        if self.fill_password('webcustomer_login_id', 'webcustomer_password', u_name = False, p_name = False):
            try:
                login = self.driver.find_element(by = By.NAME, value = "button")
                login.click()
                time.sleep(10)

                xpaths = {"資産価値": "//*[@id='main_area']/div[3]/div/div[1]/div/div[2]/div/div[2]/p",
                          "内未実現損益": "//*[@id='main_area']/div[3]/div/div[1]/div/div[3]/div/div[2]/p"
                         }
                for k, x  in xpaths.items():
                    e = elem2int(self.driver.find_element(By.XPATH, x), additional_chars = ["円"], remove_space_after = True)
                    print(e)
                    self.values[k] = e
            except Exception as inst:
                print_except(inst)
Saxo Bank証券の子クラスです。get関数だけが独自のものです。SNSによる二段階認証が入ります。こちらもクラス名やらxpathで指定しています。


class Saxo(FinancialInstitution):
    name = "SaxoBank"
    def get(self):
        if self.fill_password('field_userid', 'field_password'):
            try:
                login = self.driver.find_element(by = By.NAME, value = "button_login")
                login.click()

                time.sleep(30)
                self.wait.until(
                    EC.presence_of_element_located((By.CLASS_NAME, "portfolio-balances"))
                )
                while(True):
                    time.sleep(2)
                    elements = self.driver.find_elements(by = By.CLASS_NAME, value = "portfolio-balances-value")
                    if len(elements) > 3:
                        break
                balance = elements[2]
                self.values["資産価値"] = elem2int(balance)

                cash = elements[0]
                self.values["内現金"] = elem2int(cash)
            
                gain = self.driver.find_element(by = By.XPATH, value = "//*[@id='main']/div[4]/div/section/div[2]/div/div/div[2]/div[3]/div/div/div[2]/div/div[2]/div/div/div/div/div[1]/div[2]/p/span")
                self.values["内未実現損益"] = elem2int(gain)
            except Exception as inst:
                print_except(inst)
        return
全てのクラスを一つづつinstanciateします。

FinancialInstitution.clear()
ib = Ib("ib-new")
saxo = Saxo()
wealthnavi = Wealthnavi()
sbi = Sbi()
neobank = Neobank()
smbc = Smbc()
mufg = Mufg("Mufg1")
kamakura = Kamakura()
最後にベースクラスのget_all_values関数を実行すると、全ての金融機関からの情報をv(辞書の辞書)に読み出します。


v = FinancialInstitution.get_all_values()
print(v)
{'Wealthnavi': {'資産価値': 66, '内未実現損益': 16}, 'SBI証券': {'資産価値': 11, '内未実現損益': 39, '国内株式': 15, '内現金(ドル)': 46, '内現金(円)': 14}, 'SBI銀行': {'円預金': 19, 'ドル預金': 25}, '三井住友銀行XXX支店': {'円預金': 37}, '三菱UFJ': {'円預金': 23}, 'IB': {'資産価値': 48, '内未実現損益': 22, '内現金': 1}, 'SaxoBank': {'資産価値': 17, '内現金': 18, '内未実現損益': 45},'鎌倉投信': {'資産価値': 66, '内未実現損益': 16}}
open_spreadsheet()は目的のGoogle spreadsheetの目的のシートを取って来る関数です。ここにはコード例をあげませんがwebですぐ見つかると思います。 共有とか必要な設定もしてください。スプレッドシートの最後の列に、最初の2列の金融機関の名称、項目を使って、値を辞書から引いて書き込みます。

コードが実行されると、新しい列の1行目に今日の日付を書き込みます。その後、資産価値等をスプレッドシートにコピーします。スプレッドシートの例については、この記事の後半を参照してください。1列目に"合計"と書いておけば、セルのフォーミュラをコピーしますので、新しい日付で合計等の計算もできます。

    st = open_shisan()
    row1 = st.row_values(1)
    col1 = st.col_values(1)
    col2 = st.col_values(2)
    coln = len(row1) + 1
    today = date.today()
    st.update_cell(1, coln, today.strftime("%Y/%m/%d"))

    for i in range(len(col1)):
        if col1[i] in v.keys():
            if col2[i] in v[col1[i]]:
                print(i + 1, col1[i], col2[i], v[col1[i]][col2[i]])
                st.update_cell(i + 1, coln, v[col1[i]][col2[i]])
        elif col1[i] == "合計":
            src = rowcol_to_a1(i + 1, coln - 1)
            dest = rowcol_to_a1(i + 1, coln)
            c =  st.copy_range(src, dest)


以上です。要素の探索や他の部分で、もっと良い方法があるよ、という場合は教えてください。

コメント

このブログの人気の投稿

建築士さんと家を建てよう

米国の年金の受給申請をした