2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AtCoder ABC のテストをコマンド一発で自動化した

Posted at

はじめに

こんばんは、最近はデータベースの勉強をしていましたわたしです。

ひとつまた日常の反復作業を利便化したいということで、今回は毎週土曜の 21:00 に行われる競技プログラミングコンテスト、AtCoder の ABC において、問題の入力出力例のテストをコマンドひとつで自動で行ってくれるスクリプトを Python で作成してみました。

ちまたでは AtCoderCLI というツールがあって今回のテストとさらに提出もやってくれるみたいなのですが、まあ自分でやってみますかということで。

なお、わたしは競プロは Go を使って解いているのでコンパイルとかは Go に合わせて説明してあります。他の言語なら他の言語でやっていただいてください。

先に完成品を見せておくとこんな感じになります。いいでしょ?

image.png

実装方法

どういう風にテストを自動化するのか、手順を先に述べておきます。

  1. ライブラリをつかって ABC の問題ページの内容を取得する
  2. そこから入力例を取得する
  3. 問題を解くソースコードをコンパイル実行し、先の入力例を読み込ませその出力結果を保持する
  4. ABC の問題サイトの出力例と出力結果を照合する
  5. すべての例の結果を調べ、それを出力する

ここからは実装の手順をひとつずつだらだら解説していくので手っ取り早く完成品を見たい方は最後まで飛ばしてください。

1. ページの取得

まずは入力例を ABC のサイトから入手するために、URL からサイトのソースを取得する部分を作りましょう。

これには Requests という Python のライブラリを使用します。pip でインストールしてから使いましょう

pip install requests

なお、わたし(Mac)の場合 pip でインストールしたら error: externally-managed-environment というエラーが出ました。これは Python がシステムで使用しているのでライブラリを安易にインストールできない仕様のようです。解決策としては pyenv などの仮想環境を使用するか、その仕様を無効にするかなどです。わたしは仮想環境を使用しました。

Requestsget メソッドを使って指定した URL にアクセスし、レスポンスを得ることが出来ます。ためしに ABC400 の A 問題をリクエストしてみましょう。

import requests

url = "https://atcoder.jp/contests/abc400/tasks/abc400_a"
response = requests.get(url)

get メソッドの結果は Response オブジェクトとして返ってきます。このオブジェクトには text プロパティがあり、これを用いると HTML のソースを文字列としてゲットできます。

htmlContent = response.text
print(htmlContent)
# print結果
# <!DOCTYPE html>
# <html>
# <head>
#     <title>A - ABC400 Party</title>
# ...

HTML のソースがゲットできていますね。

ちなみにこの結果は静的ページを取得しています、なので実際にブラウザでアクセスして表示されるコンテンツとは少し違っています。JavaScript による動的ページを生成する前のデータということですね。ブラウザの開発ツールにおける Content タブではなく Source タブから見られる情報です。もし、入力例や出力例が動的に生成されていたら少し面倒でしたが静的ページにおいておいてくれてたので安心でした。

2. 入力例と出力例を取り出す

さて、この文字列から、ブラウザでは下のように見える入力例・出力例を探して取得できればいいです。

image.png

実際、プリント結果をにらめっこすると

<div class="part">
<section>
<h3>入力例 1</h3><pre>10
</pre>

</section>
</div>

<div class="part">
<section>
<h3>出力例 1</h3><pre>40
</pre>

という部分が見つかりますので、これを取り出すことができればいいですが、文字列のままの操作はやや面倒そうです。

そこで、BeautifulSoup というライブラリを使用します。これは文字列である HTML ソースをいわゆる DOM のように使えるようにする便利なやつです。インストールして使ってみましょう。

pip install beautifulsoup4

HTML ソースは BeautifulSoup というオブジェクトとして扱います。

from bs4 import BeautifulSoup

soup = BeautifulSoup(htmlContent, "html.parser")

これで、クラス名や ID 名、タグなどで要素を取得することが出来ます。

さて、ここからはさっきの HTML ソースの文字列をにらめっこし、どうやったら例をゲットできるか考えます。

<!DOCTYPE html>
<html>
<head>...</head>
<body>
 ...
 <span class="h2">
  A - ABC400 Party
 ...
 <div id="task-statement">
  <span class="lang">
   <span class="lang-ja">
    ...
    <div class="part">
     <section>
      <h3>問題文</h3>
       ...
     </section>
    </div>
    ...
    <div class="part">
     <section>
      <h3>入力例 1</h3>
       <pre>10
       </pre>
     </section>
    </div>
    <div class="part">
     <section>
      <h3>出力例 1</h3>
       <pre>40
       </pre>
       ...
     </section>
    </div>
    ...
   </span>
   <span class="lang-en">
    ...
   </span>
  </span>
 </div>
