パパエンジニアのポエム

奥さんと娘ちゃんへの愛が止まらない

Elixir - Phoenixで株価を表示する(後編)

前回の続き。
今回は、Volume(日別出来高ランキング)ページを実装する。

VolumeController

引数に受けた文字列をDateにキャストする。
その値を使いDBからデータを取得する。
ポイントは where句ではピン演算子を使うこと。
んでそのままテンプレートに@itemsとして渡す。
web/controllers/volume_controller.exが以下。

defmodule StockScraping.VolumeController do
  use StockScraping.Web, :controller

  def index(conn, %{"date" => date}) do
    target_date = case Date.from_iso8601(date) do {:ok, value} -> value end
    items = StockScraping.YahooVolume |> where(date: ^target_date) |> Repo.all
    render conn, "index.html", items: items
  end
end

VolumeView

ヘルパーは今のところ使用していないので、デフォルトのまま。
web/views/volume_view.exが以下。

defmodule StockScraping.VolumeView do
  use StockScraping.Web, :view
end

VolumeTemplate

コントローラーから受け取った@itemsレンダリングする。
web/templates/volume/index.html.eexが以下。

<h3>出来高増加率ランキング</h3>
<ul>
  <%= for item <- @items do %>
    <li>
      <%= item.ranking %> - <%= item.name %>
    </li>
  <% end %>
</ui>

これで一旦表示出来た。
この時点で月別カレンダーと日別出来高ランキングが表示されるようになった。

Elixir - Phoenixで株価を表示する(前編)

MySQLに入っている株価データを表示する。
ページ構成としてはHome(月別カレンダー)とVolume(日別出来高ランキング)の2つ。
今回はHomeだけ。

Routing

ルーティングでのポイントは、HomeControllerへのルートは引数有り無しのに種類用意すること。
web/router.exが以下。

defmodule StockScraping.Router do
  use StockScraping.Web, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  scope "/", StockScraping do
    pipe_through :browser

    get "/", HomeController, :index
    get "/:date", HomeController, :index

    get "/volume/:date", VolumeController, :index
  end
end

HomeController

ここでは引数で受けた文字列をDateにキャストしている。
引数がない場合は、現在時刻を使用する。
(キャスト方法は本当にこれであってるんだろうか…)

render conn, "index.html", items: itemsとすることでテンプレートで@itemsを参照可能になる。
web/controllers/home_controller.exの最終形が以下。

defmodule StockScraping.HomeController do
  use StockScraping.Web, :controller

  def index(conn, params) do
    target_date = case params do
      %{"date" => date} -> case Date.from_iso8601(date) do {:ok, value} -> value end
      _ -> now()
    end

    items = Repo.all(StockScraping.YahooVolumeDate)
      |> Enum.filter(fn(x) ->
        x.date.year == target_date.year
        && x.date.month == target_date.month
      end)

    render conn, "index.html", items: items
  end
end

HomeView

ViewへはDateをstringに変換する関数を作成し、テンプレートから呼び出す。
あえてパターンマッチングで実装してみた。
web/views/home_view.exが以下。

defmodule StockScraping.HomeView do
  use StockScraping.Web, :view

  def convert(date) do
    case Timex.format(date, "{YYYY}-{0M}-{D}") do {:ok, value} -> value end
  end
end

HomeTemplete

UIおいといてとりあえず表示させる。
ポイントはvolume_path(@conn, :index, convert(item.date))
VolumeControllerのIndexアクションへのURLをクエスパラメータ付きでレンダリングする処理。
Routingのget "/volume/:date", VolumeController, :indexでルートを追加しないと例外でる。

<h3>カレンダー</h3>
<ul>
  <%= for item <- @items do %>
    <li>
      <a href="<%= volume_path(@conn, :index, convert(item.date)) %>"><%= item.date %></a>
    </li>
  <% end %>
</ul>

これで一旦株価を更新した日付の一覧を表示できた。

Elixir - Phoenixでページを作成する

今回はページを作成していく。
Routing Action View Templateを理解する。

Routing

web/router.exHomeControllerを追加する。

defmodule StockScraping.Router do
  use StockScraping.Web, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  scope "/", StockScraping do
    pipe_through :browser
    get "/", HomeController, :index
  end
