🙆

部会の準備が面倒だった話

2023/11/03に公開

はじめに

こんにちは.まずはこの記事を開いてくれてありがとうございます.これは私が所属するMMAというサークル(総合格闘技じゃないよ)の部会の準備を楽にしようとした話です.

初心者が初心者なりに作成して記事を書くので,拙い部分も多いとは思いますが,最後まで読んでいただければ幸いです.

https://github.com/gae-22/club_meeting

部会の準備

MMA では,毎週部会を行っています.部会では,毎週部員から挙げられる議題の話し合いだったり,講習会だったりを行っています.問題は,議題を投票して決めるところです.

その準備には,次のようなものが必要です.

  • Wiki から議題の確認
  • 投票用の GoogleForm の作成
  • GoogleForm のパスワードの設定
  • GoogleForm の共有

かなりのめんどくさがりの私には,毎週これらの作業を行うのはかなり面倒な作業でした.めんどくさいなら自動化してしまおうと思って,部会用の Web アプリ(?)を作成しました.(?)なのは,動的サイトでもなんでもなくてただスクレイピングしてきたり,GoogleForm を作成したりするだけだからです.まあ詳細を見ていきましょう.

主な機能

  • Wiki から議題を取得
  • 議題を投票用の GoogleForm に反映
  • 投票用の GoogleForm のパスワードを設定
  • 投票用の GoogleForm を共有

今回作成した Web アプリの主な機能は上記の 4 つです.これだけでもかなり楽になりますね

作成詳細

ここからは作成についての詳細を書いていきます.細かい関数の使い方やオプションなどについてはたくさんネットに転がっているので省略します.

動作環境

  • Python3
  • Flask

ディレクトリ構成

この記事内では,部会用の Web アプリのディレクトリ構成を以下のようにします.また,この記事内で書かれるパスは部会用の Web アプリのディレクトリからの相対パスとします.

ディレクトリ構成
.
├── meeting/
│   ├── models
│   │   ├── agenda.py
│   │   ├── password.py
│   │   ├── room.py
│   │   ├── scrape.py
│   │   ├── spread.py
│   │   └── thread.py
│   ├── static/
│   │   ├── css/
│   │   │   └──common.css
│   │   ├── images/
│   │   │   ├──logo.png
│   │   │   └──qr.png
│   │   └── javascript/
│   │       └──common.js
│   ├── templates/
│   │   ├── meeting/
│   │   │   ├── agenda.html
│   │   │   ├── form.html
│   │   │   ├── index.html
│   │   │   ├── room.html
│   │   │   └── thread.html
│   │   └── layout.html
│   └── __init__.py
├── datas/
│   ├── meeting.json  # スプレッドシートの情報
│   ├── password.txt  # パスワード
│   └── config.ini    # Wikiのログイン情報
└── server.py

Wiki から議題を取得

まずは,Wiki から議題を取得するところからです.スクレイピングするために,BeautifulSoupを利用しました.Wiki から議題を取得するためには,Wiki にログインする必要があります.そのためには,ログイン情報が必要です.ログイン情報は./datas/config.iniに書かれています.

config.ini
./data/config.ini
[User]
name     : <id>
password : <password>

この<id><password>には,自分のログイン情報を書きます.
そして,実際にログインしてスクレイピングを行うのは./meeting/models/scrape.pyです.

scrape.py
./meeting/models/scrape.py
import requests, configparser
import BeautifulSoup

def scrape(URL):
    config_file = "./datas/config.ini"
    config = configparser.ConfigParser()
    config.read(config_file, encoding="utf-8")
    name = config.get("User", "name")
    password = config.get("User", "password")

    login_info = {
        "name": name,
        "password": password,
        "login": "ログイン",
        "login": "ログイン",
    }

    session = requests.session()
    res = session.post(URL, data=login_info)
    soup = BeautifulSoup(res.content, "html.parser")

    return soup

ただし,./meeting/models/scrape.pyで行うのは html の取得のみです.html から議題を取得するのは./meeting/models/thread.pyです.

thread.py
./meeting/models/thread.py
from meeting.models.scrape import scrape
from datetime import datetime
import re


def update():
    BBS_URL = (<スクレイピング先のURL>)
    soup = scrape(BBS_URL)
    tbody = soup.find("tbody")
    mat = []
    trs = tbody.find_all("tr")
    for tr in trs:
        r = []
        for td in tr.find_all("td"):
            instead = [
                "<td>",
                "</td>",
                "<strong>",
                "</strong>",
                '<td colspan="3" style="text-align: center;">',
                '<td colspan="3" style="text-align: left;">',
                "</a>",
            ]
            td = str(td).replace("<br/>", "\n")
            td = re.sub(r"<a href=(.*?)\">", "", td)
            for item in instead:
                td = str(td).replace(item, "")
            r.append(td)
        mat.append(r)

    return mat[0:-1]