...
</body>
</html>

重要そうな部分だけピックアップしました。問題の部分は idtask-statementdiv 要素で囲まれ、そこには日本語版と英語版の両方が書かれており、日本語版はクラス名 lang-jaspan 要素になっていることが分かります。そして、肝心の入力例や出力例の部分はクラス名が partdiv 要素で囲まれていることが分かります。さらに入力例のテキストの部分は pre というタグの要素でしまわれています。

もう少しわかりやすくするとこんな感じ。

image.png

なお、この問題は例が 3 個あるので「出力例1」の下には 2 つの例が同じく part クラスの div 要素で続いていきます。例が終わると、本物ではソースコードを打ち込むエリアになりますが、静的ページではこのあとに英語版が続いていて、これはクラス名 lang-enspan 要素です。

ターゲットは入力例と出力例のところなので、最終的には pre タグの中身がほしいです。ただ、この pre 要素はクラス名も ID 名もないため指定ができないですね... しかし、入力例 1 の pre 要素は全体の pre 要素のうち 2 番目に現れています(この前の HTML には登場していません)。

ならば、pre 要素をすべてゲットしてその 2 つめから処理していけばいいのでは!とは思いつきますが、これには 1 敗します。なぜなら次のような問題もあるからです。

image.png

入力がクエリで与えられていて、そのクエリが別で示されています。この灰色の部分は pre 要素でしたので、これでは入力例の pre が 3 つめになってしまいます。

じゃあだめだ。そこでクラス part に注目しましょう。part は「問題文」「制約」「入力」「出力」「入力例1」「出力例1」... とまとめられていて、これはどの問題でも変わりません。ならば、part の 5 つめ以降を見ればいいのでは...!

よって、クラス名を指定して要素を取り出すということをしましょう。BeautifulSoup オブジェクトに対し find メソッドを使用します。

soup = BeautifulSoup(htmlContent, "html.parser")

partElements = soup.find(class_="lang-ja").find_all(class_="part")
exampleElements = partElements[4:]

引数で class_ を指定すると、そのクラス名の要素を取り出せます。find メソッドは最初の 1 つ、find_all メソッドはすべて取り出します。上では、lang-ja のクラス名の要素の 1 つめを取り出し、さらにそこから part という名前のクラスの要素をすべて取り出しています。結果はリストで受け取れますが、ほしいのは 5 番目以降なので、スライスします。

lang-ja クラスを指定しているのは lang-en の方にも part クラスがあるからです。

いまゲットできたのは part クラスの部分なので、div 要素の中身すべてになっています。よって余計な「入力例1」などの h2 要素なども混入しています。なので、 pre 要素のみを取り出しましょう。

examples = [
    pre.find("pre").get_text().replace("\r\n", "\n")
    for pre in exampleElements
]

find で取ったあとは文字列に変換します。get_text メソッドでできます。

そのあと置換が起こっていますがこれはあとでテストの結果と照合するときに改行コードをそろえるためです。ABC の問題ページでの例はほぼ LF のようですが、たまに CRLF で記述されていることがあるのでそれの対処用です。

これで入力例と出力例の両方を文字列のリストとして取得することが出来ました。

3. コンパイルして実行

ここからは問題を解いたソースコードをスクリプトでコンパイル実行し、さっきの入力例を食べさせる、ということをやります。

どうやってコンパイルすればいいのかですが、コマンド実行ができるライブラリがあるのでそれを使いましょう。subprocess というものがあります。これの run 関数を使えばコマンド実行ができます。

import subprocess

for i, pre in enumerate(examples):
    if i % 2 == 1:
        continue

    sb = subprocess.run(
            ["go", "run", "A.go"],
            input=examples[i],
            encoding="UTF-8",
            text=True,
            capture_output=True,
        )

run 関数は引数にコマンドを受け取ります。[ ] の中に " " で囲んで書き、コマンドプロンプト上で空白にするところは , でつなぎます。

わたしは Go で競プロを解いていて、問題ごとにファイルを用意しています。よって A 問題を解く場合は A.go をコンパイルすればいいですね。ちなみに Go はコンパイルと実行を同時にやってくれる run コマンドというものがあるのでこれを使いましょう。

その他、run 関数はいろんな引数があります。input には標準入力に書くものを渡すことが出来ます。今回の入力例をわたしてあげましょう。なお、examples には入力例と出力例の両方が順番に入っているので、インデックスが奇数番号のときは処理をカットしています。