end

Action

HomeControllerを追加する。
web/controllers/home_controller.exを新規作成。

defmodule StockScraping.HomeController do
  use StockScraping.Web, :controller

  # connはPlug.Conn構造体
  def index(conn, _params) do
    render conn, "index.html"
  end
end

View

Viewはヘルパー関数みたいなものらしい。
web/views/home_view.exを追加する。

defmodule StockScraping.HomeView do
  use StockScraping.Web :view

  def title do
    "株出来高増加率ランキング"
  end
end

あえてtitleという関数を定義している。
これでテンプレートで使用可能となる。

Template

HTMLテンプレートのこと。
web/templates/home/index.html.eexを新規追加。
HomeViewに追加したtitle/0を呼び出す。

<p><%= title %></p>

webサーバーを起動させる。

mix phoenix.server

http://localhost:4000 にアクセスするとtitleのみが表示される。

Elixir - Phoenixのセットアップ

このブログで書いたように、
Elixir習得のために作り始めた株価スクレイピングアプリは当初データ収集のみの想定だった。
でも思いの外気合のみで作れてしまい、まるで言語を習得できていないのでWebアプリにしようと思う。
定期的にデータをスクレイピングし、それをHTMLで表示するアプリをAWSで運用する。

ElixirでWebアプリといったら Phoenix らしい。
にしても作者完全にFF好きだな、ElixirPhoenixて。

Phoenix のインストー

Installation · Phoenix

Hex のインストー

mix local.hex

Phoenix のインストー

mix archive.install https://github.com/phoenixframework/archives/raw/master/phoenix_new.ez

node と npm のインストー

sudo apt-get install -y nodejs npm

sudo npm cache clean
sudo npm install n -g

sudo n stable
sudo ln -sf /usr/local/bin/node /usr/bin/node

# 古いnodejs npm削除
sudo apt-get purge -y nodejs npm

inotify-tools のインストー

Linuxのみ inotify-tools をインストールする必要があるらしい。

sudo apt-get install inotify-tools

MySQL の設定

Using MySQL · Phoenix
databaseオプションをmysqlにしphoenixプロジェクト作成。

mix phoenix.new stock_scraping --database mysql

configファイルを編集

config/dev.exsconfig/test.exsconfig/prod.secret.exsを、
自分のMySQLの環境に合わせて修正する。

config :stock_scraping, StockScraping.Repo,
  adapter: Ecto.Adapters.MySQL,
  database: "stock_scraping",
  username: "ytoida",
  password: "",
  hostname: "localhost",
  port: 3306,
  pool_size: 10

Repo の作成

コンパイルして、StockScraping.Repoを作る。

mix do deps.get, compile
mix ecto.create

Webサーバー起動

ここまできたらWebサーバーを起動できるはず。
起動してみる。

mix phoenix.server

すると、

[info] Running StockScraping.Endpoint with Cowboy using http://localhost:4000
12 Jun 17:20:46 - info: compiled 6 files into 2 files, copied 3 in 3.8 sec

と出力された。
どうやら http://localhost:4000 でアクセス出来るらしい。
Chromeからアクセスして、Welcomeページ出たらOK牧場

MySQL(Local)をエクスポートしMySQL(AWS)にインポートする

環境が整ったので、LocalのあるMySQLのデータをAWSに移行する。

MySQL(Local)エクスポート

mysqldumpコマンドを実行すると実行ディレクトリにエクスポートファイルが作成される。
さっそくコマンドを実行。

mysqldump -u[ユーザー名] -p[パスワード] -r [バックアップファイル名] --single-transaction [データベース名]

エクスポートファイルをEC2に転送

scpコマンドで転送する。
これでルートディレクトリにエクスポートファイルが転送される。

chmod 400 ec2.pem

scp -i ec2.pem [ファイル名] [EC2ユーザー]@[EC2ホスト]:~

MySQLAWS)インポート

さきほど転送されたエクスポートファイルをインポートする。

mysql -u[ユーザー名] -p[パスワード] -h[ホスト] [データベース名] < [エクスポートファイル名]

あとはMySQLにログインして対象データベースがインポートされているか確認してみる。

