TypeScriptでしりとりを作ってみた話

この記事ははじめてのアドベントカレンダー2024の12日目の記事です。

## はじめに
はじめまして、Latte72 です。
普段は Python や C/C++、JavaScript などを使って開発をしています。最近は自作OSや自作コンパイラ、Deep Learning に興味があり、色々と挑戦中です。 

以前に TypeScript でしりとりを実装し、ブラウザ上で利用できるようにしたので、ソースコードとともに実装の解説をしていきます。

## 完成品（完全版）
- デモページ：https://shilitori.latte72.net/shilitori.html
- ソースコード：https://github.com/latte72r/ShilitoriJS/  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448138/ba290689-b638-8125-cca9-c931e2ad1c2d.png)

## 今回の実装について
GitHub リポジトリ名が ShilitoriJS となっていることからお気づきの方もいらっしゃるかもしれませんが、このプログラムはもともと JavaScript で開発していました。
小規模なプログラムなのでそのままでも良かったのですが、最近の流行りに合わせて TypeScript に変更しました。

## しりとりのルール
実装に際してしりとりのローカルルールを定めました。どこまでが普通のルールかもよくわからないのでリストにしておきます。

### 入力・出力はすべて全角カタカナで行う
プログラムでの操作上、文字はすべてカタカナであるほうが都合が良いためです。
以前にPythonでしりとりを作った際には漢字や平仮名をカタカナに変換してくれるpykakasiというライブラリを使ったのですが今回はシンプルな実装にしたかったのでカタカナのみの入力に制限しています。
~~JavaScriptで動くカタカナ変換ライブラリを探すのが面倒くさかったからではありません、たぶん~~

### 前の単語の語尾と同じ文字から始める
これがしりとりの一番基本的なルールだと思います。
追加で以下のルールを適用するこのにします。
- 語尾が伸ばし棒「ー」の場合は一文字前の文字の母音を用います
- 語尾が小書き文字（「ャ」や「ョ」）の場合は大きい文字に変換します
- 語尾が濁音や半濁音（「バ」や「プ」）の場合は濁点や丸を外した文字に変換します
- 語尾が旧仮名遣い（「ヲ」や「ヰ」など）の場合は現代仮名遣いに変換します

### 語尾に「ン」がついたら負けである
これもしりとりの一番基本的なルールだと思います。
~~アフリカの言葉に「ン」から始まる単語があるかどうかは気にしていません。~~

### 一度使われた単語は使えません
カタカナでの配列が同じ場合は入力できません。
~~このプログラム内では「柿」と「牡蠣」は同一のものです。~~

## 実際のソースコードの解説
実際に作成したソースコードの解説を行っていきたいと思います。

