SBI証券の資産を確認するコードを用意する。

Haskellで株のシステムトレードプログラムを作る。(まだまだ途中)


この投稿の最後に書いたとおりに、SBI証券の資産もSlackに送る様にした。
コードを激しく変更した影響(複数の証券会社をリストで与える様にした)で設定ファイルの書き方が変っているので、テストケースの設定ファイルを参考に書き換えてください。

Python3で接続している投稿でしていることをそのままHaskellで書けば出来るが、今回の用途にはバックアップサイトで十分だと判断したので、バックアップサイトに接続します。

接続のやりかた

開発者による開発者のためのリソース。
MDN ウェブドキュメント

重要なことは大体このサイトにあるので、困ったらそこを探してください。これから書くのは蛇足であって個人の感想です。

プロトコル通りサーバーとの通信が出来れば、好きなプログラム言語で実装できますよ。

HTTP/HTTPS

Webサーバーには当然ながらHTTP/HTTPSで接続します。

Hypertext Transfer Protocol (HTTP) は HTML などのハイパーメディアドキュメントを転送するための、アプリケーション層 プロトコルです。これはウェブブラウザーとウェブサーバーの間の通信用に設計されていますが、他の用途でも使用できます。HTTP は伝統的な クライアントサーバモデル に従い、クライアントがコネクションを開き、リクエストを送信し、レスポンスを受け取るまで待ちます。また HTTP は ステートレスプロトコル であり、サーバーは 2 つのリクエストの間でいかなるデータ (状態) も保持しません。
HTTP­  ―  MDN ウェブドキュメント

HTTP は ステートレスプロトコル であり、サーバーは 2 つのリクエストの間でいかなるデータ (状態) も保持しません。

サーバー内部に状態を持たないので、自分が期待するレスポンスを得るためにサーバーが要求する情報は、すべてこちらのリクエストに載せて送信しなければならない。
ということ。

自分は、返値を得るために必要な情報全てを引数で与える純粋関数みたいなものという認識でいる。

実装した

ちょうど手元にHaskellでの実装があるので(新作ですコレ)、コードを交えながら書きます。(注意:厳密に正しいのかは保証しない。)
前に書いたPythonもHaskellも、だいたい一緒でしょう。ほらブロックをインデントで表現する所とか一緒やん。

これですることは、以下の通り。

  1. ログインページへアクセス
  2. ログインページのフォームにアカウント情報を入れてサーバーに提出
  3. ログイン(セッションの確立)後に欲しい情報のリンクにアクセスしてページを得る
  4. 上の繰り返し
  5. ログアウト

ログイン

