インターに通う子供が使えるWordle補助スクリプトを作った話



# Wordleとは

Wordleは、５文字の英単語を当てるゲームです。なにもヒントがない状態から、候補を入力するたびにヒントが増えていきます。ヒントは文字の色で表現され、緑色 =「確定」、黄色 =「含まれるけど位置だけ違う」、グレー =「含まれない」をそれぞれ表します。できるだけ少ない回数で正解に到達することを目的としています。問題は毎日変わり、答えをバラさずに結果をTwitterなどで共有することができます。

[Wordle - A daily word game](https://www.nytimes.com/games/wordle/index.html)

# きっかけ

ちょっと前に、子供（10歳男児）にさりげなく勧めたところ早速やり始めました。そして、それから毎夜、本を読み聞かせる前に、Wordleを各自やって、何トライで回答にたどり着けるかを競うことが僕ら親子の習慣になりました。

はじめは、スマホの辞書アプリ　COBUILDを使って、ワイルドカードで単語を絞ったりしていたのですが、確定している文字、位置（緑条件）でしか絞り込めないことから、自分でツールを作って楽をしようと思えてきました。

ついでに、子供に身近な問題をプログラミングで解決できることを見せる良い機会だと思ったのも大きなきっかけでした。

# 実行環境などの検討

ツールを作るにあたり実行環境、言語などの検討をしました。

- コマンドラインのツール
    
    うちの子供は、インターナショナルスクールに通っており、授業はノートパソコンでやっています。そのため、普段からノートパソコン（Mac)を学校に持っていっており、うちでも宿題、ゲームなどでなにかと使用しています。以前、マイクラサーバをセットアップした際、ターミナルでサーバを立ち上げる作業を教えたところ、手順通りに、苦もなくやっていましたのでコマンドラインツールでも大丈夫ではないかという結論に至りました。
    
- Pythonスクリプト
    
    単にスキル的にも時間的にもハードルが低いPythonのスクリプトにしました。
    

# **具体的な内容**

## 最終的な形

![WordleHelper_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398711/49a28c1f-86d6-8887-aec6-548d367dcec4.gif)


起動後、Green:、Yellow:と表示されるので、そこに不明な文字列をワイルドカード”?”として入力する形にしました。Grayについてはただ除外する文字を入力するようにしました。

## 辞書

おおもとの辞書はMacなどUnix系OSに最初から入っている辞書`/usr/share/dict/words`を使用しました。そこから、エディタを使用して５文字の文字列だけを正規表現で抽出して作成しました。

## 正規表現でのフィルタリング

肝である、正規表現でのフィルタリングは以下のサイトをそのまま参考にさせていただきました。