AWSの無料枠内でEC2とMySQLを起動する

昨日飲み過ぎ+寝不足で今日はElixir触れなかった、こんちくしょう。
ということでAWSの設定は終わったのでそれを書く。

IAMの設定

IAM とは - AWS Identity and Access Management
Linuxでrootユーザー使いながら作業しないし、Windowsでadministratorで作業しないよねという話。
adminグループとadminユーザーを作成し、管理者権限を付与した。
作成完了。

請求アラームの設定

請求アラームの作成 - AWS 請求情報とコスト管理
12ヶ月の無料枠を使って運用するので、1$でも発生したらすぐアラートをメールに送る。
0$ < 過去6時間での請求額を閾値に設定。

EC2

この記事を参考に無料枠ないでインスタンス起動。
sil.hatenablog.com SSH接続時のIP許可は後ほど設定する。

MySQL

この記事を参考に無料枠ないでry。
qiita.com
EC2からしかアクセス出来ないようにセキュリティグループを組むこと。

AWS CLI のセットアップ

AWS Command Line Interface のインストール - AWS Command Line Interface
pipでインストールするらしい。

sudo apt-get install -y python-pip
sudo pip install awscli
pip install --upgrade --user awscli

続いてConfig設定。

$ aws configure
AWS Access Key ID [None]: ***
AWS Secret Access Key [None]: ***
Default region name [None]: ap-northeast-1
Default output format [None]: json

これでSSH接続可能。
インスタンス情報を表示してみる。

aws ec2 describe-instances

載せないけどちゃんと表示された。

SSH 接続

まずはec2サーバーへのSSH接続を許可する。
以下を見ながらローカルのIPアドレスからのSSH接続を許可する。
Linux インスタンス用の受信トラフィックの認可 - Amazon Elastic Compute Cloud
許可したらターミナルからSSH接続できるようになる。

