Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,22 @@ https://kyuridenamida.github.io/atcoder-tools/
## Usage


*重要: かつてパスワード入力なしでログインを実現するために`AccountInformation.py`にログイン情報を書き込むことを要求していましたが、セキュリティリスクが高すぎるため、セッション情報のみを保持する方針に切り替えました。
今後はできるだけ保持されているセッション情報を利用してAtCoderにアクセスし、必要に応じて再入力を要求します。
過去のユーザーの皆様には`AccountInformation.py`を削除して頂くようお願い申し上げます。*
*重要: AtCoderにCAPTCHAが導入されたため、従来のユーザー名/パスワードによる自動ログインが困難になりました。
現在は**ブラウザのREVEL_SESSIONクッキーを使用したログイン方式**を採用しています。
初回実行時にクッキー取得手順が表示されますので、ブラウザでAtCoderにログイン後、開発者ツールからREVEL_SESSIONクッキーの値をコピーして入力してください。
一度設定すれば、クッキーの有効期限まで自動的にログイン状態が維持されます。*

### ログイン方法 (REVEL_SESSIONクッキー)

1. ブラウザでAtCoderにログインします: https://atcoder.jp/login
2. F12キーを押して開発者ツールを開きます
3. 「Application」タブ(Firefoxの場合は「Storage」タブ)をクリック
4. 左側から「Cookies」→「https://atcoder.jp」を選択
5. 「REVEL_SESSION」の「Value」列の値をコピー
6. atcoder-toolsの実行時に表示されるプロンプトに貼り付け

このクッキー値はローカルに保存され、次回以降の自動ログインに使用されます。ユーザー名・パスワードこそ流出しないものの、この値を悪用されると第三者があなたのアカウントにログインできてしまいます。
流出しないように管理してください。LICENSEにもあるように、このツールを利用したことによるいかなる不利益に対してもatcoder-toolsの開発者は一切責任を負いません。


- `atcoder-tools gen {contest_id}` コンテスト環境を用意します。
Expand Down
122 changes: 87 additions & 35 deletions atcodertools/client/atcoder.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import getpass
import os
import re
import warnings
from http.cookiejar import LWPCookieJar
from typing import List, Optional, Tuple, Union
from typing import List, Optional, Union
from http.cookiejar import Cookie

import requests
from bs4 import BeautifulSoup
Expand Down Expand Up @@ -61,10 +61,62 @@ def __call__(cls, *args, **kwargs):
return cls._instances[cls]


def default_credential_supplier() -> Tuple[str, str]:
username = input('AtCoder username: ')
password = getpass.getpass('AtCoder password: ')
return username, password
def show_cookie_instructions():
"""Show instructions for obtaining REVEL_SESSION cookie"""
print("\n[English]")
print("=== How to get AtCoder REVEL_SESSION cookie ===")
print("1. Log in to AtCoder using your browser")
print(" https://atcoder.jp/login")
print()
print("2. Open Developer Tools by pressing F12")
print()
print("3. Follow these steps to get the REVEL_SESSION cookie:")
print(" - Click the 'Application' tab (or 'Storage' tab in Firefox)")
print(" - Select 'Cookies' → 'https://atcoder.jp' from the left sidebar")
print(" - Find 'REVEL_SESSION' and copy its 'Value'")
print()
print("4. Paste the REVEL_SESSION value below")
print("=" * 48)
print()
print("[日本語/Japanese]")
print("=== AtCoder REVEL_SESSION クッキーの取得方法 ===")
print("1. ブラウザでAtCoderにログインしてください")
print(" https://atcoder.jp/login")
print()
print("2. F12キーを押して開発者ツールを開いてください")
print()
print("3. 以下の手順でREVEL_SESSIONクッキーを取得してください:")
print(" - 「Application」タブ(Firefoxの場合は「Storage」タブ)をクリック")
print(" - 左側から「Cookies」→「https://atcoder.jp」を選択")
print(" - 「REVEL_SESSION」の「Value」列の値をコピー")
print()
print("4. 下記にREVEL_SESSIONの値を貼り付けてください")
print("=" * 48)