今回の取得したい情報は表となっているため,<tbody>タグの中身を取得しています.また,<td>タグの中身を取得する際に,<br/>タグを改行に変換しています.また,その他のタグは削除しています.ここは,自分の取得したい情報に合わせて変更してください.

また,これは URL 先の全ての情報を取得していますが,直近の議題のみを部会では使用するため,直近のみを取得する./meeting/models/agenda.pyも作成しました.

agenda.py
./meeting/models/agenda.py
from meeting.models.thread import update
import re


def get_agendas():
    BBS_ARRAY = update()
    length = len(BBS_ARRAY)
    agendas = []
    for i in range(0, length):
        element = BBS_ARRAY[length - i - 1][0]
        if re.match("\s*--", element):
            agendas = BBS_ARRAY[length - i - 2 : length]
            break

    return agendas

議題を挙げる際の書式として,--で始まる行の次の行からが議題となっています.そのため,--で始まる行の次の行からを取得しています.

議題を投票用の GoogleForm に反映

次に,議題を投票用の GoogleForm に反映するところです.GoogleForm に反映するために,Python から GoogleSpreadSheet にフォームのパスワードを入力し,それをトリガーとして GAS を用いて GoogleForm を作成しています.

パスワードの設定

まずは,パスワードの設定です.パスワードは./datas/password.txtに一覧が書かれています.部会の Web アプリでは,このファイルからパスワードを取得しています.これはそもそも GoogleForm を解答できる人に制限がかかっており,これを見られる人も少ないため,ランダムではなく MMA に関係する単語の一覧にしています.(それでも本当はランダムにしたほうが良いですが)

password.py
./meeting/models/password.py
import datetime, random, string
from meeting.models.spread import write_password

password_past = ""


def reload_time():
    mtg_from_time = datetime.time(16, 40, 00)
    mtg_to_time = datetime.time(17, 20, 00)
    mtg_wkdy = 0

    now_time = datetime.datetime.now().time()
    now_wkdy = datetime.datetime.now().weekday()

    return (
        True
        if mtg_from_time < now_time < mtg_to_time and now_wkdy == mtg_wkdy
        else False
    )


def choose_random():
    global password_past
    pass_path = "./datas/password.txt"

    with open(pass_path, "r") as f:
        lines = f.read().splitlines()

    while True:
        password = random.choice(lines)
        if password != password_past:
            break
    write_password(password)
    return password


def randomname(n):
    global password_past
    password = ""

    if reload_time():
        password = password_past
    elif password == "":
        password = choose_random()

    if password == 1:
        randlst = [
            random.choice(string.ascii_lowercase + string.digits) for i in range(n)
        ]
        password_past = password = "".join(randlst)
        write_password(password)

    return password

パスワードの設定は毎週月曜日の 16:40~17:20 に行われます.これは,部会の時間に合わせています.また,スプレッドシートに書き込むのは,パスワードが変更されたときのみです.それを行うのは./meeting/models/spread.pywrite_password関数です.

spread.py
./meeting/models/spread.py
import gspread
from meeting.models.agenda import get_agendas

key_name = "./datas/meeting.json"
sheet_name = "部会"
sheet_id = "<スプレッドシートのID>"


def write_password(password):
    sheet_name = "<シート名>"
    gc = gspread.service_account(filename=key_name)
    wks = gc.open_by_key(sheet_id).worksheet(sheet_name)
    wks.update_cell(8, 2, password) # B8セルにパスワードを書き込み


def write_agenda():
    sheet_name = "<シート名>"
    gc = gspread.service_account(filename=key_name)
    wks = gc.open_by_key(sheet_id).worksheet(sheet_name)
    last_row = len(wks.col_values(1))
    agendas = get_agendas()
    k = 3
    cnt = 0
    for agenda in agendas:
        cnt += 1
        if cnt % 2 == 0:
            agenda = agenda[0]
            idx = agenda.find("+")
            agenda = agenda[idx + 1 :]
            idx = agenda.find("\n")
            agenda = agenda[:idx]
            if "告知" not in agenda:
                wks.update_cell(k, 1, agenda)
                k += 1
    for i in range(k, last_row + 1):
        for j in range(1, len(wks.row_values(i)) + 1):
            wks.update_cell(i, j, "")

このwrite_agenda関数で GoogleForm に作成に用いる議題の書き込みも行っています.

GoogleForm の作成

次に,GoogleForm の作成です.GoogleForm の作成は GAS で行っています.

