AtCoderをrequestsで自動ログインする

## はじめに
[WINC(早稲田コンピュータ研究会) Advent Calendar 2022](https://adventar.org/calendars/8102) 20日目の記事です。

僕は最近C++でAtCoderをはじめました。過去問を解いてる際に、よくファイル名を`A.cpp`とパフォーマンスだけではなく、検索しやすいように`A_contest_name.cpp`と問題名をつけて管理してました。しかし、その作業が意外とめんどくさいので、自動でそれをやってくれるコードを書きました(ほぼ需要はない)。また、本コードはrequestsというpythonのライブラリを主に使っています。次に少しだけrequestsの解説をします。

## requestsとは
> Requests is an elegant and simple HTTP library for Python, built for human beings.

[公式サイト](https://docs.python-requests.org/en/latest/
)によると、requestsとはhttpを簡単に扱えるpythonのライブラリです。
これを使えば、APIへ自由にアクセスができ、BeautifulSoupと組み合わせクレイピングができます。sessionを保持しておくことができるので、ログインが必要なサイトでも同様に使えます。また、さまざまなデータ形式をサポートしているため、一括にファイルをアップロードや削除できないサイトのために、効率化コードを書くこともできます。便利なのでみんな使ってみれください。

https://docs.python-requests.org/en/latest/


## プログラムについて
### 概要
プログラムがやっていることは非常にシンプルで、AtCoderに自動ログインし、コンテスト名のフォルダを作成し、各問題名のファイルを作成し、テンプレートを貼り付けるだけです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/584886/8c1c34c7-1d08-5470-45cd-0fdf5184d38c.png)


### インストール
プログラムを使用する前に以下のライブラリをダウンロードしてください。
```shell
python3 -m pip install logging
python3 -m pip install tqdm
python3 -m pip install validators
python3 -m pip install beautifulsoup4
python3 -m pip install requests
```

### サンプルコード
(1) input contest name
```shell
python3 scrape.py abc278, python3 scrape.py abs
````
or

(2) input contest url
```shell
python3 scrapeABC.py https://atcoder.jp/contests/abc278 
python3 scrapeABC.py https://atcoder.jp/contests/abc278/
python3 scrapeABC.py https://atcoder.jp/contests/abc278/tasks
python3 scrapeABC.py https://atcoder.jp/contests/abc278/tasks/
```

### 全コード
```py:scrapeABC.py
import logging
import requests
import os, sys, re
from bs4 import BeautifulSoup
import validators
import urllib.parse
from tqdm import tqdm
from logging import getLogger
from time import sleep

