歌ネットから歌詞を取得して穴埋めクイズを作成する

カラオケで穴埋めクイズ風に歌詞を隠しつつを歌うというのが面白かったので作りました。

※Pythonで作成したプログラムをWindows実行用にPyinstallerでexe化していますが、
 ウィルス対策ソフトによってマルウェアなどに分類されてしまうことがあるそうです。
 当然、そのような機能は実装していませんが。。。

■ファイル
https://drive.google.com/file/d/1wKf-ZSTDrIE2CUmj81Ls_6uaudk7Uy_v/view?usp=drive_link

【exeファイルの使い方】

  1. ダウンロードしたzipを解凍して、「quiz_creater.exe」を起動します。
    ※セキュリティの警告が表示される場合は、「詳細情報→実行する」
  2. URLに歌ネットの歌詞ページのURLを貼り付けて、GETボタンを押下するとテキストエリアに歌詞が表示されます。
  3. テキストエリアの歌詞から、穴埋めにしたいところを選択するとテキストボックスが追加されます。
  4. テキストボックス右側のDELボタンを押下すると穴埋めが解除されます。
  5. SAVEボタンを押下するとファイル保存ダイアログが表示されるので、HTMLファイルを出力したいフォルダとファイル名を設定してください。

【HTMLについて】

  1. HTMLを起動すると穴埋め状態で表示されます。
  2. ブラウザがChromeなら、F11キーを押下することで全画面表示になるかと思います。
  3. 画面内をクリックすることで、上から順番に穴埋め箇所が解除されます。

コードも載せておきます。

import os
import tkinter as tk
from tkinter import filedialog
import requests
from bs4 import BeautifulSoup
import copy

replace_list = {}
replace_list_index = 0

class ReplaceItem :
    def __init__(self, text_box, start_index, end_index, id_index) :
        self.text_box = text_box
        self.start_index = start_index
        self.end_index = end_index
        self.id_index = id_index

    def get_text(self) :
        result = "<span id=\"text" + str(self.id_index) + "\" style=\"background-color:#000;color:#000\">" + self.text_box.get() + "</span>"
        return result

def replace_text(event):
    global replace_list
    global replace_list_index

    # テキストエリアからテキストを取得
    text = text_area.get("1.0", "end-1c")

    # テキストの長さが0の場合、何もせずに終了
    if len(text) == 0:
        return

    # テキストエリアで選択されたテキストの開始位置と終了位置を取得
    try:
        start_index = text_area.index(tk.SEL_FIRST)
        end_index = text_area.index(tk.SEL_LAST)
    except ValueError:
        # テキストが選択されていない場合、何もせずに終了
        return

    selected_text = text_area.get(start_index, end_index)

    # フレームを作成してテキストボックスとボタンを横に並べる
    frame = tk.Frame(window)
    frame.pack(fill=tk.X, expand=True)

    # テキストボックスを配置
    text_box = tk.Entry(frame)
    text_box.insert(0, selected_text)
    text_box.pack(fill=tk.X, expand=True, side = 'left')

    # 置換アイテムリストに追加
    id_index = replace_list_index + 1 - 1
    replace_list[replace_list_index] = ReplaceItem(text_box, start_index, end_index, id_index)
    replace_list_index = replace_list_index + 1

    # 削除ボタンを配置
    del_button = tk.Button(frame, text="DEL")
    del_button.configure(command=lambda: del_text_box(frame, text_box, del_button, text_area, start_index, end_index, id_index))
    del_button.pack(side = 'right')

    # テキストエリアを更新
    text_area.delete(start_index, end_index)
    text_area.insert(start_index, "■" * len(selected_text))

# テキストボックスを削除する関数
def del_text_box(frame, text_box, del_button, text_area, start_index, end_index, del_index) :
    text_area.delete(start_index, end_index)
    text_area.insert(start_index, text_box.get())
    text_box.destroy()
    del_button.destroy()
    frame.destroy()
    replace_list.pop(del_index)