[Wordle単語を辞書から正規表現一発で検索する](https://zenn.dev/shu1007/articles/83f594adf7b6db)

肯定先読みパターン（(?=と)で囲う）をいくつかつなげて、そこで定義されるいくつかの条件にマッチするものだけをフィルタリングします。かんたんに列挙すると以下のようになります。

- 緑文字条件をフィルタリングするパターン：
文字と位置が確定している文字を指定。例： (?=a..l.)

- 黄文字条件をフィルタリングするパターン：
その文字は含まれているけと位置は違うということなので、以下の組み合わせで指定。
    - 含まれている文字を指定　例：(?=.*p)
    - 除外位置を指定　例：(?!p....)

- グレー文字条件をフィルタリングするパターン：
除外文字を指定　例：(?!.*b)

- 大文字と小文字を区別しない：先頭に(?i)をつける

以上の各文字の肯定読みパターンを５文字アルファベットパターンと結合します。

例）(?i)^(?=a..l.)(?=.*p)(?!p....)(?!.*b)[a-z]{5}$

このようなパターンを入力に応じて生成する形にしました。

## その他追加した点

プロトタイプを作成したあと、親子で使っていく過程で、追加した機能を以下に列挙します。

### 候補の並べ替え

単語の表示の順番がランダムだと使いにくいため、各単語にレーティングしてレーティングが高い順を最初の方に表示するようにしました。ルールについては、後でいくらでも変えられるので、あまりこだわらずに、目についた部分を修正する方針で、とりあえず、以下の２つのルールにしました。

- 大文字から始まるものは最後の方に表示。
- 母音(vow sound)を多く含む単語を最初の方に表示

### ステータス表示

逐次、現在のステータスを見やすく表示しようかなと思ったのですが。あまり見やすい表示になりませんでした。個人としても使っているときも、あまり見ないので、いらない機能だったのかも。。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398711/f37de15f-7a86-60a1-4b8b-4503921f3ef1.png" width="20%">

## コード
```python:wordlehelper.py
import re
import os
DISPLAY_WORD_CNT = 200

def reg_generate(green_reg, yellow_reg, gray_reg, used_char):
    # 初期化
    green_string_for_reg = ""

    # タイトル、凡例
    print()
    print("🟩🟨🟩🟨🟩🟨 Wordle Helper 🟩🟨🟩🟨🟩🟨")
    print()
    print("Input Example--------------------")
    print("Green: a????")
    print("Yellow: ????e")
    print("Gray: c,v,d")
    print("---------------------------------")
    print()
    
    # フォーマットチェック1
    def format_check1(input_string):
        for char in input_string:
            if char != "?" and not char.isalpha():
                return False
        return True
    # フォーマットチェック2
    def format_check2(input_string):
        for char in input_string:
            if char != "," and not char.isalpha():
                return False
        return True    

    # Green処理
    while True:
        green_string = input("Green: ")
        if len(green_string) == 5 and format_check1(green_string):
            for char in green_string:
                if char == "?":
                    green_string_for_reg += "."
                else:
                    green_string_for_reg += char
                    used_char += char
            green_reg += "(?=" + green_string_for_reg + ")"
            break
        elif green_string == "":
            break
        else:
            print("Please type in 5 letters word consits of only alphabet and ? or return to skip.")
            continue

    # Yellow処理
    while True:
        yellow_string = input("Yellow: ")
        if len(yellow_string) == 5 and format_check1(yellow_string):
            for index, char in enumerate(yellow_string, start=1):
                if char != "?":
                    used_char += char
                    # 含まれている文字パターン
                    yellow_reg += "(?=.*" + char + ")"
                    # 除外位置パターン
                    exclude_loc = ""
                    for i in range(1, 6):
                        if i == index:
                            exclude_loc += char
                        else:
                            exclude_loc += "."
                    yellow_reg += "(?!" + exclude_loc + ")"
            break
        elif yellow_string == "":
            break
        else:
            print("Please type in 5 letters word consits of only alphabet and ? or return to skip.")
            continue
    # Gray処理
    while True:
        gray_string = input("Gray: ")
        if format_check2(gray_string):
            exclude_chars = gray_string.replace(",","")
            for char in exclude_chars:
                if char not in used_char:
                    gray_reg += "(?!.*" + char + ")"
            break
        elif gray_string == "":
            break
        else:
            print("Please type in 5 letters word consits of only alphabet and ? or return to skip.")
            continue

                
    # regular expression return
    return green_reg, yellow_reg, gray_reg, used_char

def filter_via_regex(init_set, green_reg, yellow_reg, gray_reg):
    reg = "(?i)" + "^" + green_reg + \
                yellow_reg + gray_reg + "[a-zA-Z]{5}$"
    print()
    print("Regular Expression Pattern:", reg)
    word_set = set()
    for word in init_set:
        if re.match(reg, word):
            word_set.add(word)
    print()
    return word_set

def slot_status(green_reg, yellow_reg, gray_reg):
    # Green Status表示
    green_reg_list = green_reg.split(')')
    green_alpha_dic = {}
    for pattern in green_reg_list:
        if pattern == '':
            continue
        else:
            for i, letter in enumerate(pattern):
                if letter.isalpha():
                    green_alpha_dic[letter] = i - 2
    print("   ", end="")
    for num in range(1, 6):
        enter_flag = False
        for letter, loc in green_alpha_dic.items():
            if num == loc:
                print('\033[32m\033[07m\033[1m' + letter + '\033[0m', end=" ")
                enter_flag = True
        if enter_flag == False:
            print("?", end=" ")
    print()
    # Yello Status表示
    yellow_reg_list = yellow_reg.split('!')
    yello_alpha_dic = {}
    for pattern in yellow_reg_list:
        if pattern == '':
            continue
        elif pattern.startswith('.') or pattern[0].isalpha:
            for i, letter in enumerate(pattern[0:6]):
                if letter.isalpha():
                    yello_alpha_dic[letter] = i + 1
    for letter, loc in yello_alpha_dic.items():
        print('\033[33m\033[07m\033[1m' + letter + '\033[0m' + ": ", end="")
        for num in range(1, 6):
            if (num == loc) or (num in green_alpha_dic.values()):
                print("✕ ", end="")
            else:
                print("△ ", end="")
        print()
    # Gray Status 表示
    print()
    gray_reg_list = gray_reg.split('(')
    gray_alpha_list = []
    for pattern in gray_reg_list:
        if pattern == '':
            continue
        else:
            for letter in pattern:
                if letter.isalpha():
                    gray_alpha_list.append(letter)
    for i, letter in enumerate(gray_alpha_list):
        print("✕" + '\033[37m\033[07m\033[1m' + letter + '\033[0m', end="")
        if i == len(gray_alpha_list) - 1:
            print()
        else:
            print(", ", end="")
    print()


def candidates_show(word_set):
    # ステータス表示
    print("Match Number: ", len(word_set), end="")
    print(f" (Showing {DISPLAY_WORD_CNT})") if len(
        word_set) > DISPLAY_WORD_CNT else print("")

    # 大文字と小文字を分ける。
    upper_words = []
    lower_words = []
    for word in word_set:
        if word.islower():
            lower_words.append(word)
        else:
            upper_words.append(word)

    # 小文字のリストをRating順にソート
    lower_words_dic = dict.fromkeys(lower_words, 0)
    vow_sounds = "aiueo"
    for word in lower_words_dic:
        for letter in word:
            if letter in vow_sounds:
                lower_words_dic[word] += 1
    for word in lower_words_dic:
        for letter in word:
            if word.count(letter) > 1:
                lower_words_dic[word] -= 1
    lower_words_tup = sorted(lower_words_dic.items(),
                             key=lambda x: x[1], reverse=True)
    lower_words = [l for l, r in lower_words_tup]

    word_list = lower_words + upper_words

    # 単語のリストを出力　
    count = 0
    for word in word_list:
        print(word, end="  ")
        count += 1
        if count % 10 == 0:
            print()
            if count >= DISPLAY_WORD_CNT:
                break

def main():
    # 初期化
    used_char = ""
    green_reg = ""
    yellow_reg = ""
    gray_reg = ""

    # Word辞書ファイルを読み込んでsetに格納
    init_set = set()
    five_word_file = os.path.join(os.path.dirname(__file__), '5wordsList')
    with open(five_word_file, 'r', encoding='utf-8') as f:
        for row in f:
            init_set.add(row.strip())

    flag = True
    while flag == True:
        # 正規表現パターン作成
        green_reg, yellow_reg, gray_reg, used_char = reg_generate(
            green_reg, yellow_reg, gray_reg, used_char)

        # フィルタリング処理
        word_set = filter_via_regex(init_set, green_reg, yellow_reg, gray_reg)

        # ステータス出力
        slot_status(green_reg, yellow_reg, gray_reg)

        # 候補の表示
        candidates_show(word_set)

        # プロンプト
        print("\n")
        while flag == True:
            ctrl_string = input("Another try to narrow down? (y or n) : ")
            if ctrl_string == "n":
                flag = False
                break
            elif ctrl_string == 'y':
                break
            else:
                pass



if __name__ == '__main__':
    main()
```


# まとめ

今のところは、毎日使ってくれているみたいです。なにか「仕掛け」ができてよかったなと思います。目的を達成する最低限のツールができました。

案外、つかえるもので、とくに不満を言われたことはないです。はじめて子供に褒められました。

完全に個人用途なのですが、githubで公開しています。（拙いコードですのでご容赦ください）

https://github.com/so1earth/WordleHelper