ssh -i "ec2.pem" ec2-user@****
       __|  __|_  )
       _|  (     /   Amazon Linux AMI
      ___|\___|___|

こんな出力がされたら成功。

ElixirでMySQLの株価をUpdate

前回の続き。
今度はMySQLのデータを更新してみる。
基本文法でちょっと時間かかった、やはりまずは基礎を勉強してからやるべきだったかも…。

文字列をintgerやfloatやdateに変換する

String – Elixir v1.4.4
Getting Started – timex v3.1.15
この辺みながらスクレイピングした値をパースする。

To integer

str |> String.to_integer()
** (ArgumentError) argument error
    (stock_scraping) lib/stock_scraping.ex:18: StockScraping.parse/1
    (elixir) lib/kernel/cli.ex:76: anonymous fn/3 in Kernel.CLI.exec_fun/2

はい死亡。
よくよくみると文字列が123,456,000のように区切り文字が入っている。
他にやり方はあるかもしれないが、何も考えずにリプレイスする。

str |> String.replace(",", "") |> String.to_integer()

無事成功。
このパイプライン演算子(|>)は慣れると結構気持ちいいかもしれない。

To float

こちらは最初から区切り文字をリプレイス。

str |> String.replace(",", "") |> String.to_float()
** (ArgumentError) argument error
    (stock_scraping) lib/stock_scraping.ex:18: StockScraping.parse/1
    (elixir) lib/kernel/cli.ex:76: anonymous fn/3 in Kernel.CLI.exec_fun/2

はい死亡。
文字列が小数になってないとダメらしい、どないしよ。
Float – Elixir v1.4.4
Float.parse/1でいけそうだ。

** (ArgumentError) argument error

はい、死亡。
よくみると結果がTuple({123456000.0, ""})で返ってきてた。
Tupleへのアクセスはelem/2で行うらしい。

str |> String.replace(",", "") |> String.to_float() |> elem(0)

これで無事パースできた。

To date

Timex使って特に問題なくパース出来た。

year = Enum.at(list, 0) |> String.to_integer
month = Enum.at(list, 1) |> String.to_integer
day = Enum.at(list, 2) |> String.to_integer
date = Timex.to_date({year, month, day})

MySQLのデータを更新する

更新対象のデータを全件取得する(DBへの通信を1回で済ませるため)

この時気をつけるのは、WHERE句にはピン演算子を使うひつようがあるということ。
理由はよくわからない、後で調べる。

targets = StockScraping.YahooVolume |> where(date: ^date) |> all

上記全データから対象PKデータを取得する

ランキング1位のデータを取得。これは例。

target = targets |> Enum.filter(fn(x) -> x.ranking == 1 end) |> hd

変更したいフィールドと値でmapを作る

変更部分をmapにする感じ。
実際の処理は後述。

params = build_tuple(x, date)

Changesetを作成しUpdate

Ecto.Changeset – Ecto v2.1.4
対象データ(target)と変更map(params)を使ってchangesetを作りupdate

changeset = change(target, params)
update(changeset)

これでMySQLのデータを更新できた。

現時点のまとめ

  • 株価サイトをスクレイピングして
  • 更新日付を見てデータがなければMySQLにINSERT
  • 更新日付を見てデータがあればMySQLにUPDATE

なアプリケーションが出来上がった(はず)。
次回はAWSを使ってデイリーで実行するよう自動化してみる。
最終的なビジネスロジックが書かれているコードはこちら。
近々GitHubにあげる。

defmodule StockScraping do
  use Timex
  import StockScraping.Repo
  import Ecto.Query
  import Ecto.Changeset

  def main(args) do
    HTTPoison.get!("https://info.finance.yahoo.co.jp/ranking/?kd=33&mk=2&tm=d&vl=b")
    |> parse
  end

  defp parse(%{status_code: 200, body: body}) do
    volume_elements = Floki.find(body, "tr.rankingTabledata")
    date_elements = Floki.find(body, "div.dtl")
    date = parse_date(date_elements)

    if StockScraping.YahooVolumeDate |> get_by(date: date) do
      targets = StockScraping.YahooVolume |> where(date: ^date) |> all

      # UPDATE MySQL
      update_volume(volume_elements, date, targets)
    else
      local = Timex.local()
      naive_datetime = NaiveDateTime.new(DateTime.to_date(local), DateTime.to_time(local)) |> elem(1)
      IO.inspect(naive_datetime)
      %StockScraping.YahooVolumeDate
      {
        date: date,
        add_date: naive_datetime,
        updt_date: naive_datetime
      }
      |> insert()

      # INSERT MySQL
      add_volume(volume_elements, date)
    end
  end

  defp parse_date(elements) do
    date_list = elements
    |> Enum.at(0)
    |> elem(2)
    |> Enum.at(1)
    |> String.replace(~r/[^0-9]/, ",")
    |> String.split(",", trim: true)

    year = Enum.at(date_list, 0) |> String.to_integer
    month = Enum.at(date_list, 1) |> String.to_integer
    day = Enum.at(date_list, 2) |> String.to_integer
    Timex.to_date({year, month, day})
  end

  defp update_volume(elements, date, targets) do
    elements |> Enum.each(fn(x) ->
      params = build_map(x, date)
      ranking = params[:ranking]
      target = targets |> Enum.filter(fn(x) -> x.ranking == ranking end) |> hd
      changeset = change(target, params)
      update(changeset)
    end)
  end

  defp add_volume(elements, date) do
    entities = elements |> Enum.map(fn(x) -> build_map(x, date) end)
    insert_all(StockScraping.YahooVolume, entities)
  end

  defp build_map(value, date) do
    element = elem(value, 2)
    %{
      date: date,
      ranking: get_value(element, 0) |> String.to_integer(),
      code: get_code(element) |> String.to_integer(),
      market: get_value(element, 2),
      name: get_value(element, 3),
      price: get_value(element, 5) |> String.replace(",", "") |> Float.parse() |> elem(0),
      volume: get_value(element, 6) |> String.replace(",", "") |> String.to_integer(),
      volume_average: get_value(element, 7) |> String.replace(",", "") |> String.to_integer(),
      volume_rate: get_value(element, 8) |> String.replace(",", "") |> Float.parse() |> elem(0),
    }
  end

  defp get_value(element, index) do
    Enum.at(element, index)
    |> elem(2)
    |> hd
  end

  defp get_code(element) do
    Enum.at(element, 1)
    |> elem(2)
    |> Enum.at(0)
    |> elem(2)
    |> hd
  end
end