# 歌詞を取得
def getKashi(text_box, text_area) :
    response = requests.get(text_box.get())
    if response.status_code == 200:
        # Beautiful Soupを使用してHTMLを解析
        soup = BeautifulSoup(response.text, 'html.parser')

        # 歌詞がどの要素に格納されているかを調査し、適切な要素を選択
        # 例: 歌詞が <div class="lyrics"> タグの中にある場合
        lyrics_element = soup.find('div', id='kashi_area')
        
        if lyrics_element:
            # 歌詞テキストを取得
            lyrics_text = "\n".join(lyrics_element.stripped_strings)
            
            # 歌詞を表示またはファイルに保存
            text_area.insert(tk.END, lyrics_text)

        else:
            print("歌詞が見つかりませんでした。")
    else:
        print("URLにアクセスできませんでした。")

# HTML出力
def createHtml(text_area) :
    global replace_list

    # スクリプトのディレクトリを取得
    script_directory = os.path.dirname(os.path.abspath(__file__))

    # ファイル保存ダイアログを表示
    file_path = filedialog.asksaveasfilename(initialdir=script_directory, defaultextension=".html", filetypes=[("HTMLファイル", "*.html")])
    if not file_path:
        return

    # ファイル名(拡張子を除く)を取得
    filename = os.path.splitext(os.path.basename(file_path))[0]

    # 出力文字列を生成
    store_text = text_area.get("1.0", "end-1c")
    sorted_items = sorted(replace_list.items(), key=lambda item: (int(item[1].start_index.split(".")[0]), int(item[1].start_index.split(".")[1])), reverse=True)
    index_strs = []
    for key, item in sorted_items :
        text_area.delete(item.start_index, item.end_index)
        text_area.insert(item.start_index, item.get_text())
        index_strs.append(str(item.id_index))
    output_str = text_area.get("1.0", "end-1c").replace("\n", "<br/>\n")
    text_area.delete("1.0", "end")
    text_area.insert("1.0", store_text)

    # HTMLを成形
    html_str = "<html>\n"
    html_str = html_str + "  <head>\n"
    html_str = html_str + "    <title>\n"
    html_str = html_str + "      " + filename + "\n"
    html_str = html_str + "    </title>\n"
    html_str = html_str + "    <script>\n"
    html_str = html_str + "      var list = [" + ", ".join(list(reversed(index_strs))) + "];\n"
    html_str = html_str + "      var currentIndex = 0;\n"
    html_str = html_str + "\n"
    html_str = html_str + "      function changeStyles() {\n"
    html_str = html_str + "        if (currentIndex < list.length) {\n"
    html_str = html_str + "          var elementId = \"text\" + list[currentIndex];\n"
    html_str = html_str + "          var element = document.getElementById(elementId);\n"
    html_str = html_str + "\n"
    html_str = html_str + "          if (element) {\n"
    html_str = html_str + "            element.style.backgroundColor = \"transparent\"; // 背景色を無色に設定\n"
    html_str = html_str + "            element.style.color = \"black\"; // 文字色を黒色に設定\n"
    html_str = html_str + "          }\n"
    html_str = html_str + "\n"
    html_str = html_str + "          currentIndex++;\n"
    html_str = html_str + "        }\n"
    html_str = html_str + "      }\n"
    html_str = html_str + "\n"
    html_str = html_str + "      window.addEventListener(\"click\", changeStyles);\n"
    html_str = html_str + "    </script>\n"
    html_str = html_str + "  </head>\n"
    html_str = html_str + "  <body>\n"
    html_str = html_str + output_str + "\n"
    html_str = html_str + "  </body>\n"
    html_str = html_str + "</html>\n"

    with open(file_path, 'w') as file:
        file.write(html_str)
        print(f"ファイル '{file_path}' に保存しました")

# Tkinterウィンドウの作成
window = tk.Tk()
window.title("Text Replacement")

# テキストエリアを作成
text_area = tk.Text(window)
text_area.pack(fill=tk.BOTH, expand=True, side = 'top')  # 画面の伸長に合わせてサイズを変更

