😸

Dropbox API を駆使して AtCoder のテストケースを自動でダウンロードする

2021/10/14に公開

たまに AtCoder をやっていてシステムのテストケースが見たい時に、見るのが不便だなあと思うことがありました。
テストケースはここにアップロードされているのですが、

  • そもそも Dropbox でのファイル表示が見づらい
  • 入力と出力が別フォルダになっているのが不便

という難点があります。

幸いなことに、AtCoder TestCase Extension
という便利な Chrome 拡張機能が公開されているので、ほとんどの方はこれを使えばよいでしょう。ただ、自分はシークレットモードを常用しているため、あまりこの手の拡張機能を入れたくないという気持ちがありました。
また、純粋に Dropbox の共有ファイルを自動で手元にダウンロードするにはどうすればいいのだろうというエンジニア的な興味もありました。
実際、AtCoder TestCase Extension の製作者はブログ

各テストケースへのURLを取得する必要があり、DropboxのAPIからサクッと取得できると楽だったのですが、ドキュメントを調べた限りそれは難しいようでした。

と述べています。これは本当なのでしょうか?

いくらか試行錯誤した結果、Dropbox API を駆使することで、スクレイピングせずにファイルを落とせるということが分かりました。
そのノウハウを説明したいと思います。


前提として、Python の dropbox モジュールを使います。また、https://www.dropbox.com/developers/ からアクセストークンを作成する必要があります。
この際 sharing.readfiles.metadata.read のスコープ(scope)を許可するように設定してください。
また、(2021年9月頃に廃止されるようですが)可能であれば long-lived access tokens という有効期限が切れない(=セキュリティ的に危ない方の)アクセストークンを入手してください。
今後デファクトになる short-lived access token は 1 つごとに 2, 3時間程度しか持たないので、ダウンロードが長時間必要な場合不便です。
よく分からない方は、この辺のドキュメントを読んでみてください。

まず、API を操作するためのインスタンス dbx を用意します。

import dropbox
import os
import requests

dbx = dropbox.Dropbox("Dropbox のアクセストークン", scope=["sharing.read", "files.metadata.read"])

ファイルをダウンロードする上でのポイントが 3 つあります。

  1. 基本的には共有リンクの dl=0 というパラメータを dl=1 に変えればダウンロードが可能
    (ただし、一度にダウンロードできるフォルダは 20 GB 未満・合計ファイル数が 1 万件未満という制限があるため、テストケースフォルダ全体を dl=1 で一気に落とすことはできない)
  2. /files/list_folder API(dbx.files_list_folder())で、指定したパス内のフォルダの情報を列挙可能
  3. /sharing/get_shared_link_file API(dbx.sharing_get_shared_link_file())で、指定したパスの情報(共有リンク含む)を取得可能

具体的には、以下のようなコードを書けば良いです。

# AtCoder の Dropbox のリンク
SHARED_URL = "https://www.dropbox.com/sh/arnpe0ef5wds8cv/AAAk_SECQ2Nc6SVGii3rHX6Fa?dl=0"

# 各コンテストの情報が格納された配列
contests = dbx.files_list_folder(
    path="", 
    shared_link=dropbox.files.SharedLink(url=SHARED_URL, password=None)
).entries

for contest in contests:

    # あるコンテスト内の各問題の情報が格納された配列
    problems = dbx.files_list_folder(
        path=f"/{contest.name}", 
        shared_link=dropbox.files.SharedLink(url=SHARED_URL, password=None)
    ).entries

    for problem in problems:

        # 各テストケースの標準入力側が格納された配列
        in_testcases = dbx.files_list_folder(
            path=f"/{contest.name}/{problem.name}/in", 
            shared_link=dropbox.files.SharedLink(url=SHARED_URL, password=None)
        )

        # 各テストケースの標準入力側をダウンロードする
        for testcase in in_testcases.entries:

            # テストケースファイルの情報を取得
            data = dbx.sharing_get_shared_link_file(
                url=SHARED_URL, 
                path=f"/{contest.name}/{problem.name}/in/{testcase.name}"
            )
            # ダウンロード可能なURLに張り替える
            download_url = data[0].url.replace("dl=0", "dl=1")

            # Requests でダウンロードする
            res = requests.get(download_url)
            with open(f"ファイルの保存先のパス", 'wb') as f:
                f.write(res.content)


        # 各テストケースの標準出力側が格納された配列
        out_testcases = dbx.files_list_folder(
            path=f"/{contest.name}/{problem.name}/out", 
            shared_link=dropbox.files.SharedLink(url=SHARED_URL, password=None)
        )

        # 各テストケースの標準出力側をダウンロードする
        for testcase in out_testcases.entries:

            # テストケースファイルの情報を取得
            data = dbx.sharing_get_shared_link_file(
                url=SHARED_URL, 
                path=f"/{contest.name}/{problem.name}/out/{testcase.name}"
            )
            # ダウンロード可能なURLに張り替える
            download_url = data[0].url.replace("dl=0", "dl=1")

            # Requests でダウンロードする
            res = requests.get(download_url)
            with open(f"ファイルの保存先のパス", 'wb') as f:
                f.write(res.content)

なお、上のコードは最も細かい粒度で(各テストケースごとに)ダウンロードした場合ですが、単にダウンロードするだけなら、
コンテストの情報が得られた時点で dbx.sharing_get_shared_link_file() を使って各コンテストごとにフォルダをダウンロードするのが楽だと思います。
あと、実際に動かす際は念のため time.sleep(1) とかした方がいいと思います。Rate Limit の数値は明示されていませんが、あまり雑に API を叩いていると Abuse 扱いになるっぽいです(参考リンク)。

Discussion