💬

ハッカソンで議論向けWeb掲示板アプリを作った

2022/06/07に公開

はじめに

大学の有志とチームを組み、日本最大規模の学生対象ハッカソンである(らしい)JPHACKS 2021に参加しました。この記事はその参加記となります。

https://jphacks.com/

作ったもの

https://twitter.com/koooootake/status/1454338009789173762

賛成・反対・中立の立場を明確に示すことで、より正確な議論の理解を促進することを目的とした議論アプリです。アプリ名は「優しい議論」より「Yasaron」としました。UIはスマホ向けに特化させています。

賛成・反対・中立の立場を確定させることで、初めて本文を入力できるようになります。

類似している(と判定された)意見が自動的にまとめられるため、少数派の意見が多数派の意見と対等に扱われ、不要なコメントに埋もれることなく比較検討がしやすくなっています。これにより見やすい環境下で平等なディスカッションができます。

リポジトリはこちらです。

https://github.com/jphacks/C_2118

開発の流れ

Discordでチャット・通話・画面共有をしながら開発を進めました。技術は主に担当したバックエンドについて書きます。

スケジュールは以下のリンク先を参照してください。

https://jphacks.com/2021/detail/

初日(Kick Off Day~中間発表)

内輪チームでの参加であり、ハッカソンにエントリーした時点でアイデア出しをしていたため、スタートダッシュを決めて初日から動くものを作ることができました。

技術選定

予め出しておいたアイデアのうち、スポンサー提供物の自然言語処理APIをうまく活用できそうな案を採用しました。スマホ特化のWebアプリで掲示板(SNS?)的なものを作るという方向性が決まったので次は技術選定です。

出来るだけメンバー全員が開発に携われるようにということで、バックエンドで使う言語はメンバー全員が書けるPythonにしました。またPythonのWebアプリケーションフレームワークとしてはDjangoが有名ですが、多機能故に複雑で覚えることが多いため、Webアプリ未経験者がいることを踏まえてシンプルで軽量なフレームワークであるFlaskを使うことにしました。

https://flask.palletsprojects.com/

GitHub上のリポジトリの運用

GitHubでのチーム開発はまずmasterへの直接pushを禁止し、各自でブランチを切り、更新したらPull Requestを投げて、別のメンバーが承認したらmasterで取り込むという方式で開発を進めました。

Wiki

メンバー向けにGitHubのWikiに環境構築・実行方法・URL・API設計などを書きました。

https://github.com/jphacks/C_2118/wiki

最初のコミット

https://github.com/jphacks/C_2118/commit/c1bc644220dc2a2715dea49de65c0bb62a6894a5

コメントの書き込みと表示

このあたりのコミットでとりあえずコメントの書き込みとデータベースへの登録ができるようになりました。

https://github.com/jphacks/C_2118/commit/1af2b82a303d39dd5b33a26fcd448c01f29da644

https://github.com/jphacks/C_2118/commit/2d889dfc6ae0d3c02f25ff7a13a8973818d5f332

https://github.com/jphacks/C_2118/commit/27c33402fb14450a1aec6d86f870f1a379351838

データベースの操作にはORMライブラリであるFlask-SQLAlchemyを使用しました。SQL文が書けなくても簡単にデータベースを利用することができます。

app.py
from flask import Flask, render_template, request
from flask_sqlalchemy import SQLAlchemy

import json
import random

app = Flask(__name__)

app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.sqlite"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

db = SQLAlchemy(app)


class Comment(db.Model):
    __tablename__ = "comments"

    comment_id = db.Column(db.String, primary_key=True)  # コメントID
    parent_comment_id = db.Column(db.String, nullable=False)  # 親のコメントID
    title = db.Column(db.String, nullable=False)  # タイトル
    text = db.Column(db.String, nullable=False)  # 本文
    attribute = db.Column(db.Integer, nullable=False)  # 賛成 or 反対 or 中立


@app.route("/")
def index():
    return render_template("index.html")