# フレームを作成
frame = tk.Frame(window)
frame.pack(fill=tk.X, expand=False)

# ラベルを作成
label1 = tk.Label(frame, text="URL:")
label1.pack(side = 'left')

# テキストボックスを作成
text_box = tk.Entry(frame)
text_box.insert(0, "https://www.uta-net.com/song/344723/")
text_box.pack(fill=tk.X, expand=True, side = 'left')

# GETボタンを作成
up_button = tk.Button(frame, text="GET", command=lambda: getKashi(text_box, text_area))
up_button.pack(side = 'right')

# SAVEボタンを作成
save_button = tk.Button(frame, text="SAVE", command=lambda: createHtml(text_area))
save_button.pack(side = 'right')

# テキスト選択時のイベントハンドラを設定
text_area.bind("<ButtonRelease-1>", replace_text)

# プログラムの実行
window.mainloop()

AWSのEC2(WindowsServer)でキャプチャ取得を自動化したかった話

主題のままなのですが、AWSのEC2でテストを消化する機会があり、ファイル収集やキャプチャ取得が面倒だと思ったので、パワーシェルで自動化しました。

キャプチャ取得は、メモ帳とエクスプローラー。
ファイル収集はそんなに難しくないと思うので省略です。

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

$exeFolder = $MyInvocation.MyCommand.Path
$exeFolder = Split-Path "$exeFolder" -Parent