Google Apps Script
./meeting/models/form.gs
// 各アイテム作成
function createItem(form, title, type, help, required) {
    if (type == 'ラジオボタン') {
        return form
            .addMultipleChoiceItem()
            .setTitle(title)
            .setHelpText(help)
            .setRequired(required);
    } else if (type == 'チェックボックス') {
        return form
            .addCheckboxItem()
            .setTitle(title)
            .setHelpText(help)
            .setRequired(required);
    } else if (type == 'プルダウン') {
        return form
            .addListItem()
            .setTitle(title)
            .setHelpText(help)
            .setRequired(required);
    } else if (type == '記述式') {
        return form
            .addTextItem()
            .setTitle(title)
            .setHelpText(help)
            .setRequired(required);
    } else if (type == '日付') {
        return form
            .addDateItem()
            .setTitle(title)
            .setHelpText(help)
            .setRequired(required);
    }
}

// フォーム内容&回答削除
function clearForm(form) {
    // フォーム内の質問をすべて削除
    var items = form.getItems();
    while (items.length > 0) {
        form.deleteItem(items[0]);
        items = form.getItems(); // ループのたびに items を再取得して残りのアイテムを確認
    }
    // フォームの回答を削除
    form.deleteAllResponses();

    var ss = SpreadsheetApp.getActiveSpreadsheet();
    //シート名は置き換えてください。
    var sh = ss.getSheetByName('回答');
    //シートのすべてをクリアする
    sh.clear();
}

// メイン関数
function main(password) {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    // // 概要シートからフォームのIDを取得する
    const formId = ss.getSheetByName('概要').getRange('B6').getValue();
    var form = FormApp.openById(formId);

    // フォーム内の質問と回答をクリア
    clearForm(form);

    // ここにフォームの編集コードを追加
    const formTitle = ss.getSheetByName('概要').getRange('B1').getValue();
    const formDescription = ss.getSheetByName('概要').getRange('B2').getValue();

    form.setTitle(formTitle);
    form.setDescription(formDescription);

    // 質問シートの値を取得
    const q_sheet = ss.getSheetByName('質問');
    const rows = q_sheet.getLastRow();
    const columns = q_sheet.getLastColumn();
    const q_values = q_sheet.getRange(1, 1, rows, columns).getValues();

    // パスワード作成
    const p_sheet = ss.getSheetByName('概要');
    var title = 'パスワード';
    var help = '今回のパスワードを入力してください';
    var password = p_sheet.getRange('B8').getValue();
    var pass = FormApp.createTextValidation();
    var valid = pass
        .requireTextMatchesPattern(password)
        .setHelpText('パスワードが違います')
        .build();
    createItem(form, title, '記述式', help, true).setValidation(valid);

    form.addPageBreakItem()
        .setTitle('議題')
        .setHelpText('以下の議題に回答してください.');

    // アイテムを追加する
    for (let i = 1; i < rows - 1; i++) {
        const title = q_values[i + 1][0];
        const type = q_values[i + 1][1];
        const help = q_values[i + 1][2];
        var required = q_values[i + 1][3];
        if (required != true) required = false;

        let choiceVals = [];
        for (let j = 2; j < columns - 2; j++) {
            const choiceVal = q_values[i + 1][j + 2];
            if (choiceVal == '') {
                break;
            } else {
                choiceVals.push(choiceVal);
            }
        }

        const item = createItem(form, title, type, help, required);

        if (
            choiceVals.length != 0 &&
            (type == 'ラジオボタン' ||
                type == 'チェックボックス' ||
                type == 'プルダウン')
        ) {
            item.setChoiceValues(choiceVals);
        }
    }
    // 初回フォームURL取得用
    ss.getSheetByName('概要').getRange('B4').setValue(form.getEditUrl());
    ss.getSheetByName('概要').getRange('B5').setValue(form.getPublishedUrl());
}

この GAS を使うことで,各項目のタイトルやタイプ,選択肢などを設定することができます.また,オプションで説明や必須項目の設定もできます.手動で作成するのはかなり面倒なので,これを使うことでかなり楽になります.

ここまでで,GoogleForm の作成は完了です.もう一度,Python から GoogleForm を作成する流れを確認しておくと,Python から GoogleSpreadSheet にフォームのパスワードと議題を入力し,それをトリガーとして GAS を用いて GoogleForm を作成しています.ずいぶん長い道のりでしたね.

Flask で Web アプリを作成

ここまでで,議題を取得して GoogleForm を作成するところまでできました.次に,Flask を用いて Web アプリを作成します.Flask の使い方はそこまで難しいことはしていないので,詳細は省略します.公式のチュートリアルをやれば大抵のことは理解できると思います.このチュートリアルはかなり分かりやすいです.

この Web アプリ上で,先ほど取得した議題や GoogleForm の QR コード を表示させます.また,パスワードの表示もこの Web アプリ上で行います.現状では毎回localhostを立てているので外部から見られる心配はあまりありません.(ちゃんとログイン機能をつけて公開したい)

まとめ

ここまでで,部会の準備を楽にするための Web アプリを作成しました.これで,部会の準備がかなり楽になりました.部長のめんどくさがりがもっと加速しそうですね.ここまで読んでいただきありがとうございました.

参考文献

GitHubで編集を提案

Discussion