@app.route("/comment", methods=["POST"])
def post_comment():
    comment = json.loads(request.data)
    new_comment = Comment(
        comment_id=str(random.randint(1, 100000)),  ## ID生成は仮なのて適当
        parent_comment_id=comment["parent_comment_id"],
        title=comment["title"],
        text=comment["text"],
        attribute=comment["attribute"],
    )

    db.session.add(new_comment)
    db.session.commit()

    return comment["title"]  # 何かをreturnしないと500 Internal Server Errorが返ってくるので仮で


@app.route("/comment/<comment_id>", methods=["GET"])
def get_comment(comment_id):
    comment = Comment.query.filter_by(comment_id=comment_id).first()

    return render_template(
        "comment.html",
        comment_id=comment.comment_id,
        parent_comment_id=comment.parent_comment_id,
        title=comment.title,
        text=comment.text,
        attribute=comment.attribute,
    )


@app.route("/comments", methods=["GET"])
def get_comment_all():
    return render_template(
        "comments.html",
        comments=Comment.query.order_by(Comment.comment_id.desc()).all(),
    )


## CLI用 DB初期化
@app.cli.command("init-db")
def init_db():
    db.create_all()


if __name__ == "__main__":
    app.run()

バックエンド側でコメント書き込み時にコメントIDを返すようにして初日は終了です。jsonifyにlist型やdict型を渡してreturnするとheaderにContent-Type: application/jsonを付けていい感じに返してくれます。便利ですね。

https://github.com/jphacks/C_2118/commit/820217322f74a234fc74927252ee3cd06f6f0980

2日目

トップページにCSSが効いて綺麗になりました。バックエンドの進捗はなしです。

https://github.com/jphacks/C_2118/commit/4b80722dc2f67bb5a6cbca55a4adf2b4da84e5a7

Project

GitHubにはProjectというTrelloのようにカンバン式でタスク管理ができる機能があります。今まで使ったことがなかったためこの時点で触ってみました。Issueをカンバン式で管理できるので便利です。

https://docs.github.com/ja/issues/organizing-your-work-with-project-boards/managing-project-boards/about-project-boards

https://github.com/jphacks/C_2118/projects/1

3日目

進捗なし。

4日目

コメントの返信と子コメント表示

子コメントの表示ができるようになりました。

https://github.com/jphacks/C_2118/commit/906545896bbf9745a31faadcf8d5b291b7a29ef8

https://github.com/jphacks/C_2118/commit/c70fe5eb87864612ff8b22b2ce86438b7c2f616d

https://github.com/jphacks/C_2118/commit/ce4814025f73cc645c6a21f4d1663f7be3e402f9

https://github.com/jphacks/C_2118/commit/a1f9dc8e6208908b31448d0e9e75e2cb69c6a275

gooラボAPIを試す

テキストペア類似度APIで類似した意見をまとめたいのと、キーワード抽出APIでタグ検索的なものを実装したい構想があったため、この時点でAPIを試してみました。

https://github.com/jphacks/C_2118/commit/5830b0fa6d3ce82987c29a8dd75bffc40bbf730f

https://github.com/jphacks/C_2118/commit/e0c411582cffe0f42106f3db56826bc69f73a2ad

https://labs.goo.ne.jp/apiusage/

app.py
# 省略

# 環境変数
from dotenv import load_dotenv

load_dotenv()

# 省略

# キーワード抽出
def get_keywords():
    item_data = {
        "app_id": os.environ["GOO_LAB_APP_ID"],  # ここにgooラボAPIのアプリケーションID
        "title": "",  # タイトル
        "body": "",  # 本文
        "max_num": 10,
    }
    try:
        response = requests.post("https://labs.goo.ne.jp/api/keyword", json=item_data)
        response.raise_for_status()
    except requests.exceptions.RequestException as e:  # エラーの場合のみ
        print(e)
    else:  # 正常に処理された場合のみ
        response_json = json.loads(response.text)
        print(response_json)
    finally:  # 常に実行
        pass


# テキストペア類似度
def get_textpair():
    item_data = {
        "app_id": os.environ["GOO_LAB_APP_ID"],  # ここにgooラボAPIのアプリケーションID
        "text1": "",  # ここに比較するテキスト1
        "text2": "",  # ここに比較するテキスト2
    }
    try:
        response = requests.post("https://labs.goo.ne.jp/api/textpair", json=item_data)
        response.raise_for_status()
    except requests.exceptions.RequestException as e:  # エラーの場合のみ
        print(e)
    else:  # 正常に処理された場合のみ
        response_json = json.loads(response.text)
        print(response_json)
    finally:  # 常に実行
        pass