SBI証券 バックアップサイトログインページ(https://k.sbisec.co.jp) にアクセスして
ログインページのフォームにアカウント情報を入れてサーバーに提出します。

<form> 要素
すべての HTML フォームは、以下のように <form> 要素から始まります:

<form action=”/my-handling-form-page” method=”post”>

</form>

  • action 属性は、フォームで収集したデータを送信すべき場所 (URL) を定義します。
  • method 属性は、データを送信するために使用する HTTP メソッド (“get” または “post”) を定義します。

HTML を書きましょう­  ―  MDN ウェブドキュメント

このページのフォームは

ユーザネーム
input name="username" value=""
パスワード
input name="password" value=""
ログインボタン
input type="submit" name="login" value=" ログイン "

になっているのはブラウザの「ソースを表示」でみればわかる。
プログラムはこれをスクレイピングで取り出している。この入力に対する出力はテストケースに書いておいた。

サーバーに提出

<form> 要素は action 属性と method 属性により、どこへどのようにデータを送信するかを定義できます。

しかし、これだけでは不十分です。データに名前をつけることが必要です。これらの名前は両側で重要です。ブラウザ側ではそれぞれのデータにどのような名前をつけるかを示すものであり、サーバ側では名前によってそれぞれのデータを扱うことができます。

よってデータに名前をつけるために、各々のデータを集めるフォームウィジェットの name 属性を使用しなければなりません

データを Web サーバに送信する­  ―  MDN ウェブドキュメント

これらのinput要素に入力してサーバーに提出(submit)すればログインできる。

その部分がここ

ちなみにバックアップサイトではフォームの提出は1回でよい。

セッション確立

HTTP はステートレスであるがセッションレスではない

HTTP はステートレスです。同じコネクション上であっても、連続的に実行される 2 つのリクエスト間に関係性はありません。これは電子商取引のショッピングバスケットなどのように、ユーザーが一貫した方法で特定のページと対話したいときに直接問題になります。しかし HTTP の核心がステートレスであっても、 HTTP Cookie によってステートフルなセッションを実現できます。ヘッダーの拡張性を利用して、ワークフローに HTTP Cookie が追加されれば、それぞれの HTTP リクエストが同じ状況や同じ状態を共有するためにセッションを作成できるようになります。

HTTP の概要  ―  MDN ウェブドキュメント

HTTP Cookie

HTTP Cookie (ウェブ Cookie、ブラウザー Cookie) はサーバーがユーザーのウェブブラウザーに送信する小さなデータであり、ブラウザーに保存されて次のリクエストと共に同じサーバーへ返送されます。一般的には 2 つのリクエストが同じブラウザから送信したものであるかを知るために使用されて、例えばユーザーのログイン状態を維持することができます。Cookie は、ステートレスな HTTP プロトコルのためにステートフルな情報を記憶します。

HTTP Cookie  ―  MDN ウェブドキュメント

ログインが成功すると、サーバーとの間にセッションが確立する。つまり自分の要求に対してこんなHTTPヘッダが送られてくる。

注記:
Haskellで書かれているので、タプルのリストになっています。
素のHTTPメッセージはフォームデータを送信する  ―  MDN ウェブドキュメントを参照。
[("Content-Encoding","gzip")
,("Content-Type","text/html;charset=UTF-8")
,("Date","Wed, 31 Jan 2018 00:00:00 GMT")
,("Server","-")
,("Set-Cookie","******************************************; Path=/*****/; Secure; HttpOnly")
,("Set-Cookie","*******************************************; Path=/; Secure; HttpOnly")
,("Set-Cookie","******************************************;PATH=/*****/;SECURE;HTTPONLY")
]

サーバーはクライアントへ、Cookie を保存するよう求めます (例えば PHP、Node.js、Python、Ruby on Rails といったアプリケーションが行います)。ブラウザーに送信されるレスポンスに Set-Cookie ヘッダーが含まれており、ブラウザーは Cookie を保存します。
そして、サーバーに対するすべての新たなリクエストで、ブラウザーは以前保存したすべての Cookie を Cookie ヘッダーでサーバーへ送信します。

Set-Cookie ヘッダーと Cookie ヘッダー  ―  MDN ウェブドキュメント

“Set-Cookie” HTTP レスポンスヘッダーでユーザーエージェント(このプログラム)に対してCookieを送信してきたので
responseCookieJar関数で取り出したCookieを関数の返値に含めて、次回以降のアクセスでこのCookieを送信する。

ちなみにPythonではこんな低いレイヤーをさわらなくてもrequestsライブラリがうまくやります。

見たいページにアクセスする

本文(text/html)の文字コードを確認する

文字コードの指定はHTMLの<head>で指定するのが一般的ですが、今回は送られてきたHTTPヘッダに文字コードの指定があるのでこれに従います。

("Content-Type","text/html;charset=UTF-8")

ウェブ・サーバーの設定をする権限があるならば、HTTPヘッダーを利用するのも悪くないことも頭に入れておくべきです。しかしながら注意点として、HTTPヘッダーによる指定の方が文書内でのmeta指定よりも優先度が高いため、ページ製作者は既にHTTPヘッダーで文字エンコーディングが指定されているかどうかについて常に頭の中に入れておくべきです。もし既に指定されている場合、meta要素では同じエンコーディングを指定しなければなりません。
HTMLで文字エンコーディングを指定する  ―  w3.org

一般的にはHTMLヘッダ中に文字コードの指定がある

Pythonではrequestsで判定しています。

r.encoding = r.apparent_encoding

というか、本来はライブラリがうまくやるはずなんだけど、うまくできてないのはなんでなんやろうね。(リンク先での話)

 

Haskellはこう。Parsecでパーサーを用意した。
src/BrokerBackend.hs

type HtmlCharset = String

-- |
--
-- HTMLから文字コードを取り出す関数
-- 先頭より1024バイトまでで
-- <meta http-equiv="Content-Type" content="text/html; charset=shift_jis"> とか
-- <meta charset="shift_jis">とかを探す
--
-- >>> takeCharsetFromHTML "<head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"></head>"
-- Right "UTF-8"
-- >>> takeCharsetFromHTML "<head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=shift_jis\"></head>"
-- Right "shift_jis"
-- >>> takeCharsetFromHTML "<html><head><meta charset=\"utf-8\"></head></html>"
-- Right "utf-8"
-- >>> takeCharsetFromHTML "<html><head><meta charset=\"shift_jis\"></head></html>"
-- Right "shift_jis"
takeCharsetFromHTML :: BL8.ByteString -> Either P.ParseError HtmlCharset
takeCharsetFromHTML =
    P.parse html "(parse html header)"
    . first1024Bytes
    where
    -- |
    -- 先頭より1024バイト
    first1024Bytes :: BL8.ByteString -> BL8.ByteString
    first1024Bytes = BL8.take 1024
    -- |
    -- htmlをパースする関数
    html :: P.Parser HtmlCharset
    html =
        P.try (P.spaces *> tag)
        <|> P.try (next *> html)
        <?> "end of html"
    -- |
    -- 次のタグまで読み飛ばす関数
    next :: P.Parser ()
    next = P.skipMany1 (P.noneOf ">") <* P.char '>'
    -- |
    -- タグをパースする関数
    tag :: P.Parser HtmlCharset
    tag = P.char '<' *> meta
    -- |
    -- ダブルクオート
    wquote = P.many (P.char '\"')
    -- |
    -- metaタグをパースする関数
    meta :: P.Parser HtmlCharset
    meta =
        P.string "meta" *> P.spaces
        *> (P.try charset5 <|> P.try httpEquiv)
        where
        -- |
        -- meta http-equivタグをパースする関数
        --
        -- 例 : http-equiv="Content-Type" content="text/html; charset=shift_jis"
        -- https://www.w3.org/TR/html5/document-metadata.html#meta
        httpEquiv :: P.Parser HtmlCharset
        httpEquiv =
            P.string "http-equiv=" *> contentType
        -- |
        -- meta http-equiv Content-Typeをパースする関数
        contentType :: P.Parser HtmlCharset
        contentType =
            P.string "\"Content-Type\"" *> P.spaces
            *> P.string "content=" *> wquote *> content <* wquote
    --
    -- 例 : charset="utf-8"
    -- https://www.w3.org/TR/html5/document-metadata.html#meta
    charset5 :: P.Parser HtmlCharset
    charset5 =
        P.string "charset=" *> wquote *> description <* wquote
        where
        description = P.many (P.alphaNum <|> P.char '_' <|> P.char '-')


--
-- 例 : "text/html; charset=shift_jis"
-- RFC2616 - 3.7 Media Typesによる定義
-- "https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7"
-- media-type     = type "/" subtype *( ";" parameter )
-- type           = token
-- subtype        = token
content :: P.Parser HtmlCharset
content =
    typesubtype *> P.spaces *> P.char ';' *> parameter
    <?> "broken content"
    where
    typesubtype = P.many (P.alphaNum <|> P.char '/')
    --
    --
    parameter :: P.Parser HtmlCharset
    parameter =
        P.spaces *> charset <?> "missing charset"
    --
    --
    charset :: P.Parser HtmlCharset
    charset =
        P.string "charset="
        *> P.many (P.alphaNum <|> P.char '_' <|> P.char '-')


-- |
-- HTTPヘッダからcharsetを得る
--
-- >>> takeCharsetFromHTTPHeader [("Content-Length","5962"),("Content-Type","text/html; charset=Shift_JIS")]
-- Just "Shift_JIS"
-- >>> takeCharsetFromHTTPHeader [("Content-Length","5962"),("Content-Type","text/html; charset=utf-8")]
-- Just "utf-8"
-- >>> takeCharsetFromHTTPHeader [("Content-Encoding","gzip"),("Content-Type","text/html;charset=UTF-8")]
-- Just "UTF-8"
-- >>> takeCharsetFromHTTPHeader [("Content-Length","5962")]
-- Nothing
takeCharsetFromHTTPHeader :: N.ResponseHeaders -> Maybe HtmlCharset
takeCharsetFromHTTPHeader headers = do
    -- HTTPヘッダから"Content-Type"を得る
    ct <- BL8.fromStrict <$> lookup "Content-Type" headers
    -- "Content-Type"からcontentを得る
    case P.parse content "(http header)" ct of
        Left   _ -> Nothing
        Right "" -> Nothing
        Right  r -> Just r


-- |
-- HTTP ResponseからUtf8 HTMLを取り出す関数
takeBodyFromResponse :: N.Response BL8.ByteString -> TL.Text
takeBodyFromResponse resp =
    let
        html = N.responseBody resp
        -- HTMLから文字コードを得る
        csetHtml = case takeCharsetFromHTML html of
                    Left   _ -> Nothing
                    Right "" -> Nothing
                    Right  r -> Just r
        -- HTTPレスポンスヘッダから文字コードを得る
        csetResp = takeCharsetFromHTTPHeader $ N.responseHeaders resp
        -- HTTPレスポンスヘッダの指定 -> 本文中の指定の順番で文字コードを得る
        charset = csetHtml App.<|> csetResp
        -- UTF-8テキスト
        utf8txt = flip toUtf8 html =<< charset
    in
    -- デコードの失敗はあきらめて文字化けで返却
    Mb.fromMaybe (forcedConv html) utf8txt
    where
    --
    --
    toUtf8  :: IConv.EncodingName
            -> BL8.ByteString
            -> Maybe TL.Text
    toUtf8 charset =
        either
        (const Nothing)
        Just
        . TLE.decodeUtf8'
        . IConv.convert charset "UTF-8"
    -- |
    -- 文字化けでも無理やり返す関数
    forcedConv :: BL8.ByteString -> TL.Text
    forcedConv = TL.pack . BL8.unpack

doctestに通っているので、この関数の動作はこの通り。
(大文字で書かれた要素からは文字コードを取れないが気にしないことにしている)

-- |
--
-- HTMLから文字コードを取り出す関数
-- 先頭より1024バイトまでで
-- <meta http-equiv="Content-Type" content="text/html; charset=shift_jis"> とか
-- <meta charset="shift_jis">とかを探す
--
-- >>> takeCharsetFromHTML "<head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"></head>"
-- Right "UTF-8"
-- >>> takeCharsetFromHTML "<head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=shift_jis\"></head>"
-- Right "shift_jis"
-- >>> takeCharsetFromHTML "<html><head><meta charset=\"utf-8\"></head></html>"
-- Right "utf-8"
-- >>> takeCharsetFromHTML "<html><head><meta charset=\"shift_jis\"></head></html>"
-- Right "shift_jis"

-- |
-- HTTPヘッダからcharsetを得る
--
-- >>> takeCharsetFromHTTPHeader [("Content-Length","5962"),("Content-Type","text/html; charset=Shift_JIS")]
-- Just "Shift_JIS"
-- >>> takeCharsetFromHTTPHeader [("Content-Length","5962"),("Content-Type","text/html; charset=utf-8")]
-- Just "utf-8"
-- >>> takeCharsetFromHTTPHeader [("Content-Encoding","gzip"),("Content-Type","text/html;charset=UTF-8")]
-- Just "UTF-8"
-- >>> takeCharsetFromHTTPHeader [("Content-Length","5962")]
-- Nothing
$ stack exec doctest BrokerBackend.hs
Examples: 11  Tried: 11  Errors: 0  Failures: 0

HTTPヘッダあるいはHTMLの<head>で指定された文字コードを得たら、このプログラムはテキスト全てutf8で扱うので、ここでutf8に変換しておく。

保有証券一覧を見に行く

トップページをスクレイピングしたらこんな情報が取れる
test/SBIsecCoJp/ScraperSpec.hs

 [ ("ログアウト","/bsite/member/logout.do")
 , ("取引/株価照会","/bsite/price/search.do")
 , ("注文照会","/bsite/member/stock/orderList.do")
 , ("注文取消/訂正","/bsite/member/stock/orderList.do?cayen.comboOff=1")
 , ("口座管理","/bsite/member/acc/menu.do")
 , ("保有証券一覧","/bsite/member/acc/holdStockList.do")
 , ("買付余力","/bsite/member/acc/purchaseMarginList.do")
 , ("信用建玉一覧","/bsite/member/acc/positionList.do")
 , ("信用建余力","/bsite/member/acc/positionMargin.do")
 , ("出金/振替指示","/bsite/member/banking/withdrawMenu.do")
 , ("ATMカード","/bsite/member/acc/atmwithdrawMax.do")
 , ("重要なお知らせ","javascript:openJmsg('/bsite/member/jmsg/JMsgRedirect.do?categoryId=03')")
 , ("当社からのお知らせ","javascript:openETrade('/bsite/member/jmsg/JMsgRedirect.do?categoryId=02')")
 , ("登録銘柄","/bsite/member/portfolio/registeredStockList.do")
 , ("銘柄登録","/bsite/member/portfolio/stockSearch.do")
 , ("銘柄一括登録/編集","/bsite/member/portfolio/lumpStockEntry.do")
 , ("銘柄削除","/bsite/member/portfolio/registeredStockDelete.do")
 , ("リスト作成/変更","/bsite/member/portfolio/listEdit.do")
 , ("マーケット情報","/bsite/market/menu.do")
 , ("ランキング","/bsite/market/rankingListM.do")
 , ("海外指標","/bsite/market/foreignIndexDetail.do")
 , ("外国為替","/bsite/market/forexDetail.do")
 , ("市況コメント","/bsite/market/marketInfoList.do")
 , ("ニュース","/bsite/market/newsList.do")
 , ("取引パスワード/注文確認省略設定","/bsite/member/setting/omissionSetting.do")
 , ("手数料","/bsite/info/productGroupList.do?service_info_id=commission")
 , ("取扱商品","/bsite/info/productGroupList.do?service_info_id=goodsList")
 , ("新着情報/キャンペーン","/bsite/info/campaignInfoList.do")
 , ("お問い合わせ","/bsite/info/inquiryList.do")
 , ("ログイン履歴","/bsite/member/loginHistory.do")
 , ("!ポリシー/免責事項","/bsite/info/policyList.do")
 , ("システムメンテナンス","/bsite/info/systemInfoDetail.do?sortation_id=maintenance")
 , ("ログアウト","/bsite/member/logout.do")
 , ("金融商品取引法に係る表示","/bsite/info/policyDetail.do?list=title&policy_info_id=salesLaw&text_no=1")
 ]

これはリンクテキストとhrefのタプルのリスト。

これを見て”保有証券一覧”ページを見に行く(lookup関数で探してURLを取り出す)
src/SBIsecCoJp/Broker.hs

-- |
-- リンクのページへアクセスする関数
fetchLinkPage :: BB.HTTPSession -> T.Text -> MaybeT IO TL.Text
fetchLinkPage sess t =
    fetch sess =<< MaybeT . return . lookupLinkOnTopPage sess =<< pure t
-- |
-- 引数のページへアクセスしてページを返す
fetch :: M.MonadIO m => BB.HTTPSession -> N.URI -> m TL.Text
fetch BB.HTTPSession{..} =
    fmap BB.takeBodyFromResponse
    . BB.fetchPage sManager sReqHeaders (Just sRespCookies) []
-- |
-- トップページ上のリンクテキストに対応したURIを返す
lookupLinkOnTopPage :: BB.HTTPSession -> T.Text -> Maybe N.URI
lookupLinkOnTopPage BB.HTTPSession{..} linktext =
    let (S.TopPage tp) = S.topPage sTopPageHTML in
    lookup linktext tp
    >>= BB.toAbsoluteURI sLoginPageURI . T.unpack

ほらlookup関数がいるでしょ。
まあそれはいいとして

    . BB.fetchPage sManager sReqHeaders (Just sRespCookies) []

肝心なのはサーバーから受け取ったCookie “sRespCookies” を全てのアクセスでサーバーに送るところ。
サーバーに接続しているのは自分だけではないので、このCookieを毎度送らないとセッションを識別できない。(おまえは誰だ?)となるので必ずエラーになる。

 

あとは好きなだけこれを繰り返してページを取得するだけ。

 

注記:
ここで非常に重要なことを書いておきますね。

これはプログラムのアクセスなので、人間には不可能な高速度、頻度でアクセス出来るんですよ。
このページには少なくとも500ms未満でアクセスするとエラーページが返却されてくるので、
アクセス毎に一定時間の待ちを入れて、ゆっくりアクセスする配慮をよろしくお願いいたします。

ログアウト

明示的にログアウト関数を呼んでいないのは、こうやって使うから。

test/SBIsecCoJp/BrokerSpec.hs

fetchUpdatedPriceAndStore :: Conf.InfoBroker -> Conf.Info -> IO ()
fetchUpdatedPriceAndStore broker conf =
    M.runResourceT . GenBroker.siteConn broker ua $ \sess ->
        GenBroker.fetchUpdatedPriceAndStore broker connInfo sess
    where
    connInfo = Conf.connInfoDB $ Conf.mariaDB conf
    ua = Conf.userAgent conf

Control.Monad.Trans.Resource.runResourceTで資源管理しているからで、こうしておくとPythonのwithみたいにスコープを抜ければ解放される。

ログを確認してみる

ログをデーターベースに残しているので、それを見てみる。


ログインページのフォームをサーバーに提出して、サーバーからSet-Cookieの応答を得て、そのCookieをHTTP/HTTPSリクエストに含めてアクセスしている事が解る。
このやり方しか知らなくて、以前にCookieが来なくて混乱していたのはいい思い出。
松井証券のアクセスログにCookieが無いでしょ、それは HTTP/HTTPS Get method で同じ事をしていただけでした。
興味があるなら、自分の手元でこのプログラムを動かしてログをみれば理解がはかどるのではないかな。

後は

k-db.comがサービス停止したので株価情報取得機能を一旦停止した。
「ネットストック・ハイスピード」でダウンロードしたらいいけれども、手作業は面倒(機械がしろよ)。
なんかいい方法はないものかな。

コメントを残す

メールアドレスが公開されることはありません。

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください