てけとーぶろぐ。

ソフトウェアの開発と、お絵かきと、雑記と。

Pythonで株価アラートをつくる(1)

Raspberry Pi + Pythonビットコイン自動取引」というところを目指して
何回かにわたって記事を書いていこうと思う。

最終的にはビットコインなのだけど
まずは手始めにより多くの人に馴染みがある日本の株式を扱う。

プログラムで好きな銘柄の株価を取得して
取得するだけではつまらないので
株価が設定した条件を満たしたらメールで通知するようにしてみよう。

証券会社のサイトにも似たような機能があるとは思うが
プログラムを書けばより高度な条件も設定できる。

環境

PythonPython Releases for Windows | Python.orgから
Python 3.7.3 - March 25, 2019」の「Windows x86-64 executable installer」をダウンロードしてインストールしている。

株価の取得

株探(かぶたん)からBeautifulSoup4を使ってスクレイピングで株価を取得する。

ある銘柄の株価は例えば銘柄コード「7860」であれば
https://kabutan.jp/stock/kabuka?code=7860
というURLから見られるようになっている。

そこでここから株価を得るクラスを「stock_price.py」としてつくる。

from bs4 import BeautifulSoup
from urllib import request
import datetime
from dataclasses import dataclass


@dataclass
class Ticker:
    code: str
    price: int
    time: datetime.datetime
    opening_price: int
    high_price: int
    low_price: int
    closing_price: int
    from_the_day_before: int
    from_the_day_before_percentage: float
    volume: int


class StockPrice:
    @staticmethod
    def get_ticker(code: str):
        url = 'https://kabutan.jp/stock/kabuka?code=' + code
        response = request.urlopen(url)
        soup = BeautifulSoup(response, 'html.parser')
        response.close()

        # 現在の株価と時刻は画面上部のstockinfo_i1から取得する
        div = soup.find('div', id='stockinfo_i1')
        span = div.find('span', class_='kabuka')
        time = div.find('time')

        # 時刻以外はstock_kabuka0クラスのテーブルから取得する
        table = soup.find('table', class_='stock_kabuka0')
        tbody = table.find('tbody')
        tds = tbody.find_all('td')
        if len(tds) != 7:
            raise RuntimeError('Unexpected HTML Structure.')

        price = int(span.text.replace(',', '').replace('円', ''))
        dt = datetime.datetime.fromisoformat(time.attrs['datetime'])
        opening_price = int(tds[0].text.replace(',', ''))
        high_price = int(tds[1].text.replace(',', ''))
        low_price = int(tds[2].text.replace(',', ''))
        closing_price = int(tds[3].text.replace(',', ''))
        from_the_day_before = int(tds[4].text.replace(',', '').replace('+', ''))
        from_the_day_before_percentage = float(tds[5].text.replace(',', '').replace('+', ''))
        volume = int(tds[6].text.replace(',', ''))

        daily_ticker = Ticker(code, price, dt, 
                                opening_price, high_price, low_price, closing_price, 
                                from_the_day_before, from_the_day_before_percentage, volume)
        return daily_ticker


if __name__ == '__main__':

    print(StockPrice.get_ticker('1775'))

株価が取得できるか実行してみる。

> python .\stock_price.py
Ticker(code='1775', price=1798, time=datetime.datetime(2020, 5, 8, 15, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=32400))), opening_price=1770, high_price=1800, low_price=1740, closing_price=1798, from_the_day_before=33, from_the_day_before_percentage=1.9, volume=11300)

取得できた。

メール通知

続けてメール通知をできるようにする。
メール送信にはGmailアカウントを使うことにする。

今回はGmailアカウントを使って自作プログラムからのメール送信を簡単に行うために
「安全性の低いアプリのアクセス」を許可することにする。

Webブラウザーで以下の操作を行う。

  1. メール送信に使うGmailアカウントでログイン
  2. Googleアカウントの「セキュリティ」→「安全性の低いアプリのアクセス」から安全性の低いアプリのアクセスをオンにする。

「mail_sender.py」としてメール送信を行うクラスをつくる。

import smtplib
from email.mime.text import MIMEText
from email.utils import formatdate
import os


class MailSender:
    def __init__(self, gmail_user, gmail_password, to_address):
        self._gmail_user = gmail_user
        self._gmail_password = gmail_password
        self._to_address = to_address

    def send_mail(self, subject, body):
        msg = MIMEText(body)
        msg['Subject'] = subject
        msg['From'] = self._gmail_user
        msg['To'] = self._to_address
        msg['Bcc'] = ''
        msg['Date'] = formatdate()

        smtpobj = smtplib.SMTP_SSL('smtp.gmail.com', 465, timeout=10)
        smtpobj.login(self._gmail_user, self._gmail_password)
        smtpobj.sendmail(self._gmail_user, self._to_address, msg.as_string())
        smtpobj.close()


if __name__ == '__main__':

    GMAIL_USER = os.environ.get("GMAIL_USER")
    GMAIL_PASSWORD = os.environ.get("GMAIL_PASSWORD")
    TO_ADDRESS = os.environ.get("TO_ADDRESS")

    mail_sender = MailSender(GMAIL_USER, GMAIL_PASSWORD, TO_ADDRESS)

    mail_sender.send_mail('subject', 'body')


メール送信ができるか実行してみるのだがその前に
このプログラムはOSの環境変数からGmeilアカウントと送信先メールアドレスを取得するようになっている。
そこであらかじめ環境変数に値を設定しておく必要がある。
以下の環境変数を設定する。

環境変数 説明
GMAIL_USER メール送信に使うGmailアカウントのメールアドレス
GMAIL_PASSWORD メール送信に使うGmailアカウントのパスワード
TO_ADDRESS 送信先メールアドレス

コマンドプロンプトであれば以下のように設定できる。

> set GMAIL_USER=hoge@gmail.com

PowerShellであれば以下の通り。

> $env:GMAIL_USER="hoge@gmail.com"

準備ができたら実行する。

> python .\mail_sender.py

おそらく初回は送信できない。
その代わりに、メール送信に使うGmailアカウントのGmail受信トレイに
「不審なログインの試みをブロックしました」
といったメールが届いている。
これがまさに自作プログラムからのログインであるということであれば
「心当たりがある」とすることで以降ブロックされないようになる。


簡単な株価の監視

株価の取得とメール送信ができたので
それらを使って簡単な株価の監視をつくる。

一定時間ごとに指定の銘柄の株価の取得を行い
株価が指定の価格以上になったらメール通知するようにする。

一定時間ごとの実行は以下のような単純なものでもいいのだが

if __name__ == '__main__':

    while True:
        update()
        sleep(3)

後で拡張しやすいようにapschedulerのBackgroundSchedulerクラスを使う。
「main_single_code.py」として作成する。

import os
from apscheduler.schedulers.background import BackgroundScheduler
from mail_sender import MailSender
from stock_price import StockPrice
from time import sleep

GMAIL_USER = os.environ.get("GMAIL_USER")
GMAIL_PASSWORD = os.environ.get("GMAIL_PASSWORD")
TO_ADDRESS = os.environ.get("TO_ADDRESS")

mail_sender = MailSender(GMAIL_USER, GMAIL_PASSWORD, TO_ADDRESS)

is_condition_enabled = True
is_exception_raised = False


def update():
    global is_condition_enabled
    global is_exception_raised

    if not is_condition_enabled:
        return

    try:
        ticker = StockPrice.get_ticker('1775')

        if ticker.price >= 100:
            mail_sender.send_mail('株価アラート', '株価が条件を満たしました')
            is_condition_enabled = False

    except Exception as exception:
        mail_sender.send_mail('StockPrice.get_ticker() Exception Alert', f'exception:\n{exception}')
        is_exception_raised = True


if __name__ == '__main__':
    scheduler = BackgroundScheduler()
    scheduler.add_job(update, trigger='interval', seconds=3, max_instances=10)
    scheduler.start()

    try:
        while True:
            if is_exception_raised:
                break

            sleep(0.001)

    except KeyboardInterrupt:
        pass

    scheduler.shutdown()

実行すると銘柄コード「1775」の株価が100円以上になったら
指定のメールアドレスにメールが送られる。

次回はアラートの条件を外部ファイルで設定できるようにする。

Node.jsでJSONを返すHTTPサーバーを作る

最近TypeScriptでWebアプリを作っています。
そのなかで使ったことないけどAPIサーバーにNode.jsという選択はありかもなぁ。
本格的に使わなくともちょっとしたものを作るのに使えたらよさそう。
と思うようになりました。

以下の点でよさそうだと思っています。

  • サーバー側もJavaScriptやらTypeScriptで書ける
  • Web APIで使うJSONと相性がいい
  • 気になっているMongoDBと相性がいい
  • 認証などに使っているFirebaseと相性がいい

ということでちょっと使ってみることに。

本屋でピッタリな書籍を見つけました。これで学びました。

入門Node.jsプログラミング

入門Node.jsプログラミング

  • 作者:Jonathan Wexler
  • 出版社/メーカー: 翔泳社
  • 発売日: 2019/09/25
  • メディア: 単行本(ソフトカバー)

大体わかったので試しに以前Pythonで書いたJSONを返すサーバーをNode.jsで作ってみます。
publicディレクトリーにJSONファイルを置いておいて、例えば「test.json」を置いて
そのファイル名のURL、この例だと「localhost:3000/test」にアクセスすると「test.json」の内容が返ってきます。
Webアプリの開発中にWeb APIのモックなんかに使えるかと思います。

以下手順。

Node.jsのインストール

以下からNode.jsをダウンロードしてインストールします。
Node.js

アプリケーションの初期化

ディレクトリーを作って「npm init」

$ mkdir json_server
$ cd json_server/
$ npm init

各質問にはエンターキーでデフォルトを選んで行けば問題なしですが
「entry point:」に対しては「main.js」を指定します。

ライブラリーのインストール

必要なライブラリーをインストールします。

$ npm install express http-status-codes --save

簡単にWebサーバー、Webアプリが作れるexpressと
HTTPのステータスコードの定義が入ったhttp-status-codesです。

コードの作成

json_server/main.js を以下の内容で作ります。

const express = require("express");
const app = express();

const errorController = require("./controllers/errorController")
const jsonFileController = require("./controllers/jsonFileController")

app.set("port", process.env.PORT || 3000);

app.get(/\/.+/, jsonFileController.showJsonFile);

app.use(errorController.pageNotFoundError); 
app.use(errorController.internalServerError);

app.listen(app.get("port"), () => {
    console.log(`Server is running at http://localhost:${app.get("port")}`);
});

json_server/controllers/jsonFileController.js を以下の内容で作ります。

const fs = require("fs");
const path = require("path");

exports.showJsonFile = (req, res, next) => {
    const dirPath = 'public';
    fs.readdir(dirPath, function(err, files) {
        if (err) {
            next(err);
            return;
        }
        if (err) next(err);
        for (const file of files) {
            if (!fs.statSync(path.join(dirPath, file)).isFile() ||
                path.extname(file) !== ".json") {
                continue;
            }
            if (req.url !== '/' + path.basename(file, path.extname(file))) {
                continue;
            }
            fs.readFile(path.join(dirPath, file), (err, data) => {
                if (err) {
                    next(err);
                    return;
                }
                res.send(JSON.parse(data.toString()));
            });
            return;
        }
        next();
    });
};

json_server/controllers/errorController.js を以下の内容で作ります。
こいつはエラー処理に特に気を使わない場合は不要です。

const httpStatus = require("http-status-codes");

exports.pageNotFoundError = (req, res) => { 
    let errorCode = httpStatus.NOT_FOUND; 
    res.status(errorCode);
    res.send("Page not found");
};

exports.internalServerError = (error, req, res, next) => { 
    let errorCode = httpStatus.INTERNAL_SERVER_ERROR; 
    console.log(`Error occurred: ${error.stack}`);
    res.status(errorCode);
    res.send({ error: error });
};

json_server/public/test.json としてサンプルのjsonファイルを作ります。

{
    "message": "こんにちは"
}
実行

以下のコマンドで実行します。
終了するときは Ctrl+C です。

$ node .\main.js

使い始めのお作法が分かればあとは楽ちんですね。

ヘッドレスChromeCastでYoutube音声をディスプレイ無しで再生

Youtube音声をバックグラウンドでダラダラ流したいなと思いました。

スマホで月額払ってYouTube Premiumでも使えという話ですが
押し入れに眠っているChromeCastを使ってもできそうだと思いました。

普通にChromeCastをディスプレイなりに繋げばいいわけですが
余っているディスプレイもないし、音だけ聞きたいのでディスプレイをわざわざ点けたくない。

ディスプレイ無しでChromeCastでYoutube音声は流せないのか?
いくつかのコネクターの接続でできました。

f:id:kurimayoshida:20190218133345j:plain
完成品(かっこわるい…)

HDMIVGA 変換コネクターをつないで
VGAネクター側に抵抗を差し込むとディスプレイがつながっている扱いになる
という仕組みです。

f:id:kurimayoshida:20190218133321j:plain
VGAネクター側の抵抗

スマホからChromeCastにYoutubeをキャストしてやるとスピーカーからYoutube音声が流れます。
スマホの画面を消しても流れ続けます。

f:id:kurimayoshida:20190218133456j:plain
再生中

以下が材料

Chromecast(第1世代)(他でもいけるはず。リンクは第2世代。)

HDMIとHDMI延長コネクター《メスとメス》

HDMIとHDMI延長コネクター《メスとメス》

f:id:kurimayoshida:20190218133029j:plain
抵抗(75Ω)3本

抵抗を指すと…というところは以下を参考にしました。

qiita.com

試していませんが
HDMIVGA 変換コネクターは以下のほうが小型でよさそうですね…。

また抵抗がむき出しなのは危険なので
55円でダミープラグを作ってVirtualDesktopのデスクトップを広げた話 - Qiita
のようにコネクターにしたほうがいいですね。

出来合いのものもあるようですが抵抗つけただけでできると知ると買う気が起きませんね…。

レーザーカッターでラズパイケースをつくる

以前100均素材でラズパイケースをつくったが
最近はもっぱらMDFボードをレーザーカッターで切ってつくっている。

こういうものには3Dプリンターだろうと思って試したのだが
現状では設計時間もコストも工作時間も精度も
レーザーカッターの方がいいという結論に。
箱状のものだからなのだとは思う。

自分のつくりかたを紹介する。

まずおおまかな設計。
今回はラズパイZeroの公式ケースがそのまま収まるケースを作る。

raspberry pi zero size」などでWeb検索してサイズの入った図面を手に入れ
サイズを把握する。

以下のサイトでベースとなる図面を作成する。

www.makercase.com

2.5mm厚のMDFを使う予定なので「Material Thickness」は2.5mmに。
「Generate Laser Cutter Case Plans」でsvgファイルを出力する。

f:id:kurimayoshida:20190202214854p:plain

このSVGIllustratorなどで編集して
カメラ用の穴やUSB端子用の穴を加えたりする。

正しい位置に穴を加えるために
前述のサイズの入った図面を使ったり
実際に公式ケースのサイズを測ったりする。
サイズを測るのにはデジタルノギスが便利。

できた図面。

f:id:kurimayoshida:20190202214838p:plain

図面ができたら2.5mm厚のMDFボードを買う。
大きなホームセンターに行けば手に入ると思う。
自分は近くのUnidy等で買う。
大きなものを買ってn等分にカットしてもらうと割安に。

f:id:kurimayoshida:20190202214924j:plain

あとはMDFボードをレーザーカッターで図面に従ってカットする。

レーザーカッターを時間貸ししている施設で行う。
自分はFabLabをよく使っている。

f:id:kurimayoshida:20190202214907j:plain

図面と素材を持っていくとレーザーカッターでカットしてくれるという場所もある。
自分はCAINZ工房を一度使ったことがある。

CAINZというホームセンター内にレーザーカッターがあり
そこで買ったMDFならカットしてくれる。
量産するならば割安になると思う。

f:id:kurimayoshida:20190202214903j:plain

カットできたら組み立てる。

f:id:kurimayoshida:20190202214928j:plain

ネジで止めて、お得意の100均の三脚をつける。

f:id:kurimayoshida:20190202214938j:plain

ちなみに
こちらの「ラズパイZero公式ケース用ケース(三脚付き)」を
2/10の同人ハードウェアフェスにて販売予定です。
よろしければお越しください。
mag.switch-science.com

ポケモンGOフレンド。

数ヶ月前のこと。

ポケモンGOのフレンドが
同僚のおっちゃん1人しかいなかったので
なんとなくフレンド募集掲示板に書き込まれていた
フレンドコード何件かにフレンド申請を送ってみた。

程なくして数人から承認が。
めでたくフレンドが増えたと思ったのもつかの間
何回かプレゼント交換が済むと次々と縁切りされてしまった。

そんな中1人残った方が。

1ヶ月間くらいか。

その方は、島根県松江の方らしく
毎日松江のお寺なんかの心休まる風景(ポケストップ画像)が
送られてくる。

僕はというと
気を抜くと家の前の公園で満たされてしまうプレゼントの中から
マシな風景(それでも通勤経路の風景なのだが)を
見繕って送る日々。

そんな日が続いた。

そしてある日、いつものようにプレゼントを開けてみると
そこにはいつもと違う風景が。

「松江空港」

え、空港? …旅行? どこへ…?

こっちに来る? いや、あるはずない、妄想も大概に…