# 省略

適当なブログやヤフコメなどで試して、想定通りに動くことを確認できたところで4日目は終了です。

5日目

存在しないコメントへのアクセスに404を返す

今までは存在しないコメントへのアクセスがあると500 Internal Server Errorを吐いていましたが、ちゃんと404 Not Foundを返すようになりました。

https://github.com/jphacks/C_2118/commit/853ae60f2e075e63ac2b4c98580567af68c97d26

コメントの書き込み日時の記録

コメントの書き込み日時が記録されるようになりました。本来SQLiteにはDateTime型がないため文字列に変換する必要がありますが、SQLAlchemyがDateTime型とString型の変換をいい感じにやってくれるため、何も気にすることなくDateTime型を扱うことができます。

https://github.com/jphacks/C_2118/commit/cf709d65dfd29f85d282af7d0e53a9634ca7d4d1

https://github.com/jphacks/C_2118/commit/83caaa2ffcc9688d546579673f7832654aff1d0a

https://docs.sqlalchemy.org/en/14/core/type_basics.html#sqlalchemy.types.DateTime

フロントエンド側の日付表示がいい感じになった所で5日目は終了です。

6日目

デザインが良い感じになりました。バックエンドの進捗はなしです。

https://github.com/jphacks/C_2118/commit/2f6b8445a0e260a1abb04203517a8148d0457e90

https://github.com/jphacks/C_2118/commit/cd8412ffb145996f8887c40f5f65600cf221a5d2

https://github.com/jphacks/C_2118/commit/9c062e66af59ca5f1ab2b5eeeac6bdc6ae34a6e4

7日目

コメントID生成

ここで今までstr(random.randint(1, 100000))で誤魔化していた部分をちゃんと実装しました。

コメントID生成アルゴリズムの案としては、連番ID, UUID, 連番IDや現在時刻をmd5などでハッシュ化したものがありましたが、どれもIDの見た目が安易だったり英数字混じりで汚いということで最終的にSnowflakeというアルゴリズムを採用することにしました。

https://github.com/jphacks/C_2118/commit/3cfa6d489ca65f99490e5a3692356e8a69325ffb

SnowflakeはTwitterで使われていた(今も?)ID生成アルゴリズムです。Twitterのツイートの個別ページの例で説明するとhttps://twitter.com/<screen_name>/status/<ここがSnowflake>です。

詳しい説明は省くので以下の記事をご覧ください。

https://developer.twitter.com/ja/docs/basics/twitter-ids

https://qiita.com/kawasima/items/6b0f47a60c9cb5ffb5c4

http://mitakadai.me/archives/258

https://www.slideshare.net/moaikids/20130901-snowflake

感情分析アダプター

感情分析アダプターの動作確認ができました。追加学習のことなどを考えて感情分析アダプターは掲示板本体とは分離させてAPIとして利用する方針にしました。

ただWindows環境で動かそうとした所、長時間ハマってしまったため[2]、実装は一旦保留としました。

https://github.com/jphacks/C_2118/commit/5e95f0250f4b2bd75ed6f32f863fc53059f002c0

https://github.com/jphacks/C_2118/commit/8166f2b5efce7c65c6052b10d4d6b7639f7c180f

https://github.com/BandaiNamcoResearchInc/sentiment-analysis-adapter

8日目(Pitch Day)

キーワードと類似度

4日目に試していたgooラボAPIを掲示板本体に組み込みました。

https://github.com/jphacks/C_2118/commit/2650ba0c653b90b1fadb1ed747ef787bf253a56a

https://github.com/jphacks/C_2118/commit/afd98d2eb4bcbdf67e88b1e020368b47e3c04ecb

https://github.com/jphacks/C_2118/commit/6504c7d5b010a59d14db1a220ab2f0f29ed327bc

https://github.com/jphacks/C_2118/commit/493769a911791e4973df0d85150bda2a09776176