def default_cookie_supplier() -> Cookie:
"""Get REVEL_SESSION cookie from user input and return Cookie object"""
show_cookie_instructions()
cookie_value = input('\nREVEL_SESSION cookie value: ').strip()
return Cookie(
version=0,
name='REVEL_SESSION',
value=cookie_value,
port=None,
port_specified=False,
domain='atcoder.jp',
domain_specified=True,
domain_initial_dot=False,
path='/',
path_specified=True,
secure=True,
expires=None,
discard=True,
comment=None,
comment_url=None,
rest={},
rfc2109=False
)


class AtCoderClient(metaclass=Singleton):
Expand All @@ -75,40 +127,39 @@ def __init__(self):
def check_logging_in(self):
private_url = "https://atcoder.jp/home"
resp = self._request(private_url)
return resp.text.find("Sign In") == -1
# consider logged in if settings link exists (only shown when logged in)
return 'href="/settings"' in resp.text

def login(self,
credential_supplier=None,
cookie_supplier=None,
use_local_session_cache=True,
save_session_cache=True):

if credential_supplier is None:
credential_supplier = default_credential_supplier

if use_local_session_cache:
load_cookie_to(self._session)
if self.check_logging_in():
logger.info(
"Successfully Logged in using the previous session cache.")
logger.info(
"If you'd like to invalidate the cache, delete {}.".format(default_cookie_path))

return

username, password = credential_supplier()

soup = BeautifulSoup(self._session.get(
"https://atcoder.jp/login").text, "html.parser")
token = soup.find_all("form")[1].find(
"input", type="hidden").get("value")
resp = self._request("https://atcoder.jp/login", data={
'username': username,
"password": password,
"csrf_token": token
}, method='POST')

if resp.text.find("パスワードを忘れた方はこちら") != -1 or resp.text.find("Forgot your password") != -1:
raise LoginError
session_cache_exists = load_cookie_to(self._session)
if session_cache_exists:
if self.check_logging_in():
logger.info(
"Successfully Logged in using the previous session cache.")
logger.info(
"If you'd like to invalidate the cache, delete {}.".format(default_cookie_path))
return
else:
logger.warn(
"Failed to login with the session cache. The session cache is invalid, or has been expired. Trying to login without cache.")

if cookie_supplier is None:
cookie_supplier = default_cookie_supplier

cookie = cookie_supplier()
self._session.cookies.set_cookie(cookie)

# Verify the cookie is valid
if not self.check_logging_in():
raise LoginError(
"Login attempt failed. REVEL_SESSION cookie could be invalid or expired.")
else:
logger.info("Successfully logged in using REVEL_SESSION cookie.")

if use_local_session_cache and save_session_cache:
save_cookie(self._session)
Expand Down Expand Up @@ -159,7 +210,8 @@ def download_all_contests(self) -> List[Contest]:
contest_ids = sorted(contest_ids)
return [Contest(contest_id) for contest_id in contest_ids]

