BitflyerのAPIを使用して、Bitcoin自動売買プログラムを作成した

Bitコインの自動売買プログラムを作った。注文のトリガーには移動平均を用いて、長期の移動平均が、短期の移動平均を上回ったとき、指値で購入する。購入に成功したら、同時に0.4%上乗せして売り注文を出す。

  • 参考にしたサイト

zero-cheese.com

import requests
import time
import pandas as pd
import traceback
import hashlib
import hmac
import json

# ビットフライヤーのAPIキーとシークレット
API_KEY = '*****'
API_SECRET = '*****'

# APIエンドポイント
API_URL = 'https://api.bitflyer.jp'

# 注文情報
order_quantity = 0.01  # 購入および売却するBTCの数量
product_code = 'BTC_JPY'  # トレード対象の通貨ペア

# 移動平均の期間設定
short_window = 10
long_window = 20

file_name = "data.csv"

try:
    df = pd.read_csv("data.csv")
except:
    print("failed read csv")
    df = pd.DataFrame(columns=['timestamp', 'best_bid', 'best_ask'])

def get_market_price():
    # 現在の市場価格を取得
    ticker_url = f'{API_URL}/v1/ticker?product_code={product_code}'
    response = requests.get(ticker_url)
    data = response.json()
    print(data)
    return float(data['ltp'])

def get_moving_averages():
    global df
    # 移動平均を計算
    historical_data_url = f'{API_URL}/v1/getticker?product_code={product_code}'
    response = requests.get(historical_data_url)
    data = response.json()
    new_data = {
        'timestamp': data['timestamp'], 
        'best_ask': [float(data['best_ask'])],
        'best_bid': [float(data['best_bid'])],
        }
    print(data)
    new_data = pd.DataFrame(new_data)
    new_data['timestamp'] = pd.to_datetime(new_data['timestamp'])
    df = pd.concat([df, new_data])
    try:
        short_ma = df['best_bid'].rolling(window=short_window).mean().iloc[-1]
        long_ma = df['best_bid'].rolling(window=long_window).mean().iloc[-1]
    except:
        short_ma = 0
        long_ma = 0
        pass
    print(short_ma, long_ma)
    return short_ma, long_ma

def place_market_order(side, quantity):
    # 成行注文を出す
    endpoint = '/v1/me/sendchildorder'
    order_url = API_URL + endpoint

    order_data = {
        'product_code': product_code,
        'child_order_type': 'MARKET',
        'side': side,
        'size': quantity,
    }
    body = json.dumps(order_data)
    headers = header('POST', endpoint=endpoint, body=body)

    response = requests.post(order_url, headers=headers, data=body)
    return response.json()

def place_limit_order(side, price, quantity):
    # 指値注文を出す
    endpoint = '/v1/me/sendchildorder'
    order_url = API_URL + endpoint

    order_data = {
        'product_code': product_code,
        'child_order_type': 'LIMIT',
        'side': side,
        'price': price,
        'size': quantity,
    }
    body = json.dumps(order_data)
    headers = header('POST', endpoint=endpoint, body=body)

    response = requests.post(order_url, headers=headers, data=body)
    return response.json()


def header(method: str, endpoint: str, body: str) -> dict:
    timestamp = str(time.time())
    if body == '':
        message = timestamp + method + endpoint
    else:
        message = timestamp + method + endpoint + body
    signature = hmac.new(API_SECRET.encode('utf-8'), message.encode('utf-8'),
                         digestmod=hashlib.sha256).hexdigest()
    headers = {
        'Content-Type': 'application/json',
        'ACCESS-KEY': API_KEY,
        'ACCESS-TIMESTAMP': timestamp,
        'ACCESS-SIGN': signature
    }
    return headers


def get_open_orders():
    # 出ている注文一覧を取得
    endpoint = '/v1/me/getchildorders'
    
    params = {
        'product_code': 'BTC_JPY',
        'child_order_state': 'ACTIVE',  # 出ている注文だけを取得
    }
    endpoint_for_header = endpoint + '?'
    for k, v in params.items():
        endpoint_for_header += k + '=' + v
        endpoint_for_header += '&'
    endpoint_for_header = endpoint_for_header[:-1]
    
    headers = header('GET', endpoint=endpoint_for_header, body="")

    response = requests.get(API_URL + endpoint, headers=headers, params=params)
    orders = response.json()
    return orders

while True:
    try:
        # 現在の市場価格と移動平均を取得
        current_price = get_market_price()
        short_ma, long_ma = get_moving_averages()
        df.to_csv(file_name, index=False)

        # 注文が出されていない場合に短期移動平均が長期移動平均を上回った場合に購入
        orders = get_open_orders()
        print(len(orders))
        if (short_ma > long_ma) and len(orders) == 0:
            print("短期移動平均が長期移動平均を上回りました。購入注文を出します。")
            order_response = place_market_order('BUY', order_quantity)
            print("注文結果:", order_response)

            # 注文が成功したら約定価格に0.4%加算して売り注文を出す
            if 'child_order_acceptance_id' in order_response:
                executed_price = int(current_price * 1.004)  # 約定価格に0.4%加算
                print(f"約定価格: {executed_price}")
                sell_response = place_limit_order('SELL', executed_price, order_quantity)
                print("売り注文結果:", sell_response)

                # 買い注文が出されたフラグを立てる
                buy_order_placed = True

        # 売り注文が出されている場合は、一定の間隔で確認
        elif buy_order_placed:
            # ここに必要な確認処理を追加
            pass

        # 一定の間隔でトレードを実行
        time.sleep(60)  # 60秒ごとに実行
    except Exception as e:
        print(f"エラーが発生しました: {e}")
        print(traceback.format_exc())
        time.sleep(60)  # エラーが発生した場合も60秒待ってから再試行