顔も名前も知らない
毎日ポケストップ画像をやり取りするだけの関係。

そして翌日送られてきたのは

「ディズニーリゾート」

なんかこう、わくわく感が伝わってくる。
ポケストップ画像だけでこの表現力。

やるじゃんか…!

…って、そういうゲームじゃねえからこれ!

Chromebook で chromebrew を試す。

最近 Visual Studio Code を使っていて、Chromebook版もあるということを知り試してみた。

Visual Studio Code for Chromebooks and Raspberry Pi

見てみると crouton extension を使うもののよう。起動に少し手間がかかる。
インストールして画面が出た時は感動したが
いざ使えてみると他のツールも使いたくなり
結局フルスペックのPCじゃないとだめじゃないかという結論に…。

いやいや、ChromebookChromebook としていいところがあって
それにあと少しだけ、ローカルで、ちょっとしたテキストを打ったり、ちょっとしたプログラムを組んだり、そういったことができればいいんだと気づく。

そんなところに chromebrew というものを見つけた。
Homebrew のような Chrome OS 用のパッケージマネージャー。

GitHub - skycocker/chromebrew: Package manager for Chrome OS

開発者モードで使えるシェル上で、chroutonを使わずに、Linuxでお馴染みの各種ソフトをインストールしたり、使ったりできるようになる。
インストールは簡単。

  1. Chromebookデベロッパーモードにする
  2. Chrome 上で ctrl+alt+t でcroshシェルのタブを開き
  3. shell と打ってエンターキーでデベロッパーシェルを開く
  4. 以下のコマンドを打ってエンターキーでインストール実行
curl -Ls git.io/vddgY | bash

wgetを使ったインストールは最新の Chrome OS にはwgetコマンドがなく使えず

Windowsな人に優しいキーバインドのターミナルベースのテキストエディター「micro」や「Pytyon3」「openssh」なんかをインストールした。

デベロッパーシェル上で以下を実行すればそれぞれインストールできる。

crew install micro
crew install python3
crew install openssh

なかなかいいじゃないか。

Selenium+Python+PhantomJSでWebサイトのスクレイピング

先回の続き。

操作対象のWebブラウザーChromeからPhantomJSに変える。

PhantomJSはGUIを持たない、いわば透明なブラウザー
これにしてやると、動作中に画面を占領されないし
コマンドラインから起動できてサーバー上でのバッチ実行などにも使えるようになる。

PhantomJSインストール

http://phantomjs.org/download.html からダウンロードしてパスを通しておく。

操作用Pythonスクリプトの作成

先回とほぼ同じ。
webdriverの作成の部分だけ変わる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import codecs
import time

from selenium import webdriver
from selenium.common.exceptions import TimeoutException

from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By

WAIT_SECOND = 30

USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36"

if __name__ == '__main__':

    driver = webdriver.PhantomJS(
        service_args=['--ignore-ssl-errors=true', '--ssl-protocol=TLSv1'], 
        desired_capabilities={'phantomjs.page.settings.userAgent': USER_AGENT})
    driver.set_window_size(1280, 1024)

    driver.get('https://google.co.jp/')

    # 検索キーワードとエンターキーを入力
    t=driver.find_element_by_id('lst-ib')
    t.send_keys(u'てけとーぶろぐ\n')

    # 要素の表示待ち
    WebDriverWait(driver, WAIT_SECOND).until(
        EC.visibility_of_element_located((By.CLASS_NAME, '_Rm')))

    # リンクをクリック
    b=driver.find_element_by_xpath('//*[@id="rso"]/div/div/div[1]/div/div/h3/a')
    b.click()

    # 要素の表示待ち
    WebDriverWait(driver, WAIT_SECOND).until(
        EC.visibility_of_element_located((By.CLASS_NAME, 'entry-title-link')))

    # スクリーンショットの保存
    driver.save_screenshot("ss.png")

    # ソースの書き出し
    file_name = 'test.html'
    with codecs.open(file_name, 'a', 'utf_8') as f:
        f.write(driver.page_source)

    driver.close()

ポイント

service_args

CentOSにおいてサイトによってはこの設定をしないと表示できなかったので指定する。

userAgent

今回はChromeと同じユーザーエージェントを設定している。
サイトによってはこの設定をしないとChromeでの表示結果と変わってしまうので指定する。

スクリーンショット

以下のコードでスクリーンショットを画像ファイルとして保存できる。

driver.save_screenshot("ss.png")

見えないブラウザーなので状況把握に便利。