### ページのHTML
まず最初にページの枠組みとなるHTMLを書いておきます。
これはとても簡略化したものなのでデザインを気にする方は[GitHubリポジトリ](https://github.com/latte72r/ShilitoriJS/)をご覧ください。

```html
<html>
<head>
    <script type="text/javascript" src="./dictionaries.js"></script>
    <script type="text/javascript" src="./shilitori.js"></script>
</head>
<body>
    <h1>Shilitori</h1>
    <div>
        <span id="label1">始：シリトリ</span>
        <div>
            <span id="label2">リ：</span>
            <input type="text" id="entry1">
        </div>
        <div>
            <input id="input1" type="button" onclick="decide()" value="決定">
            <span id="label3">１回目</span>
        </div>
    </div>
</body>
</html>
```

### 単語リストと変換用辞書

| 辞書名 | 概要 |
|:-----:|:-----|
| `vdict` | 母音辞書 (Vowel dict)  |
| `sdict` | 清音辞書 (Seione dict) |
| `wdict` | 単語辞書 (Word dict)   |

ルールの章で説明したように、このプログラムでは語尾が伸ばし棒「ー」の場合は一文字前の文字の母音を用います。
そのために`vdict`というものを導入しています。この辞書は以下のように利用します。
```typescript
console.log(vdict["プ"]) // 「ウ」が出力される
console.log(vdict["コ"]) // 「オ」が出力される
```

また、このプログラムでは語尾が小書き文字の場合は大きい文字に、濁音や半濁音の場合は濁点や丸を外した文字に、旧仮名遣いの場合は現代仮名遣いに変換します。
そのために`sdict`というものを導入しています。この辞書は以下のように利用します。
```typescript
console.log(sdict["プ"]) // 「フ」が出力される
console.log(sdict["ョ"]) // 「ヨ」が出力される
console.log(sdict["ヰ"]) // 「イ」が出力される
```

このプログラムではコンピューターから出力する単語は`wdict`という辞書に格納しています。この辞書のデータは数年前に何かのコーパスを分析し、出てきた単語のうちそれぞれのカタカナから始まる単語で頻出だったもの上位５単語を収録したものです。一部の単語は手動で入れ替えた気がします。
この辞書は以下のように利用します。

:::note info
GitHubリポジトリには頻出だったもの上位５０単語を収録した `dictionaries.ts` があります。たくさんの単語を使いたい場合はそちらを利用してください。
:::
```typescript
console.log(wdict["ア"][0]) // 「アーケード」が出力される
console.log(wdict["ア"][2]) // 「アイテム」が出力される
```

これら３つの辞書をまとめたファイルを以下に置いておきます。

<details><summary>dictionaries.ts</summary>

```typescript

const vdict: { [key: string]: string } = { "ァ": "ア", "ア": "ア", "ィ": "イ", "イ": "イ", "ゥ": "ウ", "ウ": "ウ", "ェ": "エ", "エ": "エ", "ォ": "オ", "オ": "オ", "カ": "ア", "ガ": "ア", "キ": "イ", "ギ": "イ", "ク": "ウ", "グ": "ウ", "ケ": "エ", "ゲ": "エ", "コ": "オ", "ゴ": "オ", "サ": "ア", "ザ": "ア", "シ": "イ", "ジ": "イ", "ス": "ウ", "ズ": "ウ", "セ": "エ", "ゼ": "エ", "ソ": "オ", "ゾ": "オ", "タ": "ア", "ダ": "ア", "チ": "イ", "ヂ": "イ", "ッ": "ツ", "ツ": "ウ", "ヅ": "ウ", "テ": "エ", "デ": "エ", "ト": "オ", "ド": "オ", "ナ": "ア", "ニ": "イ", "ヌ": "ウ", "ネ": "エ", "ノ": "オ", "ハ": "ア", "バ": "ア", "パ": "ア", "ヒ": "イ", "ビ": "イ", "ピ": "イ", "フ": "ウ", "ブ": "ウ", "プ": "ウ", "ヘ": "エ", "ベ": "エ", "ペ": "エ", "ホ": "オ", "ボ": "オ", "ポ": "オ", "マ": "ア", "ミ": "イ", "ム": "ウ", "メ": "エ", "モ": "オ", "ャ": "ア", "ヤ": "ア", "ュ": "ウ", "ユ": "ウ", "ョ": "オ", "ヨ": "オ", "ラ": "ア", "リ": "イ", "ル": "ウ", "レ": "エ", "ロ": "オ", "ワ": "ア", "ヰ": "イ", "ヱ": "エ", "ヲ": "オ", "ヴ": "ウ", "ン": "ン" }

const sdict: { [key: string]: string } = { "ァ": "ア", "ア": "ア", "ィ": "イ", "イ": "イ", "ゥ": "ウ", "ウ": "ウ", "ェ": "エ", "エ": "エ", "ォ": "オ", "オ": "オ", "カ": "カ", "ガ": "カ", "キ": "キ", "ギ": "キ", "ク": "ク", "グ": "ク", "ケ": "ケ", "ゲ": "ケ", "コ": "コ", "ゴ": "コ", "サ": "サ", "ザ": "サ", "シ": "シ", "ジ": "シ", "ス": "ス", "ズ": "ス", "セ": "セ", "ゼ": "セ", "ソ": "ソ", "ゾ": "ソ", "タ": "タ", "ダ": "タ", "チ": "チ", "ヂ": "チ", "ッ": "ツ", "ツ": "ツ", "ヅ": "ツ", "テ": "テ", "デ": "テ", "ト": "ト", "ド": "ト", "ナ": "ナ", "ニ": "ニ", "ヌ": "ヌ", "ネ": "ネ", "ノ": "ノ", "ハ": "ハ", "バ": "ハ", "パ": "ハ", "ヒ": "ヒ", "ビ": "ヒ", "ピ": "ヒ", "フ": "フ", "ブ": "フ", "プ": "フ", "ヘ": "ヘ", "ベ": "ヘ", "ペ": "ヘ", "ホ": "ホ", "ボ": "ホ", "ポ": "ホ", "マ": "マ", "ミ": "ミ", "ム": "ム", "メ": "メ", "モ": "モ", "ャ": "ヤ", "ヤ": "ヤ", "ュ": "ユ", "ユ": "ユ", "ョ": "ヨ", "ヨ": "ヨ", "ラ": "ラ", "リ": "リ", "ル": "ル", "レ": "レ", "ロ": "ロ", "ワ": "ワ", "ヰ": "イ", "ヱ": "エ", "ヲ": "オ", "ヴ": "ウ", "ン": "ン" }

const wdict: { [key: string]: Array<string> } = {
    'ア': ['アーケード', 'アイテ', 'アイテム', 'アイデア', 'アイドル'],
    'イ': ['イオウ', 'イガクブ', 'イキオイ', 'イコウ', 'イショウ'],
    'ウ': ['ウイング', 'ウキヨエ', 'ウケイレ', 'ウケザラ', 'ウケトリ'],
    'エ': ['エース', 'エイキョウ', 'エイコウ', 'エイゴ', 'エイセイ'],
    'オ': ['オーケストラ', 'オートバイ', 'オーナー', 'オウイ', 'オウキュウ'],
    'カ': ['カード', 'カイガイ', 'カイキュウ', 'カイシャ', 'カイチョウ'],
    'キ': ['キカイ', 'キカク', 'キギョウ', 'キコウ', 'キゴウ'],
    'ク': ['クイズ', 'クウキ', 'クウコウ', 'クウチュウ', 'クサリ'],
    'ケ': ['ケイイ', 'ケイキ', 'ケイコウ', 'ケイタイ', 'ケイトウ'],
    'コ': ['コース', 'コーナー', 'コウカ', 'コウガイ', 'コウギ'],
    'サ': ['サークル', 'サイガイ', 'サイコウ', 'サイゴ', 'サイシ'],
    'シ': ['シカク', 'シジョウ', 'システム', 'シソウ', 'シャカイ'],
    'ス': ['スーツ', 'スープ', 'スイセイ', 'スイソ', 'スイドウ'],
    'セ': ['セイカク', 'セイサク', 'セイシツ', 'セイジ', 'セイタイ'],
    'ソ': ['ソース', 'ソウカイ', 'ソウギ', 'ソウコ', 'ソウコウ'],
    'タ': ['ターミナル', 'タイケイ', 'タイコウ', 'タイサ', 'タイシュウ'],
    'チ': ['チーム', 'チカテツ', 'チキュウ', 'チケイ', 'チケット'],
    'ツ': ['ツール', 'ツアー', 'ツイデ', 'ツウカ', 'ツウコウドメ'],
    'テ': ['テープ', 'テーマ', 'テイキ', 'テイコク', 'テイスウ'],
    'ト': ['トーク', 'トーナメント', 'トイレ', 'トウキ', 'トウキョク'],
    'ナ': ['ナイカク', 'ナイガイ', 'ナイセイ', 'ナイゾウ', 'ナイター'],
    'ニ': ['ニオイ', 'ニガミ', 'ニクシミ', 'ニクタイ', 'ニセモノ'],
    'ヌ': ['ヌートリア', 'ヌードル', 'ヌイグルミ', 'ヌカヅケ', 'ヌイメ'],
    'ネ': ['ネイティブ', 'ネイロ', 'ネガイ', 'ネクタイ', 'ネジレ'],
    'ノ': ['ノート', 'ノイズ', 'ノウカ', 'ノウギョウ', 'ノウグ'],
    'ハ': ['ハイゴ', 'ハイユウ', 'ハカセ', 'ハクシャク', 'ハタケ'],
    'ヒ': ['ヒーロー', 'ヒカリ', 'ヒガイ', 'ヒガシ', 'ヒコウジョウ'],
    'フ': ['ファイル', 'フウケイ', 'フォーマット', 'フクスウ', 'フゴウ'],
    'ヘ': ['ヘイガイ', 'ヘイケ', 'ヘイシ', 'ヘイタイ', 'ヘイヤ'],
    'ホ': ['ホーム', 'ホウガク', 'ホウコウ', 'ホウシキ', 'ホウトウ'],
    'マ': ['マイク', 'マイナー', 'マイル', 'マウス', 'マエガシラ'],
    'ミ': ['ミカエリ', 'ミカタ', 'ミカド', 'ミガラ', 'ミギアシ'],
    'ム': ['ムード', 'ムービー', 'ムカイガワ', 'ムカシバナシ', 'ムカデ'],
    'メ': ['メイカ', 'メイガラ', 'メイギ', 'メイサク', 'メイシ'],
    'モ': ['モード', 'モウシコミ', 'モウシタテ', 'モウシデ', 'モウマク'],
    'ヤ': ['ヤカタ', 'ヤガイ', 'ヤキウチ', 'ヤキュウ', 'ヤクガク'],
    'ユ': ['ユーザー', 'ユートピア', 'ユーモア', 'ユアツ', 'ユイショ'],
    'ヨ': ['ヨウエキ', 'ヨウカイ', 'ヨウガク', 'ヨウキ', 'ヨウギ'],
    'ラ': ['ライス', 'ライセンス', 'ライター', 'ライダー', 'ライト'],
    'リ': ['リーグ', 'リエキ', 'リクエスト', 'リクチ', 'リスク'],
    'ル': ['ルーキー', 'ルージュ', 'ルーズリーフ', 'ルーツ', 'ルーティング'],
    'レ': ['レーサー', 'レース', 'レーダー', 'レイガイ', 'レイギ'],
    'ロ': ['ローカル', 'ロータリー', 'ロードレース', 'ロープ', 'ローラー'],
    'ワ': ['ワーク', 'ワースト', 'ワード', 'ワープロ', 'ワールド']
}
```
</details>

:::note info
以下では `pword` や `pw***` は Player's Word の略とします。
また、`cword` や `cw***` は Computer's Word の略とします。
:::

### プログラムの初期化
まず、認識できる文字リストを`clist`として定義しています。
次に機械側の単語、機械側の単語の最後の文字、使用済み単語リスト、現在の回数をグローバル変数として定義しています。

```typescript
const clist: Array<string> = Object.keys(sdict); // 文字リスト (Character list)

let cword: string; // 機械側の単語 (Computer's word)
let cwend: string; // 機械側の単語の最後の文字 (End of computer's word)
let used_words: Array<string>; // 使用済み単語リスト (Used word list)
let num_time: number; // 現在の回数 (Number of times)
```

### キー操作のバインド
毎回決定ボタンを押すのは大変なのでShiftキーが押されたらプログラムを実行するようにします。
```typescript
window.addEventListener("keydown", keyDown); // キーが押された時の設定

// キーが押された時の関数
function keyDown(event: any) {
    // Shiftなら実行
    if (event.keyCode === 16) {
        decide();
    }
}
```

### データの初期化
しりとりのデータを更新するための関数を用意します。
```typescript
// データを初期化
function refresh() {
    cword = "シリトリ"; // 機械側の単語を初期化
    cwend = cword.slice(-1); // 機械側の単語の最後の文字を初期化
    used_words = []; // 使用された単語リストを初期化
    num_time = 1; // 現在の回数を初期化
    (document.getElementById("label1") as HTMLSpanElement).innerText = "始：シリトリ"; // ラベルの文字列を初期化
    (document.getElementById("label2") as HTMLSpanElement).innerText = "リ："; // ラベルの文字列を初期化
    (document.getElementById("entry1") as HTMLInputElement).value = ""; // ラベルの文字列を初期化
    setNum() // 回数の表示を更新
}
```
### 決定ボタンが押されたとき
決定ボタンが押されたときに実行される関数を定義します。
```typescript
// 決定された時の関数
function decide() {
    let pword = (document.getElementById("entry1") as HTMLInputElement).value; // プレイヤー側の単語 (Player's word)
    let lenpw = pword.length; // プレイヤー側の単語の文字数 (Length of player's word)
    ...
}
```

### 使える単語かチェック
次の単語として適切な単語かどうかを確認します。
```typescript
    // 文字数が0文字の時は警告を出して終了
    if (lenpw === 0) {
        alert('入力してください');
        return;
    }

    // 今まで出ている単語の時は終了
    if (used_words.indexOf(pword) !== -1) {
        alert('その言葉はもう出ています');
        return;
    }

    // 認識できる文字か確認
    for (let i = 0; i < lenpw; i++) {
        let char = pword[i];
        if (clist.indexOf(char) === -1) {
            if (char !== "ー") {
                alert('文字"' + char + '"は認識できません\nカタカナで入力してください');
                return;
            }
        }
    }

    let pwfirst = sdict[pword.slice(0, 1)]; // プレイヤー側の単語の最初の文字 (First of player's word)
    let pwend = pword.slice(-1); // プレイヤー側の単語の最後の文字 (End of player's word)

    // 最初の文字と最後の文字を確認
    if (pwfirst !== cwend) {
        alert('初めの文字を確認してください');
        return;
    } else if (pwend === 'ン') {
        alert('「ン」が付きました\nあなたの負けです');
        refresh();
        return;
    }
    ...
}
```

### 語末の文字
ユーザが使った語末の文字を特定します。
```typescript
// 最後の文字が長音符なら一文字前の母音を利用
    if (pword.slice(-1) === 'ー') {
        pwend = vdict[pword.slice(-2, -1)];
    } else {
        pwend = pword.slice(-1);
    }
    pwend = sdict[pwend]; // 文字を清音に変換
    
    used_words.push(pword); // 単語を使用したリストに追加
```

### 勝ち判定
ユーザーが使った単語を除いてから勝ち判定をする。
```typescript
    // もし機械側の単語選択リストにプレイヤーの単語があれば削除
    let index = wdict[cwend].indexOf(pword);
    if (index != 0) {
        wdict[cwend].splice(index, 1);
    }

    // 勝ち判定
    if (wdict[pwend].length === 0) {
        alert('もう言葉を思いつきません\nあなたの勝ちです');
        refresh();
        return;
    }
```

### 単語を選ぶ
コンピュータが使用する単語を選びます。
```typescript
    // 単語を選ぶ
    cword = wdict[pwend][Math.floor(Math.random() * wdict[pwend].length)];

    // 選ばれた単語をリストから削除
    index = wdict[pwend].indexOf(cword);
    wdict[pwend].splice(index, 1);;

    let copy = wdict[pwend];
    copy.splice(copy.indexOf(cword), 1);
    wdict[pwend] = copy;

    // 単語を使用されたリストに追加
    used_words.push(cword);
```

### 語末の文字
選んだ単語の語末の文字を特定します。
```typescript
    // 最後の文字が長音符なら一文字前の母音を利用
    if (cword.slice(-1) === 'ー') {
        cwend = vdict[cword.slice(-2, -1)];
    } else {
        cwend = cword.slice(-1);
    }

    cwend = sdict[cwend]; // 文字を清音に変換
```

### ラベルの文字列を更新する
回数を一回増やし、表示を更新します。
```typescript
    // ラベルの文字列を変更
    num_time += 1; // 回数を進める
    (document.getElementById("label1") as HTMLSpanElement).innerText = pwend + '：' + cword;
    (document.getElementById("label2") as HTMLSpanElement).innerText = cwend + '：';
    (document.getElementById("entry1") as HTMLInputElement).value = "";
    (document.getElementById("label3") as HTMLSpanElement).innerText = String(num_time) + " 回目";
```

### TypeScriptのコード全体
<details><summary>shilitori.ts</summary>

```typescript

/*
vdict: 母音辞書 (Vowel dict)
sdict: 清音辞書 (Seione dict)
wdict: 単語辞書 (Word dict)
*/

const clist: Array<string> = Object.keys(sdict); // 文字リスト (Character list)

let cword: string = "シリトリ"; // 機械側の単語 (Computer's word)
let cwend: string = cword.slice(-1); // 機械側の単語の最後の文字 (End of computer's word)
let used_words: Array<string> = []; // 使用された単語リスト (Used word list)
let num_time: number = 1; // 現在の回数 (Number of times)

window.addEventListener("keydown", keyDown); // キーが押された時の設定

// キーが押された時の関数
function keyDown(event: any) {
    // Shiftなら実行
    if (event.keyCode === 16) {
        decide();
    }
}

// 決定された時の関数
function decide() {
    let pword = (document.getElementById("entry1") as HTMLInputElement).value; // プレイヤー側の単語 (Player's word)
    let lenpw = pword.length; // プレイヤー側の単語の文字数 (Length of player's word)

    // 文字数が0文字の時は警告を出して終了
    if (lenpw <= 0) {
        alert('入力してください');
        return;
    }

    // 今まで出ている単語の時は終了
    if (used_words.indexOf(pword) !== -1) {
        alert('その言葉はもう出ています');
        return;
    }

    // 認識できる文字か確認
    for (let i = 0; i < lenpw; i++) {
        let char = pword[i];
        if (clist.indexOf(char) === -1) {
            if (char !== "ー") {
                alert('文字"' + char + '"は認識できません\nカタカナで入力してください');
                return;
            }
        }
    }

    let pwfirst = sdict[pword.slice(0, 1)]; // プレイヤー側の単語の最初の文字 (First of player's word)
    let pwend = pword.slice(-1); // プレイヤー側の単語の最後の文字 (End of player's word)

    // 最初の文字と最後の文字を確認
    if (pwfirst !== cwend) {
        alert('初めの文字を確認してください');
        return;
    } else if (pwend === 'ン') {
        alert('「ン」が付きました\nあなたの負けです');
        refresh();
        return;
    }

    // 最後の文字が長音符なら一文字前の母音を利用
    if (pword.slice(-1) === 'ー') {
        pwend = vdict[pword.slice(-2, -1)];
    } else {
        pwend = pword.slice(-1);
    }
    pwend = sdict[pwend]; // 文字を清音に変換
    
    used_words.push(pword); // 単語を使用したリストに追加

    // もし機械側の単語選択リストにプレイヤーの単語があれば削除
    let index = wdict[cwend].indexOf(pword);
    if (index != 0) {
        wdict[cwend].splice(index, 1);
    }

    // 勝ち判定
    if (wdict[pwend].length === 0) {
        alert('もう言葉を思いつきません\nあなたの勝ちです');
        refresh();
        return;
    }

    // 単語を選ぶ
    cword = wdict[pwend][Math.floor(Math.random() * wdict[pwend].length)];

    // 選ばれた単語をリストから削除
    index = wdict[pwend].indexOf(cword);
    wdict[pwend].splice(index, 1);;

    let copy = wdict[pwend];
    copy.splice(copy.indexOf(cword), 1);
    wdict[pwend] = copy;

    // 単語を使用されたリストに追加
    used_words.push(cword);

    // 最後の文字が長音符なら一文字前の母音を利用
    if (cword.slice(-1) === 'ー') {
        cwend = vdict[cword.slice(-2, -1)];
    } else {
        cwend = cword.slice(-1);
    }

    cwend = sdict[cwend]; // 文字を清音に変換

    // ラベルの文字列を変更
    num_time += 1; // 回数を進める
    (document.getElementById("label1") as HTMLSpanElement).innerText = pwend + '：' + cword;
    (document.getElementById("label2") as HTMLSpanElement).innerText = cwend + '：';
    (document.getElementById("entry1") as HTMLInputElement).value = "";
    (document.getElementById("label3") as HTMLSpanElement).innerText = String(num_time) + " 回目";
}

// データを初期化
function refresh() {
    cword = "シリトリ"; // 機械側の単語を初期化
    cwend = cword.slice(-1); // 機械側の単語の最後の文字を初期化
    used_words = []; // 使用された単語リストを初期化
    num_time = 1; // 現在の回数を初期化

    // ラベルの文字列を初期化
    (document.getElementById("label1") as HTMLSpanElement).innerText = "始：シリトリ";
    (document.getElementById("label2") as HTMLSpanElement).innerText = "リ：";
    (document.getElementById("entry1") as HTMLInputElement).value = ""; 
    (document.getElementById("label3") as HTMLSpanElement).innerText = String(num_time) + " 回目";
}
```

</details>

## おわりに
今回のプログラムでは基本的なしりとりのルールに則ってプログラムを作成しました。普通のしりとりだけではなく「動物の名前しりとり」や「駅名しりとり」などを作ってみても面白いかもしれません。また、漢字や平仮名でも入力を受け付けられるようにライブラリを使うとユーザビリティが向上すると思います。

所属するサークルでもアドベントカレンダーをやっているのでぜひ見に来てください！
**⇒ *[Computer Society Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kcs)***