WSL2でSSHサーバを立てて外部からアクセスできるようにする手順

  1. 以下を参考に、WSL2をインストール and-engineer.com

  2. 以下を参考に、Systemdを有効にする。 qiita.com wsl.confに以下を記入する。 [boot] systemd=true

  3. 以下を参考に、TCPの22番ポートを開放する。 https://support.borndigital.co.jp/hc/ja/articles/360002711593-Windows10%E3%81%A7%E7%89%B9%E5%AE%9A%E3%81%AE%E3%83%9D%E3%83%BC%E3%83%88%E3%82%92%E9%96%8B%E6%94%BE%E3%81%99%E3%82%8B

  4. コマンドプロンプトを管理者権限で開き、以下を実行する。ip_hostはホスト側のIPアドレス(ipconfigで調べられるIPアドレス)。ip_wslはWSL側のIPアドレス(WSLでip aコマンドで調べられるアドレス) netsh.exe interface portproxy add v4tov4 listenaddress=${ip_host} listenport=22 connectaddress=${ip_wsl} connectport=22

  5. WSL にSSHをインストール sudo apt install ssh

これで外部からSSHでアクセスできるようになる。

Django+herokuで本棚アプリ作って公開してみた

Djangoを一通り勉強してWebアプリ(っぽいもの)が作れるようになったので、復習がてら手順を一通りまとめてみました。

作ったもの

本を本棚にコメント付きでしまっておけるアプリです。

本記事では個人情報のない部分を取り出し、開発の一連の流れをまとめました。本検索にはOpenBDを使用して、ISBNコードという本を一意に識別できるコードで検索し、タイトルを自動取得します。

ISBNコードは楽天Booksで検索すると分かります。上部のCREATEから作成フォームに移動し、LISTから今まで作成されたものを見ることができます。

ソースコードは以下を参照してください。

https://github.com/wooolwooolwoool/django_test

注意点としては、Heroku無料版は30分操作がないとサーバーがスリープしてしまいます。そのためフォーム入力に30分以上かけるとエラーになります。20分ごとにWgetするシェルスクリプトを起動しておけばスリープしない、ということもできます。

他に本検索APIとしては他に楽天BooksAPIがあります。それを使用すればISBNコードだけでなく、タイトル、筆者等でも検索することができます。

やったこと

環境構築して、Djangoでアプリを作って、アフィリエイト広告をつけて、Herokuで公開するところまで行いました。Herokuでの操作はGUI上で行いました。(GUI操作を説明しているサイトが少なかったため)アプリの作成から公開まで一連のことをざっとやった感じです。

参考サイト

https://qiita.com/okoppe8/items/54eb105c9c94c0960f14

開発環境

これらは予めインストールしておいてください

環境構築

※この環境構築は自分の環境に合わせて変更してください。Djangoをインストールして、ターミナルが使える状況にあればOKです。

公開してあるGitHubからコードをクローンする。

$ git clone https://github.com/wooolwooolwoool/django_test

DockerfileからDockerイメージを作成する。

$ cd doc/
$ docker build -t book:1.0 .

Dockerコンテナを起動する。このとき$HOME/bookは成果物を保存する場所なので、自由に変更してOK。--nameも変更してOK。

$ docker run --net host -v $HOME/book:/workspace:rw --name book -d book:1.0 bash

プロジェクの作成、準備

VSCodeを起動して、Remote exprolerからさっき作成したDockerコンテナを新しいウィンドウで開きます。 そのウィンドウでターミナルを開き、下記コマンドでプロジェクトを作成します。今回はDjangoチュートリアルに習ってmysiteという名前で作成します。一番後ろのピリオドを忘れるとmanage.pyが作られないので注意です。

$ django-admin startproject mysite .

同じディレクトリにmanage.pyとmysiteができたことを確認します。このmysiteが大元になります。 また今回作成するアプリを追加します。今回はbookという名前です。

$ python manage.py startapp book

同じディレクトリにbookができたことを確認します。

ライブラリインストール

bootstrap4などが必要になるのでインストールします。(Dockerfileを修正したので大丈夫だと思いますが念のため。)

$ pip install dj_database_url django_heroku bootstrap4 requests gunicorn

設定

言語、タイムゾーンを変更します。下記のようにmysite/settings.pyのTIME_ZONEとLANGUAGE_CODEを書き換えてください。

TIME_ZONE = 'Asia/Tokyo'
LANGUAGE_CODE = 'ja'

また、作成したアプリ(book)も追加しておきます。

INSTALLED_APPS = [
    (中略)
    #### 以下を追加
    'book.apps.BookConfig',
    'bootstrap4',
]

後ほど追加するテンプレートのフォルダ('book/templates')も追加しておく。

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'book/templates'),],

アプリの作成

モデルの作成

モデルとはデータベースの形を決めるものです。Bookでは本棚とその本のデータを定義します。 本はISBNコードという本を一意に識別できるコードで検索及び追加を行います。(ただし、別の本棚で同じ本を追加される場合があるのでプライマリキーとはしない。) データは以下のようになります。

  • 本棚 (Bookshelf)
    • タイトル (title 文字列 20文字まで)
    • カテゴリ (cat 整数(選択肢))
  • 本 (Book)
    • タイトル (title 文字列 100文字まで)
    • ISBNコード (isbn 文字列 13文字)
    • コメント (comment 文字列 100文字まで 空欄可)