Function openExprolerAndGetCapture($folderPath) {

    # エクスプローラーを開く
    Start-Process explorer.exe -ArgumentList $folderPath -WindowStyle Maximized
    # ウィンドウが表示されるのを待つ
    Start-Sleep -Seconds 5

    # エクスプローラーのウィンドウを探す
    $folderName = $folderPath.split("\")
    #Write-Host $folderName[-1]
    $explorerWindow = Get-Process | Where-Object { $_.MainWindowTitle -eq $folderName[-1] }

    if ($explorerWindow) {

        # 画面キャプチャを取得
        $rect = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
        $bitmap = New-Object System.Drawing.Bitmap($rect.Width, $rect.Height)

        $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
        $zero = New-Object System.Drawing.Point(0, 0)
        $graphics.CopyFromScreen($zero, $zero, $bitmap.Size)

        # 画像を保存
        $saveFileName = $exeFolder + "\" + $folderPath.replace("\", "_").replace(":", "") + ".png"
        Write-Host $saveFileName
        $savePath = $saveFileName
        $bitmap.Save($savePath, [System.Drawing.Imaging.ImageFormat]::Png)

        # ウィンドウを閉じる
        $explorerWindow.CloseMainWindow()

    }
    else {
        Write-Host "エクスプローラーのウィンドウが見つかりませんでした。"
    }

}

Function openNotepadAndGetCapture($filePath) {

    # メモ帳を開く
    Start-Process C:\Windows\notepad.exe -ArgumentList $filePath -WindowStyle Maximized
    # ウィンドウが表示されるのを待つ
    Start-Sleep -Seconds 5

    # メモ帳のウィンドウを探す
    $fileName = $filePath.split("\")
    $windowTitle = $fileName[-1] + " - メモ帳"
    Write-Host $windowTitle
    $explorerWindow = Get-Process | Where-Object { $_.MainWindowTitle -eq $windowTitle }

    if ($explorerWindow) {

        # 画面キャプチャを取得
        $rect = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
        Write-Host $rect.Width ":" $rect.Height
        $bitmap = New-Object System.Drawing.Bitmap($rect.Width, $rect.Height)

        $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
        $zero = New-Object System.Drawing.Point(0, 0)
        $graphics.CopyFromScreen($zero, $zero, $bitmap.Size)

        # 画像を保存
        $saveFileName = $exeFolder + "\" + $filePath.replace("\", "_").replace(":", "").replace(".", "_") + ".png"
        Write-Host $saveFileName
        $savePath = $saveFileName
        $bitmap.Save($savePath, [System.Drawing.Imaging.ImageFormat]::Png)

        # ウィンドウを閉じる
        $explorerWindow.CloseMainWindow()

    }
    else {
        Write-Host "メモ帳のウィンドウが見つかりませんでした。"
    }

}

try {

    # memo
    openNotepadAndGetCapture -filePath "C:\test\test.txt"

    # explorer
    openExprolerAndGetCapture -folderPath "C:\test"

}
finally {
}

さくらVPSでmariaDB + nginxをDockerで

なぜDockerなのか

試行錯誤したいからです。

構成

OS:Ubuntu 20.04
スクリプト:Setup and update
 ー初回起動時にパッケージ更新する:する
 ー日本語環境 ja_JP.UTF-8 に変更する:する

準備

アップデートなどしていきます。

sudo apt update
sudo apt upgrade

Docker

sudo apt docker.io

Docker-compose

sudo apt docker-compose

docker-compose.yml

太字部分は適宜、変更してください。
特にmariaDBは、ポート番号を変更してください。
バージョンを固定する必要ないならlatestとかでも大丈夫じゃないでしょうか。(試してない)
できればstable(安定版)にすることをおすすめします。

version: '3'

services:
  db:
    image: mariadb:11.0.2
    environment:
      MYSQL_ROOT_PASSWORD: Password
    ports:
      - '3306:3306'
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - ./mysql/data:/var/lib/mysql
  web:
    image: nginx:1.24.0
    volumes:
      - ./nginx/templates:/etc/nginx/templates
    ports:
      - "8080:80"
    environment:
      - NGINX_HOST=www.domain.net
      - NGINX_PORT=80

アクセス

パケットフィルタリングを有効にしている場合は通すようにしましょう。
mariaDBのユーザIDとパスワードは、上記のymlの設定ならroot/Passwordです。

当然ながら、外部からアクセスする場合にはドメインが必要になります。
(もしくはIPアドレス指定ですかね)

まとめ

とりあえず、アクセスできることだけ確認しました。
アプリの実装やデプロイ自動化などについては、また今度。

アプリ開発のサーバーサイド、いったん保留か。

■検討

 まず、制約事項としてさくらVPSの最小プランを選択しました。
 なので、その環境で動作する必要がありますので、軽量であることが条件となります。
 実際に何度もOSの再インストールをして試してみましたが、そもそも各種ライブラリなどがきちんとインストールできないとかありましてね。。。
 とりあえず、脆弱性の面でPHPなどは避けておきたいので、自分の中ではRubyかPythonかなと見当をつけていました。

■Ruby On Rails

 何度もOS再インストールを繰り返しながら何とかインストールができそう!ってところまで行きましたが、結局はメモリが足りずに断念しました。

 結構時間もかかっていたのに残念です。
 潤沢に資金があればプランをスケールアップして採用するのもアリでしたね。

■Python

 Rubyの次点として考えておりましたが、Rubyがダメそうとなって本格的に検討しました。
 Pythonで軽量なWEBアプリケーションが作成できるのかなと思っていましたが、FastAPIというものがあるらしく、それを使おうと思います。
 さくらVPSにはPython向けセットアップスクリプトがあったんですけど、全然インストールされてないんですよね。。。
 サーバーサイドは諦めて、アプリにデータを含めてリリースする方が早くリリースできそうなんですよねー。

HTTPステータスに502を返却している

主題のとおり、HTTPステータスに502を返却していたので、原因を調査して対策した話。

【原因調査】

まずはネットで502ってどんな時に出てくるの?ってのを調べました。
色々見て回ったところ「負荷がかかっている」みたいなのを見て、確かに日中帯に出現していて夜間帯は全然出ていない、というところで納得しました。

が、
実は負荷ではなく、別な原因がありました。

【原因判明】

原因はTomcatのコネクションプールサイズの最大数と最小数が異なるためです。
Tomcatのコネクションプールは、使用していないコネクションを自動で開放(切断)します。
なので、使用していないコネクションを開放するタイミングでリクエストが来た時はそのまま開放しちゃうので502が発生してしまうということみたいですね。

ちなみに同様の事象なのかはApacheのエラーログを見れば分かると思います。
下記のようなエラーが出ていると思うので。

proxy: error reading status line from remote server...


ちょっと気になったのは、色々サイトを見て回ったけど、何故か「Apacheにコネクションプールを使用しない設定にしましょう」(proxy-initial-not-pooledの有効化)って対策が出てくるんですよね。

【対策】

便利な機能をOFFにしましょうというのは納得できなかったのでもっと調査した結果、コネクションプールの最大数と最小数を同数にしました。

原因が不要なコネクションを閉じてしまうというなら、常にコネクションを一定量に保つようにしておきましょうということですね。

自力で思いついた訳ではないんですけど、ここに書いた方が記憶に残りそうだし、同じことでつまずいている人がいた場合に他の対策方法を提示できるかもしれないので。(^^)

※バックエンド側に設定する方法があると思いますが、それはバージョンやフレームワークとかミドルウェアで変わってくると思うので記載はしません。

Python練習問題

Chainer Tutorialに記載されている練習問題をやってみたので、結果をメモとして残しておきます。

問2.8 (制御構文)

2以上の整数 p が素数であるとは、「どんな 2 以上 p-1 以下の整数 k に対しても p は k で割り切れない」が成り立つことを指します。素数を小さい順から列挙すると、2357111317, … となります。 チュートリアルで学んだ制御構文である if や for を用いて、2 から 100 からまでに含まれる素数を列挙して下さい。

◆回答

a = [2]
for x in list(range(3, 101)):
    if all([x % y > 0 for y in list(range(2, x))]):
        a.append(x)
print(a)

◆実行結果

[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]

「rails new sample_app -G」が失敗

Ruby on railsの環境を作ろうとProgateのサイトを見ながら進めてたら、
下のような感じで失敗しました。

rails aborted!
Errno::ENOENT: No such file or directory - bs_fetch:open_current_file:open
bin/rails:4:in `<main>'
(See full trace by running task with --trace)
       rails  turbo:install stimulus:install
rails aborted!
Errno::ENOENT: No such file or directory - bs_fetch:open_current_file:open
bin/rails:4:in `<main>'
(See full trace by running task with --trace)

とりあえず–traceで詳細が見れるのかな?と思ったので次のコマンドを実行。

rails new sample_app --trace
C:/Ruby30-x64/lib/ruby/gems/3.0.0/gems/railties-7.0.3.1/lib/rails/generators/rails/app/app_generator.rb:258:in ``': No such file or directory - git config init.defaultbranch (Errno::ENOENT)

なんだか「Gitが入っていないから?」と思ったのでいったんGitをインストール。
しかし、再実行しても同様の失敗。

とりあえずrailsプロジェクトを作成しているのがデスクトップだったのが気になっていたで、
「C:¥work¥ruby」を作ってそこで再実行。

なんかうまくいきました。
パスか「C:\Users\xxxxx\OneDrive\デスクトップ\ruby_lesson」だったから、
階層か日本語が原因かな・・・?

複雑な条件分岐を分かりやすく

MapやSetを使う

複数のステータスのいずれかに一致するか、という条件判定をひとつずつ実装するのは非効率だし、分かりやすさや見易さを犠牲にすると思います。
MapやSetに存在するかを判定することで見やすく、分かりやすくなるのではないかと。

例)

public class Test {

	private static final Set<String> GOODS_SET;
	static {
		HashSet<String> map = new HashSet<String>();
		map.add("りんご");
		map.add("ぶどう");
		map.add("なし");
		map.add("もも");
		map.add("みかん");
		GOODS_SET = Collections.unmodifiableSet(map);
	}

	public static void main(String[] args) {

		if (GOODS_SET.contains("りんご")) {
			System.out.println("商品に存在します。");
		} else {
			System.out.println("商品に存在しません。");
		}

	}

}

条件分岐の名前で判定するので、分かりやすいと思います。
1点だけ難点を挙げるなら、テストで条件を網羅できたかが確認できないので、条件に含めているものが正しいのかをレビューできちんと確認する必要があるところです。