capture_outputTrue にしておくと実行結果の標準出力と標準エラー出力を取り出すことが出来ます。これは通常はバイト値になっているので text=True とすることで文字列で取り出せます。念の為、encoding="UTF-8" で UTF-8 にエンコードするようにしておきます。

4. 実行結果と出力例を照合

実行結果は stdout で呼び出すことが出来ます。

for i, pre in enumerate(examples):
    if i % 2 == 1:
        continue

    sb = subprocess.run(
        ["go", "run", f"{problem.upper()}.go"],
        input=examples[i],
        encoding="UTF-8",
        text=True,
        capture_output=True,
    )

    if sb.stdout == examples[i + 1]:
        print(f"\033[32m✅  例{i//2+1}  正解! \033[0m\n")
    else:
        print(f"\033[31m❌  例{i//2+1}  不正解 \033[0m")
        print(f"こたえの出力 {repr(examples[i + 1])}")
        print(f"あなたの出力 {repr(sb.stdout)}\n")

sb.stdout でそのまま実行結果を文字列として取り出せます。これが出力例と一致しているか確認すればいいので if 文で調べましょう。合っていたら正解、間違っていたら不正解とし、自分の結果と答えを表示するようにしてみました。

\033[32m\033[0m までの間を緑色で書くという指示です。 \033[33m にすると赤色にできます。i//2+1 となっているのは、examples の中には入力例と出力例が両方まじっているためです。最後の repr は改行コードなどのエスケープ文字をそのまま print してくださいというものです。

いまは ABC400 の A 問題を取得しているのでこれを解いて Python ファイルを実行してみましょう!

image.png

無事いい感じにテスト検証できました!試しに間違いを出すようにもしてみましょう。

image.png

うんうん、ちゃんとできてます。もうよさそう

5. 問題を指定する

もうさっきまでのでほとんど完成な気がしますが、今の時点だと ABC400 の A 問題しかできません。url の変数で問題が指定されていますが、これをいじれるようにしましょう。

スクリプト実行後の標準入力で待ってもいいですがコマンドライン引数で ABC のいくつなのか、何問題なのかを受け取れるようにします。

コマンドライン引数とは python hoge.py と実行するときにその後ろに引数を渡すことができるものです。例えば python hoge.py a とすれば a をソースコードの中で使用することができます。

ここでは次のようにして問題と ABC いくつなのかを指定するようにします。

$ python test.py a 400

こうコマンド実行すれば ABC400 の A 問題をテストできるようにします。

コマンドライン引数は sys ライブラリの argv で取り出します。これはリストになっていて初めの要素は実行したファイルのファイル名(今回だと test.py)で、2 つめから指定したものが入っています。よって次のようにすればいいということです。

import sys

clArgs = sys.argv

problem = clArgs[1]
ABCNo = clArgs[2]

url = f"https://atcoder.jp/contests/abc{ABCNo}/tasks/abc{ABCNo}_{problem}"

でも、A とか B とか問題を指定するのはいいとして、ABC いくつかを指定するのは例えばコンテスト中とかは手間ですよね。過去問を解いてるときならいいと思いますが、時間が大事なコンテスト中でえーと今日の ABC は何番だっけってのをいちいちやっていたら自動化の意味がありません。

そこで、ABC 番号が入力されなかったときは、コンテスト中を想定して今の日付から ABC 番号を逆算する処理も付け加えましょう。

結論からいうと次のように実装しました。

import sys
from datetime import datetime

clArgs = sys.argv

problem = clArgs[1]

if len(clArgs) >= 3:
    ABCNo = clArgs[2]
else:
    ABC400 = datetime(2025, 4, 5, 21, 0, 0)
    TODAY = datetime.now()

    dt = TODAY - ABC400
    ABCNo = 400 + dt.days // 7

url = f"https://atcoder.jp/contests/abc{ABCNo}/tasks/abc{ABCNo}_{problem}"

コマンドライン引数のリストが長さ 3 以上なら番号が入力されてるのでいいです。そうじゃないときは自動で ABC の番号を計算します。

その処理は ABC400 を基準に、何日ずれているかを計算して 7 で割ることで差分を出し、計算すればできます。datetime モジュールを使って日時計算をしましょう。7 で割る処理は切り捨てなので、仮に日曜日に開催されていても大丈夫です(金曜の場合は無理ですが)。

これでもう行けました!コンテスト中は番号を指定せずに問題だけでテストをしてくれます。過去問を解いてるときは番号を入れればできます。自動テストツール、完成です!!

完成!

もう大部分は完成しました。以下に完成したコードを載せます。さっきまでのとは少し変更が加わっていますが、エラー処理などをたしてあります。

import sys
import subprocess
from datetime import datetime

# require pip install
import requests
from bs4 import BeautifulSoup

clArgs = sys.argv

if len(clArgs) == 1:
    print("\033[1m\033[31mエラー:問題を指定してください\033[0m")
    sys.exit(1)

problem = clArgs[1]

if len(clArgs) >= 3:
    ABCNo = clArgs[2]
else:
    ABC400 = datetime(2025, 4, 5, 21, 0, 0)
    TODAY = datetime.now()

    dt = TODAY - ABC400
    ABCNo = 400 + dt.days // 7

url = f"https://atcoder.jp/contests/abc{ABCNo}/tasks/abc{ABCNo}_{problem}"
response = requests.get(url)

if response.status_code != 200:
    print(
        f"\033[1m\033[31mエラー:ABC{ABCNo} 問題{problem.upper()} は見つかりませんでした。ステータスコード {response.status_code}\033[0m"
    )
    sys.exit(1)

htmlContent = response.text
soup = BeautifulSoup(htmlContent, "html.parser")

partElements = soup.find(class_="lang-ja").find_all(class_="part")
exampleElements = partElements[4:]

examples = [pre.find("pre").get_text().replace("\r\n", "\n") for pre in exampleElements]

answerCnt = 0

print(f"\nABC{ABCNo} {problem.upper()}問題 テスト結果\n")

for i, pre in enumerate(examples):
    if i % 2 == 1:
        continue

    sb = subprocess.run(
        ["go", "run", f"{problem.upper()}.go"],
        input=examples[i],
        encoding="UTF-8",
        text=True,
        capture_output=True,
    )

    if sb.returncode != 0:
        print(f"\033[1m\033[31mエラー:コンパイル実行でエラーが起きました\033[0m")
        print(f"\n{sb.stderr}")
        sys.exit(1)

    if sb.stdout == examples[i + 1]:
        print(f"\033[32m✅  例{i//2+1}  正解! \033[0m\n")
        answerCnt += 1
    else:
        print(f"\033[31m❌  例{i//2+1}  不正解 \033[0m")
        print(f"こたえの出力 {repr(examples[i + 1])}")
        print(f"あなたの出力 {repr(sb.stdout)}\n")

if answerCnt == len(examples) // 2:
    print("\033[32mAll Test Passed!\033[0m\n")
else:
    print(f"\n正解数: {answerCnt} / {len(examples) // 2}\n")

  • コマンドライン引数で何も入れなかったときの強制終了処理
  • 指定された問題や ABC が存在しなかったときのエラー処理
    • レスポンスのステータスコードが 200 かどうかを確認
  • コンパイルと実行に失敗したときの処理
    • subprocess.run の結果の returncode が 0 かどうかを確認。エラーメッセージは stderr で取得できる
  • 正解したテストの数を記録し、すべてあっていたら「All Test Passed!」、間違いがあったら正解数を表示

以上の機能を追加で実装しています。

最後に、コマンドでいちいち python test.py a 400 などと打つのも面倒なのでこれはシェル側でエイリアス設定しておきましょう。

わたしは zsh を使ってるのでその設定を次のように変えました。

.zshrc
alias tesa='(){python test.py a $1}'
alias tesb='(){python test.py b $1}'
alias tesc='(){python test.py c $1}'
alias tesd='(){python test.py d $1}'
alias tese='(){python test.py e $1}'

tesa とコマンドを打てば A 問題のテストを実行してくれます。引数も用意しておき、空のときはコンテスト中、指定すればその番号の ABC の問題でテストするようになっています。

これで本当にコマンド一発で自動化!ができました!やったぜ

実行してみるとこんな感じ

image.png

うんうん、いいね。これでめちゃめちゃラクになる

おわりに

今回は ABC のテストを自動化してみました。これで今後は問題を解くのがラクになる!から、がんばれる?はい、もっと頑張ってレートをあげたいです。

ちなみに、小数で答えを出すときは対応できていません。なんか誤差がうんぬんというやつです。今回は出力と出力例を文字列で判断しているので、計算をしていないんですね。まあ、間違ってれば出力を表示するようにしてるから本番では合ってることを気づけると思うのでとりあえずこのままで。

ひさびさの Python スクリプトの作成はたのしかったですが、しばらく TypeScript を書いていたので型がないのは大変でした。特にモジュール使って取り出したやつとか、お前の正体を教えてくれって思ってました。VSCode のカーソルを重ねれば変数の型を表示してくれる機能は素晴らしい。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?