また本は紐づく本棚も必要なので定義しておきます(target)。 コードは以下の通りです。データの形式にあったフィールドを使用します。 空欄OKの場合は引数にblank=True, null=Trueを渡します。 選択肢で選ばせたい場合には、フィールドをPositiveIntegerFieldとして、Choiceに選択肢を渡します。記載方法はコード参照。プライマリキーは今回指定していないので自動で追加されます。pkで参照可能です。 book/models.pyに下記を記載してください。

from django.db import models

#### Create your models here.

category = (
    (0,"指定なし"),
    (1,"マンガ"),
    (2,"小説"),
    (3,"参考書"),
    (4,"実用書"),
    (5,"教科書"),
    (6,"雑誌"),
)

class Bookshelf(models.Model):
    title = models.CharField(verbose_name="本棚タイトル", 
                             max_length=20)
    cat = models.PositiveIntegerField(
        verbose_name="カテゴリ",
        choices=category, default=0)
    def __str__(self):
        return self.title

class Book(models.Model):
    target = models.ForeignKey(
        Bookshelf, verbose_name='紐づく本棚',
        blank=True, null=True,
        on_delete=models.SET_NULL)
    title = models.CharField(verbose_name="本タイトル", 
                             max_length=100)
    isbn = models.CharField(verbose_name="ISBNコード", 
                             max_length=13)
    comment = models.CharField(verbose_name="コメント", 
                               blank=True, null=True, max_length=100)
    def __str__(self):
        return self.title

htmlでページを作る

これから実際にページやコンテンツを作っていきます。その際実際に動かしながら行うと分かりやすいのでその準備をしていきます。

book/templates/book/というディレクトリを作成します。テンプレートはすべてここに保存していきます。この中にbase.htmlというファイルを作成し、ヘッダやフッタなどを記載しておいてください。base.htmlはほぼすべてのページの枠組みとなるものです。内容については、Gitにあるファイルを参考に作ってください。

URL

アクセス時の動作を決定します。最初に参照されるのは、mysite/urls.pyです。この''が参照されます。今回はbookにアクセスしたいのでurlpatternsでbook.urlsをIncludeします。

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('book.urls')),
]

次にbookでアクセスされたときの動作を決めるために、book/urls.pyのurlpatternsを編集します。例えばlist/にアクセスされた場合はviews.BookList.as_view()が実行されます。このとき返り値はHTTPResponse等である必要があります。

urlpatterns = [
    #### トップページ
    path('', views.top, name='top'), 
    #### 本棚一覧
    path('list/', views.BookList.as_view(), name='book_list'), 
    #### 本棚追加
    path('add/', views.add_post, name='add_post'), 
    #### 本棚詳細
    path('detail/<int:pk>/', views.book_detail, name='book_detail'),
    #### 本の検索(ユーザーからはアクセスされない想定)
    path('serch/<str:key>/', views.book_serch, name='book_serch'),
]

入力フォームの作成

モデルで定義した本棚、本を作成する際の入力フォームを作成します。 新たにbook/forms.pyというファイルを作成し、下記のように記載します。 このとき、詳しくは後述しますがBookのtitleとurlは自動入力とするため、フィールドをreadonlyにしておきます。 本棚と本を関連付けるため、forms.inlineformset_factoryでformsetを作成します。引数は親となるモデル、子となるモデル、子モデルの入力フォームを渡します。また、初期の本の冊数を0、最大冊数を10とするため、extra=0, max_num=10を渡しておきます。

from django import forms
from .models import *

class BookshelfForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super(BookshelfForm, self).__init__(*args, **kwargs)
        for field in self.fields.values():
            field.widget.attrs["class"] = "form-control"

    class Meta:
        model = Bookshelf
        fields = ('title','cat')
        widgets = {
                    'title': forms.TextInput(attrs={'placeholder':'タイトル(50字まで)',}),
                    'category': forms.Select(),
                     }

class BookForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super(Book_dataForm, self).__init__(*args, **kwargs)
        for field in self.fields.values():
            field.widget.attrs["class"] = "form-control"
        self.fields['title'].widget.attrs['readonly'] = 'readonly'
        self.fields['isbn'].widget.attrs['readonly'] = 'readonly'

    class Meta:
        model = Book
        fields = ('title', 'isbn','comment')
        widgets = {
                    'title': forms.TextInput(),
                    'isbn': forms.TextInput(),
                    'comment': forms.Textarea(attrs={'placeholder':'コメント(100字まで)','cols': 1, 'rows': 5}),
                }

BookFormset = forms.inlineformset_factory(
    Bookshelf, Book, BookForm, fields='__all__',
    extra=0, max_num=10,
)

入力フォームの作成(ビュー)

入力フォームを表示させる際に呼ばれるビュー関数を作成します。このときmodelsとformsが必要なのでインポートします。 この関数は最初に訪れるときもSubmitしたときも呼ばれます。その際、request.methodによって処理を分岐します。 各フォームは検証してから保存する必要があるので注意してください。 入力フォーム表示時はpost_form.htmlにformとformsetをレンダリングをして表示、Submit後はデータを保存しbook_listにリダイレクトします。このredirectはURLの他にbook/urls.pyで指定したnameデモ指定することができます。

def add_post(request):
    form = BookshelfForm(request.POST or None)
    context = {'form': form}
    if request.method == 'POST' and form.is_valid():
        post = form.save(commit=False)
        formset = BookFormset(request.POST, files=request.FILES, instance=post)
        if formset.is_valid():
            post.save()
            formset.save()
            return redirect('book_list')
        else:
            context['formset'] = formset
    else:
        context['formset'] = BookFormset()
    return render(request, 'book/post_form.html', context)