logging.basicConfig(format = '%(asctime)s (%(msecs)03dmsec) --%(module)s.py: %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
logger = getLogger(__name__) # ログの設定

# ファイルにコピーされるテンプレート（カスタマイズ可）
TEMPLATE = \
"""
#include <bits/stdc++.h>
using namespace std; 
#define rep(i, s, n) for (int i = (s); i < (int)(n); i++) // rep(i, 0, N) cin >> D[i] 
#define all(v) v.begin(), v.end() // sort(all(v));
//---------------------------------------------------------------------------------------------------
"""
EXTENSION = 'cpp'

# 各自のユーザー名とパスワード
USERNAME = ''
PASSWORD = ''


def login():
    login_url = "https://atcoder.jp/login"
    session = requests.session() # sessionを作成
    res = session.get(login_url) # cookieを取得するためにgetでアクセス
    revel_session = res.cookies.get_dict()['REVEL_SESSION'] # revel_sessionを取得
    revel_session = urllib.parse.unquote(revel_session) # revel_sessionをデコード
    csrf_token = re.search(r'csrf_token\:(.*)_TS', revel_session).groups()[0].replace('\x00\x00', '') # csrf_tokenを正規表現で取得し、余分な文字を除去する
    sleep(1) # 取得するまで待つ
    headers = {'content-type': 'application/x-www-form-urlencoded'} # ヘッダーの定義
    params = {
      'username': USERNAME, 
      'password': PASSWORD,
      'csrf_token': csrf_token,
    } # クエリーの定義
    data = {
      'continue': 'https://atcoder.jp/'
    } # データの定義
    res = session.post(login_url, params=params, data=data, headers=headers) # 必要な情報を使ってpostでログイン
    res.raise_for_status() # http statusが異常なら例外を起こす
    return session

def validate_url(url):
    # コンテスト名とURLのバリデーション
    if(not validators.url(url)):
        url = f'https://atcoder.jp/contests/{url}'

    # URLの'/tasks'や'/'の処理
    url = url[:-1] if (url[-1] == '/') else url
    url = url if ('tasks'in url) else url + '/tasks'
    return url

def main(key):
    # ログイン
    session = login()
    url = validate_url(key)

    # コンテストページから各問題のリンクを取得
    base_url = 'https://atcoder.jp'
    try:
        response = session.get(url).text
        sleep(1)
    except requests.exceptions.RequestException as e:
        logger.error('invalid url or contest name')
        exit()
    
    # bs4で読み込む
    soup = BeautifulSoup(response, features="lxml") 

    # 各問題のリンクを取得
    td_list = soup.find_all('td', class_='text-center no-break')
    link_list = [td.find('a').get('href') for td in td_list]
    access_url_list = [base_url + link for link in link_list]

    # コンテスト名からフォルダを作成
    try:
      contest_title = soup.find('a', class_='contest-title').contents[0]
      contest_title = contest_title.strip().replace(' - ', '_').replace(' ', '_')
      os.makedirs(contest_title, exist_ok=True)
    except Exception as e:
        logger.error('contest name is not exist')
        exit()

    # 各問題のタイトルを取得し、ファイルを作成
    for url in tqdm(access_url_list):  
        response = session.get(url).text
        soup = BeautifulSoup(response, features="lxml") # bs4で読み込む
        title = soup.find('span', class_='h2').contents[0] 
        title = title.strip().replace(' - ', '_').replace(' ', '_') # スペースやハイフン(-)をアンダーバー(_)に変換
        filename = f'{contest_title}/{title}.{EXTENSION}' # プログラムファイルを生成
        with open(filename, 'w', encoding='utf-8') as f: 
            f.writelines(TEMPLATE.strip())

if __name__ == '__main__':
    try:
        key = sys.argv[1]
    except IndexError as e:
        logger.error('please input url or contest name')
        exit()
    main(key)
```


## コード解説
ライブラリをインストール
```py
import logging
import requests
import os, sys, re
from bs4 import BeautifulSoup
import validators
import urllib.parse
from tqdm import tqdm
from logging import getLogger
from time import sleep
```

テンプレート、拡張子を定義。自分のユーザー名、パスワードを定義。

```py
# ファイルにコピーされるテンプレート（カスタマイズ可）
TEMPLATE = \
"""
#include <bits/stdc++.h>
using namespace std; 
#define rep(i, s, n) for (int i = (s); i < (int)(n); i++) // rep(i, 0, N) cin >> D[i] 
#define all(v) v.begin(), v.end() // sort(all(v));
//---------------------------------------------------------------------------------------------------
"""
EXTENSION = 'cpp'

USERNAME = ''
PASSWORD = ''
```

ログイン処理をする関数、詳しくはコメント参照。
```py
def login():
    login_url = "https://atcoder.jp/login"
    session = requests.session() # sessionを作成
    res = session.get(login_url) # cookieを取得するためにgetでアクセス
    revel_session = res.cookies.get_dict()['REVEL_SESSION'] # revel_sessionを取得
    revel_session = urllib.parse.unquote(revel_session) # revel_sessionをデコード
    csrf_token = re.search(r'csrf_token\:(.*)_TS', revel_session).groups()[0].replace('\x00\x00', '') # csrf_tokenを正規表現で取得し、余分な文字を除去する
    sleep(1) # 取得するまで待つ
    headers = {'content-type': 'application/x-www-form-urlencoded'} # ヘッダーの定義
    params = {
      'username': USERNAME, 
      'password': PASSWORD,
      'csrf_token': csrf_token,
    } # クエリーの定義
    data = {
      'continue': 'https://atcoder.jp/'
    } # データの定義
    res = session.post(login_url, params=params, data=data, headers=headers) # 必要な情報を使ってpostでログイン
    res.raise_for_status() # http statusが異常なら例外をを起こす
    return session # ログイン済みのsessionを返す
```
URLのバリデーション処理をする関数、詳しくはコメント参照。
入力がコンテスト名であればそれをもとにコンテストのURLを返し、URL名であればそのまま返す。
```py
def validate_url(url):
    # コンテスト名とURLのバリデーション
    if(not validators.url(url)):
        url = f'https://atcoder.jp/contests/{url}'

    # URLの'/tasks'や'/'の処理
    url = url[:-1] if (url[-1] == '/') else url
    url = url if ('tasks'in url) else url + '/tasks'
    return url
```
main処理の関数、詳しくはコメント参照。
```py
def main(key):
    # ログイン
    session = login()
    url = validate_url(key)

    # コンテストページから各問題のリンクを取得
    base_url = 'https://atcoder.jp'
    try:
        response = session.get(url).text
        sleep(1)
    except requests.exceptions.RequestException as e:
        logger.error('invalid url or contest name')
        exit()
    
    # bs4で読み込む
    soup = BeautifulSoup(response, features="lxml") 

    # 各問題のリンクを取得
    td_list = soup.find_all('td', class_='text-center no-break')
    link_list = [td.find('a').get('href') for td in td_list]
    access_url_list = [base_url + link for link in link_list]

    # コンテスト名からフォルダを作成
    try:
      contest_title = soup.find('a', class_='contest-title').contents[0]
      contest_title = contest_title.strip().replace(' - ', '_').replace(' ', '_')
      os.makedirs(contest_title, exist_ok=True)
    except Exception as e:
        logger.error('contest name is not exist')
        exit()

    # 各問題のタイトルを取得し、ファイルを作成
    for url in tqdm(access_url_list):  
        response = session.get(url).text
        soup = BeautifulSoup(response, features="lxml") # bs4で読み込む
        title = soup.find('span', class_='h2').contents[0] 
        title = title.strip().replace(' - ', '_').replace(' ', '_') # スペースやハイフン(-)をアンダーバー(_)に変換
        filename = f'{contest_title}/{title}.{EXTENSION}' # プログラムファイルを生成
        with open(filename, 'w', encoding='utf-8') as f: 
            f.writelines(TEMPLATE.strip())

if __name__ == '__main__':
    try:
        key = sys.argv[1]
    except IndexError as e:
        logger.error('please input url or contest name')
        exit()
    main(key)
```

## 沼りポイント
### 1.ログイン時にPOSTするデータがクエリ形式だった

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/584886/b24f56c4-f2c6-d15d-206f-1c32c8227ec9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/584886/864f6a88-cb53-a47e-2c20-7bb303fc2eb8.png)
chromeの開発者モードのネットワークタブを開きながらログインすると、そのとにhttpメソッドの情報を見ることができる。一般的に、POSTメソッドはjson形式でデータを送信するが、atcoderのサイトはクエリ形式で行う。クエリ形式とは`username=xxxxxxxxx&password=xxxxxxxxx&csrf_token=hmyQfF%2Botanxyv%2BDUMCKYiLViAtM5s5F%2BbXdo9xxfVQ%3D`のような各パラメータを`=`と`&`で繋げる形式である。そのため、`post`メソッドの`params`にクエリとしての`username`、`password`、`csrf_token`を格納し、`data`にパラメータの`continue`を格納した。
```py
   params = {
      'username': USERNAME, 
      'password': PASSWORD,
      'csrf_token': csrf_token,
    } # クエリーの定義
    data = {
      'continue': 'https://atcoder.jp/'
    } # データの定義
    res = session.post(login_url, params=params, data=data, headers=headers) 
```
### 2.CSRFトークンを取得する必要があった
CSRF（Cross-site Request Forgery）トークンとは、正規のページからアクセスが行われていることを証明するための値です。そのため、一回`get`メソッドでログインurlにアクセスし、そのcookieを取得する必要があります。
```py
res = session.get(login_url) # cookieを取得するためにgetでアクセス
```
cookieを辞書型に変更し、`REVEL_SESSION`を取り出す。
```py
revel_session = res.cookies.get_dict()['REVEL_SESSION'] # revel_sessionを取得
```
一部記号が`%`にエンコードされるため、デコードする。
```py
revel_session = urllib.parse.unquote(revel_session) # revel_sessionをデコード
```
正規表現で`csrf_token`を取得する、パターンは下の図の桁数と見比べながら書いた。また、最後に変な文字コード(`'\x00\x00'`)が入るため消してあげよう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/584886/b24f56c4-f2c6-d15d-206f-1c32c8227ec9.png)

```py
csrf_token = re.search(r'csrf_token\:(.*)_TS', revel_session).groups()[0].replace('\x00\x00', '') # csrf_tokenを正規表現で取得し、余分な文字を除去する
sleep(1) # 取得するまで待つ
```

### ※補足
最初は以下のように`REVEL_CSRF`に関してエラーが出ていたため、`REVEL_CSRF`に関してググったら似たことをやってる記事を見つけて面白かった。

https://qiita.com/BambooTuna/items/946d5b1482113acba6a4

```html
<!DOCTYPE html>
<html lang="en">
        <head>
                <title>Forbidden</title>
        </head>
        <body>

        <h1>
                Forbidden
        </h1>
        <p>
                REVEL_CSRF: tokens mismatch.
        </p>

        </body>
</html>
```