https://github.com/jphacks/C_2118/commit/5fb81cbaedf5cf54576f560d770ed5e42613c2ee

トップページの新着コメント表示

トップページの新着コメント表示とページ遷移を実装しました。

https://github.com/jphacks/C_2118/commit/a4a043cfd6a76c6483d7adc1471df31c108769d5

https://github.com/jphacks/C_2118/commit/6cfaa7e953936236339cf4cc0ceb16377dde956b

https://github.com/jphacks/C_2118/commit/7d5c5ec09f29e1eba49f4b06a50586cf898b83a7

https://github.com/jphacks/C_2118/commit/d327c94375ed65c84860e400d5e4826c3e98bda2

app.py
# 省略

# index.htmlの新着親コメント表示数
DISPLAY_COMMENT_COUNT = 5

# 省略

@app.route("/")
def index():
    now_page = int(request.args.get("p")) if request.args.get("p") is not None else 1
    is_last_page = Comment.query.filter_by(parent_comment_id="0").count() <= now_page * DISPLAY_COMMENT_COUNT

    return render_template(
        "index.html",
        comments=Comment.query.filter_by(parent_comment_id="0")
        .order_by(Comment.datetime.desc())
        .offset(DISPLAY_COMMENT_COUNT * (now_page - 1))
        .limit(DISPLAY_COMMENT_COUNT)
        .all(),
        now_page=now_page,
        is_last_page=is_last_page,
    )

    return render_template("index.html")

# 省略

キーワード検索

特定のキーワードを含むコメント一覧を取得できるようになりました。

https://github.com/jphacks/C_2118/commit/f473331104d33f57a0b5c5b3970ea1ecaefff8d7

https://github.com/jphacks/C_2118/commit/fa270f634ad3fb393943043193f64be16779732d

悪意のある長文コメントを弾く

文字数が異常に多いコメントがPOSTされたら400 Bad Requestを返すようにしました。

https://github.com/jphacks/C_2118/commit/e14b4cdb29ce671985fa555e2bc76751efc2b0ad

https://github.com/jphacks/C_2118/commit/120af574448437b99a78ea764fca6e841c30879e

日本時間にする

本番環境でテストした所、時間がグリニッジ標準時になってしまっていたため修正しました。

https://github.com/jphacks/C_2118/commit/d1afc6a15798d3802f0fb384c3e0ac992f44fd2a

Code Freeze

見た目を整えてアイコンやロゴを作ったりREADME.mdを書いたりしていたら時間切れ(Code Freeze)となりました。


ロゴ

https://github.com/jphacks/C_2118/commit/2632474ac2376c1f526eabaf119a983633507ee0

https://github.com/jphacks/C_2118/commit/4746ac1b831cb938d9e032fd796be0104aa0c6f0

https://github.com/jphacks/C_2118/commit/49a438bab8d1d9d7ba4865a7c12bb97ca0a1e888

https://github.com/jphacks/C_2118/commit/5a5dd22f5150bc35833682ce43dee9b8438ba9e4

https://github.com/jphacks/C_2118/commit/3d5dd379e02d74ed8aeb5817b20c89b2015b5b62

おわりに

ログイン認証, 感情分析, Vote, PWA, 画像やMarkdown対応など実装が間に合わなかった機能が多かったのが心残りです。スケジュール管理やアプリのプロトタイプをアイデア出しの段階で作っておくなどの前準備をもっとしっかりするべきでした。また発表会を通して他の学生のレベルの高さを知ることができたのは貴重な経験になったと思います。Award Day進出とならなかったことは悔しいですが、参加してよかったです。

今回のハッカソンではフロントエンドはピュアなHTML, CSS, JavaScriptを、バックエンドはPython, Flaskをメインで使用しましたが、フロントエンドではReact, Vue, SvelteなどのようなSPAフレームワークが、バックエンドでは(Pythonにおいては)FastAPIが最新の主流のようなので次はこれらを使った開発にも挑戦したいです。

脚注
  1. PWA化する予定でしたが間に合いませんでした。 ↩︎

  2. Linux環境でやるかPython 3.9以下を使うべきだった? ↩︎

Discussion