入力フォームの作成(HTML)

forms.pyを表示するためのページを作成します。レンダリングするフォームは{{ form.as_p }}のようにするとDjangoがいい感じにしてくれます。本を保存するフォームセットは{{ formset.management_form }}としておきます。本は<tbody class="books">の中にどんどん追加されていきます。

<form>
    <h2>本棚を作る</h2>
    <br>
    {{ form.as_p }}

    <h2>本を追加する</h2>
    {{ formset.management_form }}

    <table class="table table-striped">
        <tbody class="books">
        </tbody>
    </table>

    <input class="form-control" id="search-input-1" placeholder="ISBNコードを入力" type="text" name="search-key-isbn">
    {% csrf_token %}
    <a href="####" id="book-serch" class="btn btn-outline-secondary add-book">本を検索</a><br>
    <p id="serch-warn" class="warn"></p><br><br><br>
    <br><hr>
    <h2 class="caption">本棚を作成する</h2>
    <input class="btn btn-primary" id="create" type="submit" formmethod="post" value="本棚の作成"><br>
    <p id="create-warn" class="warn"></p><br><br><br>     
</form>

検索関数の作成

本はISBNコードから本を検索し、本のフォームを追加しタイトルを自動入力します。 検索にはopenBDを使用します。openBDはISBNコードで本の情報を取得できるサービスです。ただしJavaScriptからアクセスすると拒否される場合がほとんどなので、サーバーからPythonでアクセスする方法を取ります。(PHPやNode.jsでもできるそうですがよくわかりませんでした。誰かいい方法あれば教えてください。。)具体的には/serch/{ISBNコード}/にアクセスすると、ISBNコードでopenBDで本の情報を取得するようviews.pyに関数を作成します。openBDはJSON形式で情報を返却するので、それを解析し情報を自動入力します。今回はタイトルとISBNコードのみを取得します。

処理の流れとしては、「本を検索」ボタンが押されると検索が行われて、本が見つかれば入力欄book-templateをフォーム内table table-stripedに追加して、タイトルとISBNコードを自動入力します。見つからなければそのままスルーです。 入力欄のNameは例えばタイトルであればbook_set-__prefix__-titleの様になります。これはDjango側が決めていることなので、実際に作ってみて、F12で確認してみると早いと思います。

<script type="text/html" id="book-template">
    <tr id="book-__prefix__">
        <td class="align-middle rownum"></td>
        <td>
            {{ formset.empty_form.title }}
            {{ formset.empty_form.isbn }}
            {% bootstrap_field formset.empty_form.comment show_label=True %}
            {% bootstrap_field formset.empty_form.DELETE show_label=False field_class="float-right" %}
        </td>
    </tr>
</script>
<script>
    $(function () {
        $('.add-book').click(function (e) {
            e.preventDefault();
            // 検索ワード取得
            var v0 = document.getElementById('search-input-1').value;
            // ISBNコード欄が空なら検索しない
            if (v0 == ""){                
                return;
            }
            var url = '/serch/' + v0 + "/"; // リクエスト先URL                   
            try{                    
                const request = new XMLHttpRequest();
                request.open('GET', url);
                request.send(null);
            }catch(error){
                document.getElementById("serch-warn").textContent = "見つかりませんでした";
                return;
            }
            request.onreadystatechange = function () {
                if (request.readyState != 4) {
                    // リクエスト中
                } else if (request.status != 200) {
                    document.getElementById("serch-warn").textContent = "見つかりませんでした";
                    return;
                    // 失敗
                } else {
                    // 取得成功
                    var result = request.responseText;                    
                    try{                    
                      // 値が取得できているかの確認
                      var obj = JSON.parse(result);
                      var tmp = obj.summary.title;
                      // 本入力欄の追加
                      var count = parseInt($('####id_book_set-TOTAL_FORMS').attr('value'), 10);
                      var tmplMarkup = $('####book-template').html();
                      var compiledTmpl = tmplMarkup.replace(/__prefix__/g, count);            
                      $('tbody.books').append(compiledTmpl);
                      $('####id_book_set-TOTAL_FORMS').attr('value', count + 1);

                      // 本情報(タイトル、ISBNの入力)
                      var tmp = "book_set-__prefix__-title";
                      var tmp = tmp.replace(/__prefix__/g, count);
                      document.getElementsByName(tmp)[0].value = obj.summary.title;
                      var tmp = "book_set-__prefix__-isbn";
                      var tmp = tmp.replace(/__prefix__/g, count);
                      document.getElementsByName(tmp)[0].value = obj.summary.isbn;

                      // 検索フォームを空に
                      document.getElementById('search-input-1').value = "";
                    }catch(error){
                      // 失敗したらメッセージ表示
                      document.getElementById("serch-warn").textContent = "見つかりませんでした";
                      return;
                    };
                };
            };
        });
    });
</script>

先述の通り、/serch/{ISBNコード}/にアクセスしたとき、ISBNコードでopenBDで本の情報を取得するための関数を作成します。中身の解析はJavascriptの方で行うので、取得したものを返すのみです。

import requests
from django.http import HttpResponse
import json

def book_serch(request, key):
    url = "https://api.openbd.jp/v1/get?isbn=" + key
    html = requests.get(url)
    #### 先頭と最後尾にカッコがついているので取って返却
    return HttpResponse(html.text[1:-1])

本棚一覧の表示

今まで作られた本棚の一覧を表示します。本棚の一覧はListViewを使用するとかなり簡単にかけます。ListViewはmoddelをtemplate_nameにレンダリングしてくれます。また、get_querysetで抽出方法やソートを動的に変更でき、Submitされた値を参照してキーワードで検索したりできます。今回は新たに追加された順に取得します。