def submit_source_code(self, contest: Contest, problem: Problem, lang: Union[str, Language], source: str) -> Submission:
def submit_source_code(self, contest: Contest, problem: Problem, lang: Union[str, Language],
source: str) -> Submission:
if isinstance(lang, str):
warnings.warn(
"Parameter lang as a str object is deprecated. "
Expand Down
5 changes: 2 additions & 3 deletions atcodertools/tools/codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,8 @@ def main(prog, args, output_file=sys.stdout):
client.login(
save_session_cache=not config.etc_config.save_no_session_cache)
logger.info("Login successful.")
except LoginError:
logger.error(
"Failed to login (maybe due to wrong username/password combination?)")
except LoginError as e:
logger.error(e)
sys.exit(-1)
else:
logger.info("Downloading data without login.")
Expand Down
5 changes: 2 additions & 3 deletions atcodertools/tools/envgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,8 @@ def main(prog, args):
client.login(
save_session_cache=not config.etc_config.save_no_session_cache)
logger.info("Login successful.")
except LoginError:
logger.error(
"Failed to login (maybe due to wrong username/password combination?)")
except LoginError as e:
logger.error(e)
sys.exit(-1)
else:
logger.info("Downloading data without login.")
Expand Down
10 changes: 5 additions & 5 deletions atcodertools/tools/submit.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/python3
import argparse
import sys
import os
import sys

from colorama import Fore

Expand All @@ -18,7 +18,7 @@
from atcodertools.executils.run_command import run_command


def main(prog, args, credential_supplier=None, use_local_session_cache=True, client=None) -> bool:
def main(prog, args, cookie_supplier=None, use_local_session_cache=True, client=None) -> bool:
parser = argparse.ArgumentParser(
prog=prog,
formatter_class=argparse.RawTextHelpFormatter)
Expand Down Expand Up @@ -121,11 +121,11 @@ def main(prog, args, credential_supplier=None, use_local_session_cache=True, cli
try:
client = AtCoderClient()
client.login(save_session_cache=not args.save_no_session_cache,
credential_supplier=credential_supplier,
cookie_supplier=cookie_supplier,
use_local_session_cache=use_local_session_cache,
)
except LoginError:
logger.error("Login failed. Try again.")
except LoginError as e:
logger.error(e)
return False

tester_args = []
Expand Down
31 changes: 24 additions & 7 deletions tests/test_atcoder_client_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,32 @@ def test_submit_source_code_with_captcha_html_file(self):
@restore_client_after_run
def test_login_success(self):
self.client._request = create_fake_request_func(
post_url_to_resp={
"https://atcoder.jp/login": fake_resp("after_login.html")
}
{"https://atcoder.jp/home": fake_resp("after_login.html")},
)

def fake_supplier():
return "@@@ invalid user name @@@", "@@@ password @@@"

self.client.login(credential_supplier=fake_supplier,
def fake_cookie_supplier():
from http.cookiejar import Cookie
return Cookie(
version=0,
name='REVEL_SESSION',
value="@@@ invalid cookie @@@",
port=None,
port_specified=False,
domain='atcoder.jp',
domain_specified=True,
domain_initial_dot=False,
path='/',
path_specified=True,
secure=True,
expires=None,
discard=True,
comment=None,
comment_url=None,
rest={},
rfc2109=False
)

self.client.login(cookie_supplier=fake_cookie_supplier,
use_local_session_cache=False)

@restore_client_after_run
Expand Down
25 changes: 22 additions & 3 deletions tests/test_atcoder_client_real.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,30 @@ def test_download_problem_content(self):

@retry_once_on_failure
def test_login_failed(self):
def fake_supplier():
return "@@@ invalid user name @@@", "@@@ password @@@"
def fake_cookie_supplier():
from http.cookiejar import Cookie
return Cookie(
version=0,
name='REVEL_SESSION',
value="@@@ invalid cookie @@@",
port=None,
port_specified=False,
domain='atcoder.jp',
domain_specified=True,
domain_initial_dot=False,
path='/',
path_specified=True,
secure=True,
expires=None,
discard=True,
comment=None,
comment_url=None,
rest={},
rfc2109=False
)

try:
self.client.login(credential_supplier=fake_supplier,
self.client.login(cookie_supplier=fake_cookie_supplier,
use_local_session_cache=False)
self.fail("Unexpectedly, this test succeeded to login.")
except LoginError:
Expand Down
27 changes: 23 additions & 4 deletions tests/test_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,39 @@
"./resources/test_submit/"))


def fake_credential_suplier():
return "@@fakeuser@@", "fakepass"
def fake_cookie_supplier():
from http.cookiejar import Cookie
return Cookie(
version=0,
name='REVEL_SESSION',
value="@@@ invalid cookie @@@",
port=None,
port_specified=False,
domain='atcoder.jp',
domain_specified=True,
domain_initial_dot=False,
path='/',
path_specified=True,
secure=True,
expires=None,
discard=True,
comment=None,
comment_url=None,
rest={},
rfc2109=False
)


class TestTester(unittest.TestCase):

def test_submit_fail_when_metadata_not_found(self):
ok = submit.main(
'', ['-d', os.path.join(RESOURCE_DIR, "without_metadata")], fake_credential_suplier, False)
'', ['-d', os.path.join(RESOURCE_DIR, "without_metadata")], fake_cookie_supplier, False)
self.assertFalse(ok)

def test_test_fail(self):
ok = submit.main(
'', ['-d', os.path.join(RESOURCE_DIR, "with_metadata")], fake_credential_suplier, False)
'', ['-d', os.path.join(RESOURCE_DIR, "with_metadata")], fake_cookie_supplier, False)
self.assertFalse(ok)


Expand Down