class BookList(ListView):
    model = Bookshelf
    template_name = 'book/post_list.html'

    def get_queryset(self):
        return Bookshelf.objects.all().order_by("-pk")

本棚一覧の表示(HTML)

Listviewからはモデルがobject_listにレンダリングされて渡されるため、そこからfor文で取り出します。このとき、選択肢で定義していたデータはそのまま渡すとその番号が表示されてしまうため、get***displayのように記載して渡します。

<h2>本棚一覧</h2><hr>
{% for post in object_list %}
<a href="/detail/{{ post.pk }}/">
  <h3>{{ post.title }}</h3>
  <div>
    カテゴリ:{{ post.get_cat_display }}
  </div>
</a><hr>
{% endfor %}

本棚の詳細

本棚の一覧から本棚を選択すると、本棚の詳細を表示します。本棚の詳細を表示するためには本棚の本を取得します。取得はプライマリキーでフィルタをかければOKです。

def book_detail(request, pk):
    bookshelf = Bookshelf.objects.get(pk=pk)
    books = Book.objects.filter(target__exact=pk).all()

    context = {'bookshelf': bookshelf,
               'books': books}
    return render(request, 'book/post_detail.html', context)

本の詳細(HTML)

本棚の詳細を表示するためのhtmlです。safeを入れておくとcommentにHTMLタグを使用することができ、linebreaksbrを入れておくと、その中に改行が含まれていた場合そのとおりに表示してくれます。

<h1>{{ bookshelf.title }}</h1><hr>
<div>カテゴリ:{{ bookshelf.get_cat_display }}</div><br>

<h2>本一覧</h2><br>
{% for book in books %}
    <h3>{{ book.title }}</h3>
    <div>ISBNコード:{{ book.isbn }}</div><br>
    <div>{{ book.comment | safe | linebreaksbr }}</div><br><hr>
{% endfor %}

トップページの作成

トップページを作成します。この内容は自由なので特に記載しません。

def top(request):
    return render(request, 'book/top.html', {})

動作確認

ここまできたら実際にWebサーバーを起動して動作を確認してみましょう。 下記をmanage.pyと同じディレクトリで実行します。

$ python manage.py makemigrations
$ python manage.py migrate
$ python manage.py runserver

うまくいけば下記のようにサーバーが起動するので、書いてあるURLにアクセスしてみましょう。ただし特別な設定をしていない限り同じPCからしか見ることはできません。

Starting development server at http://127.0.0.1:8080/
Quit the server with CONTROL-C.

追加事項

実行に直接必要ありませんが、やっておくといいことを上げておきます。

アフィリエイト広告を付ける

せっかくなのでアフィリエイト広告をつけておきます。今回は登録すれば簡単に使える楽天アフィリエイトを使います。

https://affiliate.rakuten.co.jp/

上記サイトの メニュー→簡単リンク機能 からバナーが簡単に作れます。好きなところに仕込んでおきます。

CSSでデザインを調整

CSSでデザインを調整します。book/static/css/style.cssというファイルを作成し、ヘッダーで読み込んでいます。気になれば触ってみてください。

その他SEO対策

インデクス登録など、SEO対策をやるといいらしいです。このあたりはよくわかっていません。

https://developers.google.com/search/docs/beginner/seo-starter-guide?hl=ja

ページタイトルを動的に変更

ページタイトル(タブに表示されている文字列)はheadのtitleで定義されていますが、このままではすべて同じタイトルになってしまいます。なのでページごとに記載を変更するようにします。まずはheadのtitleを下記のようにblockで囲います。こうすることで、差し替えられなかったときは「Djangoの試作」、差し替えられたときはその文字列が入ります。

<head>
    (中略)
    <title>{% block title %}Djangoの試作{% endblock %}</title>

差し替えるのは簡単で、book/base.htmlを読み込んだページでblockの中身を書き換えます。これでタブに本棚のタイトルが表示されるようになります。descriptonを操作したい場合にも同様です。

{% block title %}
{{ bookshelf.title }} - Djangoの試作
{% endblock %}

Googleの検索結果に表示されたくないページ

ページによってはGoogleなどの検索エンジンから直接来てほしくないときもあると思います。そんなときはタイトルの時と同様にheadの中に<meta name="robots" content="noindex">を仕込んで置きましょう。

<head>
    (中略)
    {% block noindex %}{% endblock %}
{% block noindex %}
<meta name="robots" content="noindex">
{% endblock %}

Herokuへのデプロイ

アプリを作成したので、これを全世界に公開します。利用するサービスはHerokuを使います。Wepアプリ作成で調べればすぐに出てきます。これは基本利用無料の神のようなサービスです。

準備

デプロイ前に設定を変更しておきます。実は今まではDjangoデバッグモードで動いていました。存在しないURLに行こうとすると長々とエラーメッセージが出ていたと思います。ただこれを利用者に見せるわけにも行かないので、設定を変更します。

https://qiita.com/frosty/items/66f5dff8fc723387108c

このサイトを参考に5. 手順(アップロード編)直前まで行います。やったらアップロードしたGitのURLを覚えておきます。ちなみにPuplicでもPrivateでもOKです。正直いうとこのサイトの最後までやってしまってデプロイするというのもありです。今回はHerokuのGUIから操作していきます。GUIから行う利点としては、初心者でも変なことをしない(できない)点や、デプロイやログがスマホから確認できる点がある。慣れればCLIのほうが早い。

Herokuでの操作

  1. Herokuにログインし、New Appを作成します。
  2. Deploy → App connected to GitHubから作成したリポジトリに接続します。
  3. Manual deploy → Deploy BranchでDeployします。
  4. Deployが成功したことを確認します。
  5. 必要があれば、Automatic Deploysを有効にしておきます。
  6. Settings → Config VarsでSECRET_KEYを設定します。
  7. 右上のMore → Run Consoleからpython manage.py migrateを実行します。
  8. 右上のOpen appからアプリを開きます。

エラーの場合

  • requierment.txtが正しくない(作成したあとにライブラリを追加しているなど)場合にはエラーになります。
  • Procfileを忘れている場合もエラーになります。
  • 詳細はMore → Viwe logsからログを確認できます。

OSSをビルドしようとした際、ライブラリが足りないといわれた際の対処法

何かしらOSSをビルドしようとした際、○○というライブラリがないといわれたことはないだろうか。その際、aptを使用すれば簡単にインストールすることができる。

例えばあるライブラリをビルドする際、以下のようなエラーで失敗したとする。

E: libssl is not found

この時、sudo apt install libssl と打ってもそんなパッケージはないと怒られるだけである。libssl1.1というパッケージはあるが、これをインストールしてもビルドすることはできない。その場合は以下のようなコマンドで探すといい。

$ apt list | grep ssl | grep dev

apt listコマンドは、インストール可能なパッケージの一覧を表示してくれる。この中からsslがつくパッケージをGrepする。 そして、この中から -dev とついたそれっぽいパッケージを探す。-dev とついたパッケージは、開発者向けパッケージであり、ビルドに必要なヘッダファイルなどが含まれている。libsslだと libssl-dev パッケージがそれに該当する。

libssl-devをインストール後、ビルドに成功するようになるはず。

【メモ】ソニーグループ合同 データ分析コンペティション

ソニーグループ合同 データ分析コンペティションに参加中。

signate.jp

  • 訓練データとテストデータでサイコロの数が違うので、クラス分類などでは不可。
  • OpenCVの輪郭抽出でサイコロをクロッピング→画像分類→目の数を合計 で行けるのでは?
    • →行けそうではあるが、うまく分離できていない(2つのサイコロが重なっている)箇所が多くある。どうする?
train_y = np.load(data_path + '/y_train.npy')
train_x = np.load(data_path + '/X_train.npy').reshape(-1, 20, 20)

def drow_rect(img):
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # しきい値処理を行って白い領域を検出
    _, thresholded = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY)

    # 輪郭を検出
    contours, _ = cv2.findContours(thresholded, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    # 白い領域を包囲する四角形を描画
    croped = []
    print(len(contours))
    for contour in contours:
        if cv2.contourArea(contour) > 100:  # あるしきい値より大きな領域のみを描画
            x, y, w, h = cv2.boundingRect(contour)
            croped.append(cv2.resize(img.copy()[y:y+w, x:x+w, :], (64, 64)))
            cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)
    return img, croped


def show_img(arr):
    resized_image = cv2.resize(arr, (224,224))
    plt.imshow(resized_image)
    plt.show()

for i, im in enumerate(train_x[:2]):
    org = im.copy()
    im = cv2.cvtColor(im, cv2.COLOR_GRAY2BGR)
    im = cv2.resize(im, (224,224))
    im, croped = drow_rect(im)
    show_img(im)
    for im in croped:
        show_img(im)
    
    show_img(org)
    print("ans: {}".format(train_y[i]))

Google Apps Script で家庭内在庫管理アプリを作った

はじめに

食材やトイレットペーパなどの在庫管理がしたい。との要望を受けて、Google Apps Script で家庭内在庫管理アプリを作った。データはGoogleスプレッドシートに保存し、Webページから更新、確認可能。

なお本アプリは勉強もかねて、ほぼChatGPTで作成した。流れとしては、簡単な仕様で実装してもらう→細かいところを修正→最後に仕様をまとめてもらうといった感じ。以下、仕様。

参考にしたサイト様

www2.kobe-u.ac.jp

tonari-it.com

GoogleスプレッドシートエディタWebアプリ 仕様

概要
機能
  • データ表示: スプレッドシート内のデータ(アイテム名と数量、およびグループ名)をWebページ上のテーブルで表示。

  • 数量の更新:

    • 各アイテムの数量を直接入力フィールドから編集可能。
    • 更新ボタンをクリックすることで、スプレッドシート内のデータが更新される。
  • アイテムの追加:
    • 新しいアイテム名と数量を入力フィールドから入力。
    • 既存のグループを選択、または新しいグループ名を入力してアイテムを追加。
    • アイテム追加ボタンをクリックすることで、スプレッドシートに新しい行が追加される。
  • アイテムの削除:
    • 各アイテムの横に配置された削除ボタンをクリックすることでアイテムを削除。
    • 削除操作前に、確認画面が表示される。
レイアウト & デザイン
  • グループ表示:
    • 各グループの名前は、テーブルの見出しとして表示される。
    • 見出しのテキストは太字、20pxのフォントサイズ、下線付きで表示される。
  • レスポンシブデザイン:

実装方法

Google Apps Scriptのセットアップ
  • Googleスプレッドシートを開き、拡張機能 > Apps Script を選択してGoogle Apps Scriptのエディタを開きます。
  • 新しいHTMLファイルを追加し、それをウェブページとして使用します。例えば、Page.htmlという名前でファイルを作成します。
コードの記述

※コードは後述

デプロイ

コード

Code.gs

function doGet() {
  return HtmlService.createHtmlOutputFromFile('Page').setTitle('Spreadsheet Editor').addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

function getSheetData() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  return sheet.getDataRange().getValues();
}

function updateSheetData(data) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  sheet.clear();
  data.forEach(function(row) {
    sheet.appendRow(row);
  });
}

function addNewItem(group, itemName, quantity) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  sheet.appendRow([group, itemName, quantity]);
}

function deleteItem(rowIndex) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  sheet.deleteRow(rowIndex + 1);
}
  • Page.html
<!DOCTYPE html>
<html>
  <head>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
            font-size: 20px;
        }

        table {
            width: 100%;
            border-collapse: collapse;
        }


        th, td {
                padding: 1px 1px;
        }

        input[type="text"] { width: 20%; }
        input[type="number"] { width: 20%; }

        @media (max-width: 600px) {
            body {
                font-size: 14px;
            }

            table, th, td {
                padding: 1px 1px;
            }

            button {
                font-size: 0.8em;
            }
        }
    </style>
  </head>
  <body>
    <table id="itemsTable">
        <!-- Items will be displayed dynamically here -->
    </table>
    <button onclick="loadData()">データを再読み込み</button>
    <hr>
    グループ: 
    <select id="groupSelector">
        <!-- Groups will be added dynamically here -->
    </select> 
    または 新規グループ名: <input id="newGroupName" type="text"><br>
    アイテム名: <input id="newItemName" type="text">
    数量: <input id="newItemQuantity" type="number">
    <button onclick="addItem()">アイテム追加</button>
    
    <script>      
        function updateGroupOptions(groups) {
            var groupSelector = document.getElementById("groupSelector");
            groupSelector.innerHTML = "";  // 既存のオプションをクリア
            
            groups.forEach(function(group) {
                var option = document.createElement("option");
                option.value = group;
                option.textContent = group;
                groupSelector.appendChild(option);
            });
        }
        function loadData() {
            google.script.run.withSuccessHandler(function(data) {
                var uniqueGroups = [...new Set(data.map(item => item[0]))];  // グループの一意な一覧を取得
                updateGroupOptions(uniqueGroups);  // グループのオプションを更新
                showData(data);  // テーブルにデータを表示
            }).getSheetData();
        }
        function showData(items) {
            var table = document.getElementById("itemsTable");
            table.innerHTML = "";

            // グループごとにアイテムを整理
            var groupedItems = {};
            items.forEach(function(item) {
                if (!groupedItems[item[0]]) {
                    groupedItems[item[0]] = [];
                }
                groupedItems[item[0]].push(item);
            });

            // グループごとにアイテムをテーブルに追加
            for (var group in groupedItems) {
                var row = table.insertRow(-1);
                var cell1 = row.insertCell(0);
                cell1.innerHTML = group;  // グループ名
                cell1.colSpan = 5;  // グループ名が5列分の幅を持つように設定
                cell1.style.fontWeight = 'bold';  // ボールド体
                cell1.style.fontSize = '20px';   // 20pxのフォントサイズ(あるいは他の希望のサイズに調整)
                cell1.style.textDecoration = 'underline';

                groupedItems[group].forEach(function(item, index) {
                    var subRow = table.insertRow(-1);
                    
                    var cell2 = subRow.insertCell(0);  // アイテム名
                    cell2.innerHTML = item[1];
                    cell2.style.width = '45%';

                    var cell3 = subRow.insertCell(1);  // 数量
                    var input = document.createElement("input");
                    input.style.width = '25%';
                    input.value = item[2];
                    cell3.appendChild(input);
                    cell3.style.width = '25%';

                    var cell4 = subRow.insertCell(2);  // 更新ボタン
                    var updateButton = document.createElement("button");
                    updateButton.innerHTML = "更新";
                    updateButton.onclick = function() {
                        items[index][2] = parseInt(input.value);
                        google.script.run.updateSheetData(items);
                    };
                    cell4.appendChild(updateButton);

                    var cell5 = subRow.insertCell(3);  // 削除ボタン
                    var deleteButton = document.createElement("button");
                    deleteButton.innerHTML = "削除";
                    deleteButton.onclick = function() {
                        if (window.confirm("このアイテムを削除してもよろしいですか?")) {
                            google.script.run.withSuccessHandler(loadData).deleteItem(index);
                        }
                    };
                    cell5.appendChild(deleteButton);
                });
            }
        }

        function addItem() {
            var groupSelector = document.getElementById("groupSelector");
            try {
              var selectedGroup = groupSelector.options[groupSelector.selectedIndex].value;
            }
            catch {
              var selectedGroup = Other;
            }
            var newGroup = document.getElementById("newGroupName").value;
            var groupName = newGroup ? newGroup : selectedGroup;
            
            var itemName = document.getElementById("newItemName").value;
            var itemQuantity = parseInt(document.getElementById("newItemQuantity").value);
            
            google.script.run.withSuccessHandler(loadData).addNewItem(groupName, itemName, itemQuantity);
        }
        // Load data on initial page load
        loadData();
    </script>

</body>
</html>

画像異常検知用の学習データ自動生成ツールを作った

初めに

MVTechのような、異常検知用のデータ生成ツールをUnityで作った。ざっくりいうと、正常な部品を模擬した3Dモデルに対して以下の操作を繰り替えして、異常データを生成する。

  1. 異常データを模擬するため、3Dモデルのメッシュの一部をへこませる。
  2. 画像を保存する。

異常データは以下のような感じ。メッシュの一部をへこませるので、ある程度頂点数があるカプセルを使用している。また、柄もある程度違いが出るように、木目調のマテリアルを貼り付けている。

ちなみにスクリプトはほぼChatGPTで生成している。

異常なオブジェクトの生成

まずは先ほどの写真のようなオブジェクト(カプセルでも何でもOK)を作成する。

そして、以下のスクリプトをアタッチする。これでこのオブジェクトは生成時にへこむようになる。また、このスクリプトを無効化しておけば、正常画像を生成できる。deformationAmountで、へこむ量を調整できる。

using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(MeshFilter))]
public class RandomVertexDeformer : MonoBehaviour
{
    private Mesh mesh;
    public float deformationAmount = -0.5f;  // へこむ量を設定

    void Start()
    {
        mesh = GetComponent<MeshFilter>().mesh;
        DeformRandomVertices(5);
    }

    void DeformRandomVertices(int count)
    {
        if (mesh == null || mesh.vertexCount == 0) return;

        Vector3[] vertices = mesh.vertices;
        HashSet<int> indicesSet = new HashSet<int>();

        while (indicesSet.Count < count)
        {
            int randomIndex = Random.Range(0, mesh.vertexCount);
            indicesSet.Add(randomIndex);
        }

        List<int> selectedIndices = new List<int>(indicesSet);
        int vertexToDeformIndex = selectedIndices[Random.Range(0, selectedIndices.Count)]; // 5つの選択された頂点の中から1つをランダムに選択

        // へこませる処理
        Vector3 normal = mesh.normals[vertexToDeformIndex];
        vertices[vertexToDeformIndex] += normal * deformationAmount;

        mesh.vertices = vertices;
        mesh.RecalculateNormals();  // 法線を再計算して照明が正しく動作するようにする
    }
}

写真保存

空のオブジェクトを作成して、以下のスクリプトをアタッチする。このスクリプトは、以下を繰り返し行ってくれる。

  1. オブジェクトを生成(位置、回転はランダム)
  2. スクリーンショットを保存
  3. オブジェクトを削除
using UnityEngine;
using System.Collections;

public class ObjectSpawnerAndCameraCapture : MonoBehaviour
{
    public GameObject objectPrefab;        // インスタンス化するオブジェクトのPrefab
    public Transform spawnPosition;        // オブジェクトを生成する位置
    public Camera captureCamera;           // 画像をキャプチャするカメラ
    public int totalCaptures = 10;         // 保存する画像の合計数
    public float timeBetweenCaptures = 2f; // 画像をキャプチャする間隔(秒)

    private int currentCaptureCount = 0;
    private string basePath;
    private GameObject spawnedObject;      // 生成されたオブジェクトの参照
    
    public Vector3 moveRange = new Vector3(1, 1, 1);  // 移動する最大の範囲
    public Vector3 rotationRange = new Vector3(360, 360, 360);  // 各軸における最大の回転角度

    private void Start()
    {
        basePath = Application.persistentDataPath + "/screenshot";
        Debug.Log(basePath);
        StartCoroutine(CaptureRoutine());
    }

    private IEnumerator CaptureRoutine()
    {
        while (currentCaptureCount < totalCaptures)
        {
            Debug.Log(moveRange);
            // オブジェクトの生成
            spawnedObject = Instantiate(objectPrefab, spawnPosition.position, spawnPosition.rotation);
            
            // ランダムに移動
            float offsetX = Random.Range(-moveRange.x, moveRange.x);
            float offsetY = Random.Range(-moveRange.y, moveRange.y);
            float offsetZ = Random.Range(-moveRange.z, moveRange.z);

            spawnedObject.transform.position  += new Vector3(offsetX, offsetY, offsetZ);

            float randomRotationX = Random.Range(0, rotationRange.x);
            float randomRotationY = Random.Range(0, rotationRange.y);
            float randomRotationZ = Random.Range(0, rotationRange.z);// + spawnPosition.rotation.z;

            spawnedObject.transform.eulerAngles = new Vector3(randomRotationX, 0, 90);
            

            yield return new WaitForSeconds(1f);  // 必要に応じて待機時間を調整してください

            // カメラ画像の保存
            CaptureCameraImage();

            // オブジェクトの削除
            Destroy(spawnedObject);

            currentCaptureCount++;
            yield return new WaitForSeconds(timeBetweenCaptures - 1f);
        }
    }

    private void CaptureCameraImage()
    {
        RenderTexture renderTexture = new RenderTexture(captureCamera.pixelWidth, captureCamera.pixelHeight, 24);
        captureCamera.targetTexture = renderTexture;

        // カメラの画像をキャプチャする
        captureCamera.Render();
        RenderTexture.active = renderTexture;

        Texture2D screenshot = new Texture2D(captureCamera.pixelWidth, captureCamera.pixelHeight, TextureFormat.RGB24, false);
        screenshot.ReadPixels(new Rect(0, 0, captureCamera.pixelWidth, captureCamera.pixelHeight), 0, 0);
        screenshot.Apply();

        byte[] bytes = screenshot.EncodeToPNG();
        System.IO.File.WriteAllBytes(basePath + currentCaptureCount + ".png", bytes);

        Destroy(screenshot);

        captureCamera.targetTexture = null;
        RenderTexture.active = null;
        Destroy(renderTexture);
    }
}

objectPrefabにさっき作成した3Dモデル、spawnPositionにオブジェクトを生成する位置(カメラ前になるようにしておく)、captureCameraにカメラ、totalCapturesにキャプチャする枚数をセットする。

実行

準備ができたら実行すると、勝手に画像が生成されていく。

テスト

異常検知を試してみた結果は以下のとおり。(真ん中のマスク画像は適当)ちゃんと異常箇所が着目されていることがわかる。