Repository: kmyk/online-judge-tools
Pull request #509 (2019-08-31 16:43:14)

RELEASE: 7.0.0
バージョンを上げ、 `develop` を `master` にマージします。

@fukatani `CHANGELOG.md` のあたりの確認をお願いします。GitHub のプルリクの画面上はコミットがたくさんですが、コミットグラフ (下図) を見れば分かるように実は実質 2 コミットだけです。

![Network graph](https://user-images.githubusercontent.com/2203128/64066800-3bdcea00-cc59-11e9-8daa-dd7ff11229ce.png)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd04a9b..82e894b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
 # Change Log
 
+## 7.0.0 / 2019-09-01
+
+-   [#508](https://github.com/kmyk/online-judge-tools/pull/508) enable to `login` with GUI browers
+-   [#505](https://github.com/kmyk/online-judge-tools/pull/505) remove `get-standings` subcommand
+-   [#503](https://github.com/kmyk/online-judge-tools/pull/503) add `generate-input` subcommand
+-   [#492](https://github.com/kmyk/online-judge-tools/pull/492) add `CodeforcesContest` class
+-   [#484](https://github.com/kmyk/online-judge-tools/pull/484) make some breaking changes in the interface as a library
+-   [#461](https://github.com/kmyk/online-judge-tools/pull/461) increase a stability of `download` subcommand
+
 ## 6.6.1 / 2019-08-19
 
 -   [#490](https://github.com/kmyk/online-judge-tools/pull/490) enable to install with old setuptools
diff --git a/LOGIN_WITH_COOKIES.md b/LOGIN_WITH_COOKIES.md
deleted file mode 100644
index 018949c..0000000
--- a/LOGIN_WITH_COOKIES.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Login with cookies
-
-If you don't want to give your password to this program, you can use this giving only your session tokens. 
-
-Here, we explain about logging in yukicoder (Japanese online judge service).
-In yukicoder, You can login with password or github account or twitter account.
-
-1. Login yukicoder with the browser.
-
-Please access <https://yukicoder.me/> and log in with the browser.
-
-2. Get cookies from the browser.
-
-I used chrome with ["EditThisCookie" Plugin](https://chrome.google.com/webstore/detail/editthiscookie/fngmhnnpilhplaeedifhccceomclgfbg?), but you can choose other plugin or other browser.
-
-Please copy `REVEL_SESSION` value.
-
-![GetCookie](https://user-images.githubusercontent.com/8858287/52058622-31674e80-25ab-11e9-840d-f3960aee8711.png)
-
-3. Generate dummy session token. (Template for rewriting later.)
-
-``` console
-$ oj d --dry-run https://yukicoder.me/problems/no/9000
-[x] problem recognized: <onlinejudge.service.yukicoder.YukicoderProblem object at 0x7fb84ffca358>: https://yukicoder.me/problems/no/9000
-[x] load cookie from: ~/.local/share/online-judge-tools/cookie.jar
-[x] GET: https://yukicoder.me/problems/no/9000
-[x] 200 OK
-[x] save cookie to: ~/.local/share/online-judge-tools/cookie.jar
-
-[*] sample 0
-[x] input: サンプル1 入力
-yukicoder
-[x] output: サンプル1 出力
-Hello World!
-```
-
-Since where `cookie.jar` is saved depends on the environment, you have to confirm that by console message.
-In this case, `cookie.jar` is saved to `~/.local/share/online-judge-tools/cookie.jar`.
-
-4. Edit `cookie.jar`.
-
-Open `cookie.jar` with your text editer, you can find session cookie for yukicoder.
-
-```
-Set-Cookie3: REVEL_SESSION="XXXXX"; path="/"; domain="yukicoder.me"; path_spec; expires="2019-02-28 14:53:09Z"; HttpOnly=None; version=0
-```
-
-Please replace current `REVEL_SESSION` value to value which you copied by the browser.
-
-5. Confirm logged in.
-
-``` console
-$ oj login --check https://yukicoder.me/
-[x] service recognized: <onlinejudge.service.yukicoder.YukicoderService object at 0x7f4467a0a2b0>: https://yukicoder.me/
-[x] load cookie from: ~/.local/share/online-judge-tools/cookie.jar
-[x] GET: https://yukicoder.me/auth/github
-[x] 302 Found
-[*] You have already signed in.
-[x] save cookie to: ~/.local/share/online-judge-tools/cookie.jar
-```
-
-You can find the sentence `You have already signed in.`.
-Now, you can download testcases from yukicoder.
-
diff --git a/onlinejudge/__about__.py b/onlinejudge/__about__.py
index 0f8b890..7f1a14f 100644
--- a/onlinejudge/__about__.py
+++ b/onlinejudge/__about__.py
@@ -4,6 +4,6 @@ __author__ = 'Kimiyuki Onaka'
 __email__ = 'kimiyuki95@gmail.com'
 __license__ = 'MIT License'
 __url__ = 'https://github.com/kmyk/online-judge-tools'
-__version_info__ = (6, 6, 1, 'final', 0)
+__version_info__ = (7, 0, 0, 'final', 0)
 __version__ = '.'.join(map(str, __version_info__[:3]))
 __description__ = 'Tools for online-judge services'
diff --git a/onlinejudge/_implementation/command/generate_input.py b/onlinejudge/_implementation/command/generate_input.py
new file mode 100644
index 0000000..b733379
--- /dev/null
+++ b/onlinejudge/_implementation/command/generate_input.py
@@ -0,0 +1,231 @@
+# Python Version: 3.x
+import argparse
+import concurrent.futures
+import contextlib
+import itertools
+import os
+import pathlib
+import threading
+from typing import *
+
+import onlinejudge._implementation.format_utils as fmtutils
+import onlinejudge._implementation.logging as log
+import onlinejudge._implementation.utils as utils
+
+
+@contextlib.contextmanager
+def BufferedExecutor(lock: Optional[threading.Lock]):
+    buf = []  # type: List[Tuple[Callable, List[Any], Dict[str, Any]]]
+
+    def submit(f, *args, **kwargs):
+        nonlocal buf
+        if lock is None:
+            f(*args, **kwargs)
+        else:
+            buf += [(f, args, kwargs)]
+
+    result = yield submit
+
+    if lock is not None:
+        with lock:
+            for f, args, kwargs in buf:
+                f(*args, **kwargs)
+    return result
+
+
+def write_result(input_data: bytes, output_data: Optional[bytes], *, input_path: pathlib.Path, output_path: pathlib.Path, print_data: bool, lock: Optional[threading.Lock] = None) -> None:
+    # acquire lock to print logs properly, if in parallel
+    nullcontext = contextlib.ExitStack()
+    with lock or nullcontext:
+
+        if not input_path.parent.is_dir():
+            os.makedirs(str(input_path.parent), exist_ok=True)
+
+        if print_data:
+            log.emit('input:')
+            log.emit(utils.snip_large_file_content(input_data, limit=40, head=20, tail=10, bold=True))
+        with input_path.open('wb') as fh:
+            fh.write(input_data)
+        log.success('saved to: %s', input_path)
+
+        if output_data is not None:
+            if print_data:
+                log.emit('output:')
+                log.emit(utils.snip_large_file_content(output_data, limit=40, head=20, tail=10, bold=True))
+            with output_path.open('wb') as fh:
+                fh.write(output_data)
+            log.success('saved to: %s', output_path)
+
+
+def check_status(info, proc, *, submit):
+    submit(log.status, 'time: %f sec', info['elapsed'])
+    if proc.returncode is None:
+        submit(log.failure, log.red('TLE'))
+        submit(log.info, 'skipped.')
+        return False
+    elif proc.returncode != 0:
+        submit(log.failure, log.red('RE') + ': return code %d', proc.returncode)
+        submit(log.info, 'skipped.')
+        return False
+    assert info['answer'] is not None
+    return True
+
+
+def generate_input_single_case(generator: str, *, input_path: pathlib.Path, output_path: pathlib.Path, command: Optional[str], tle: Optional[float], name: str, lock: Optional[threading.Lock] = None) -> None:
+    with BufferedExecutor(lock) as submit:
+
+        # print the header
+        submit(log.emit, '')
+        submit(log.info, '%s', name)
+
+        # generate input
+        submit(log.status, 'generate input...')
+        info, proc = utils.exec_command(generator, timeout=tle)
+        input_data = info['answer']  # type: bytes
+        if not check_status(info, proc, submit=submit):
+            return None
+
+        # generate output
+        if command is None:
+            output_data = None  # type: Optional[bytes]
+        else:
+            submit(log.status, 'generate output...')
+            info, proc = utils.exec_command(command, input=input_data, timeout=tle)
+            output_data = info['answer']
+            if not check_status(info, proc, submit=submit):
+                return None
+
+        # write result
+        submit(write_result, input_data=input_data, output_data=output_data, input_path=input_path, output_path=output_path, print_data=True)
+
+
+def simple_match(a: str, b: str) -> bool:
+    if a == b:
+        return True
+    if a.rstrip() == b.rstrip():
+        log.warning('WA if no rstrip')
+        return True
+    return False
+
+
+def try_hack_once(generator: str, command: str, hack: str, *, tle: Optional[float], attempt: int, lock: Optional[threading.Lock] = None) -> Optional[Tuple[bytes, bytes]]:
+    with BufferedExecutor(lock) as submit:
+
+        # print the header
+        submit(log.emit, '')
+        submit(log.info, '%d-th attempt', attempt)
+
+        # generate input
+        submit(log.status, 'generate input...')
+        info, proc = utils.exec_command(generator, stdin=None, timeout=tle)
+        input_data = info['answer']  # type: Optional[bytes]
+        if not check_status(info, proc, submit=submit):
+            return None
+        assert input_data is not None
+
+        # generate output
+        submit(log.status, 'generate output...')
+        info, proc = utils.exec_command(command, input=input_data, timeout=tle)
+        output_data = info['answer']  # type: Optional[bytes]
+        if not check_status(info, proc, submit=submit):
+            return None
+        assert output_data is not None
+
+        # hack
+        submit(log.status, 'hack...')
+        info, proc = utils.exec_command(hack, input=input_data, timeout=tle)
+        answer = (info['answer'] or b'').decode()  # type: str
+        elapsed = info['elapsed']  # type: float
+        memory = info['memory']  # type: Optional[float]
+
+        # compare
+        status = 'AC'
+        if proc.returncode is None:
+            submit(log.failure, log.red('TLE'))
+            status = 'TLE'
+        elif proc.returncode != 0:
+            log.failure(log.red('RE') + ': return code %d', proc.returncode)
+            status = 'RE'
+        expected = output_data.decode()
+        if not simple_match(answer, expected):
+            log.failure(log.red('WA'))
+            log.emit('input:\n%s', utils.snip_large_file_content(input_data, limit=40, head=20, tail=10, bold=True))
+            log.emit('output:\n%s', utils.snip_large_file_content(answer.encode(), limit=40, head=20, tail=10, bold=True))
+            log.emit('expected:\n%s', utils.snip_large_file_content(output_data, limit=40, head=20, tail=10, bold=True))
+            status = 'WA'
+
+        if status == 'AC':
+            return None
+        else:
+            return (input_data, output_data)
+
+
+def generate_input(args: argparse.Namespace) -> None:
+    if args.hack and not args.command:
+        raise RuntimeError('--hack must be used with --command')
+
+    if args.name is None:
+        if args.hack:
+            args.name = 'hack'
+        else:
+            args.name = 'random'
+
+    if args.count is None:
+        if args.hack:
+            args.count = 1
+        else:
+            args.count = 100
+
+    def iterate_path():
+        for i in itertools.count():
+            name = '{}-{}'.format(args.name, str(i).zfill(args.width))
+            input_path = fmtutils.path_from_format(args.directory, args.format, name=name, ext='in')
+            output_path = fmtutils.path_from_format(args.directory, args.format, name=name, ext='out')
+            if not input_path.exists() and not output_path.exists():
+                yield (name, input_path, output_path)
+
+    # generate cases
+    if args.jobs is None:
+        for name, input_path, output_path in itertools.islice(iterate_path(), args.count):
+            if not args.hack:
+                # generate serially
+                generate_input_single_case(args.generator, input_path=input_path, output_path=output_path, command=args.command, tle=args.tle, name=name)
+
+            else:
+                # hack serially
+                for attempt in itertools.count(1):
+                    data = try_hack_once(args.generator, command=args.command, hack=args.hack, tle=args.tle, attempt=attempt)
+                    if data is not None:
+                        write_result(*data, input_path=input_path, output_path=output_path, print_data=False)
+                        break
+    else:
+        with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as executor:
+            lock = threading.Lock()
+            futures = []  # type: List[concurrent.futures.Future]
+
+            if not args.hack:
+                # generate concurrently
+                for name, input_path, output_path in itertools.islice(iterate_path(), args.count):
+                    futures += [executor.submit(generate_input_single_case, args.generator, input_path=input_path, output_path=output_path, command=args.command, tle=args.tle, name=name, lock=lock)]
+                for future in futures:
+                    future.result()
+
+            else:
+                # hack concurrently
+                attempt = 0
+                for _ in range(args.jobs):
+                    attempt += 1
+                    futures += [executor.submit(try_hack_once, args.generator, command=args.command, hack=args.hack, tle=args.tle, attempt=attempt, lock=lock)]
+                for _, input_path, output_path in itertools.islice(iterate_path(), args.count):
+                    data = None
+                    while data is None:
+                        concurrent.futures.wait(futures, return_when=concurrent.futures.FIRST_COMPLETED)
+                        for i in range(len(futures)):
+                            if not futures[i].done():
+                                continue
+                            data = futures[i].result()
+                            attempt += 1
+                            futures[i] = executor.submit(try_hack_once, args.generator, command=args.command, hack=args.hack, tle=args.tle, attempt=attempt, lock=lock)
+                            if data is not None:
+                                break
+                    write_result(*data, input_path=input_path, output_path=output_path, print_data=False, lock=lock)
diff --git a/onlinejudge/_implementation/command/get_standings.py b/onlinejudge/_implementation/command/get_standings.py
deleted file mode 100644
index 7620146..0000000
--- a/onlinejudge/_implementation/command/get_standings.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# Python Version: 3.x
-import json
-import sys
-from typing import *
-
-import onlinejudge
-
-if TYPE_CHECKING:
-    import argparse
-
-
-def get_standings(args: 'argparse.Namespace') -> None:
-    # parse url
-    problem = onlinejudge.dispatch.problem_from_url(args.url)
-    if problem is None:
-        sys.exit(1)
-
-    # get standings
-    header, rows = problem.get_standings()  # type: ignore
-
-    # print it
-    if args.format in ['csv', 'tsv']:
-        sep = {'csv': ',', 'tsv': '\t'}[args.format]
-        print(*header, sep=sep)
-        for row in rows:
-            print(*[row[col] if row[col] is not None else '' for col in header], sep=sep)
-    elif args.format == 'json':
-        print(json.dumps(rows))
-    else:
-        assert False
diff --git a/onlinejudge/_implementation/command/login.py b/onlinejudge/_implementation/command/login.py
index 9dda6f3..7631bae 100644
--- a/onlinejudge/_implementation/command/login.py
+++ b/onlinejudge/_implementation/command/login.py
@@ -1,8 +1,13 @@
 # Python Version: 3.x
+import datetime
 import getpass
+import http.cookies
 import sys
+import time
 from typing import *
 
+import requests
+
 import onlinejudge
 import onlinejudge._implementation.logging as log
 import onlinejudge._implementation.utils as utils
@@ -11,48 +16,113 @@ if TYPE_CHECKING:
     import argparse
 
 
+def login_with_password(service: onlinejudge.type.Service, *, username: Optional[str], password: Optional[str], session: requests.Session) -> None:
+    def get_credentials() -> Tuple[str, str]:
+        nonlocal username, password
+        if username is None:
+            username = input('Username: ')
+        if password is None:
+            password = getpass.getpass()
+        return username, password
+
+    service.login(get_credentials=get_credentials, session=session)
+
+
+# a wrapper class, because selenium.common.exceptions.* is not always imported
+class WebDriverException(Exception):
+    pass
+
+
+def login_with_browser(service: onlinejudge.type.Service, *, session: requests.Session) -> None:
+    try:
+        import selenium.webdriver
+    except ImportError:
+        raise
+
+    try:
+        profile = selenium.webdriver.FirefoxProfile()
+        profile.set_preference("general.useragent.override", session.headers['User-Agent'])
+        driver = selenium.webdriver.Firefox(firefox_profile=profile)
+    except selenium.common.exceptions.WebDriverException as e:
+        raise WebDriverException(e)
+
+    # get cookies via Selenium
+    url = service.get_url_of_login_page()
+    log.info('open with WebDriver: %s', url)
+    driver.get(url)
+    cookies = []  # type: List[Dict[str, str]]
+    try:
+        while driver.current_url:
+            cookies = driver.get_cookies()
+            time.sleep(0.1)
+    except selenium.common.exceptions.NoSuchWindowException:
+        pass  # the window is closed
+
+    # set cookies to the requests.Session
+    log.info('copy cookies from WebDriver')
+    for c in cookies:
+        log.status('set cookie: %s', c['name'])
+        morsel = http.cookies.Morsel()  # type: http.cookies.Morsel
+        morsel.set(c['name'], c['value'], c['value'])
+        morsel.update({key: value for key, value in c.items() if morsel.isReservedKey(key)})
+        if not morsel['expires']:
+            expires = datetime.datetime.now(datetime.timezone.utc).astimezone() + datetime.timedelta(days=180)
+            morsel.update({'expires': expires.strftime('%a, %d-%b-%Y %H:%M:%S GMT')})  # RFC2109 format
+        cookie = requests.cookies.morsel_to_cookie(morsel)
+        session.cookies.set_cookie(cookie)  # type: ignore
+
+
+def is_logged_in_with_message(service: onlinejudge.type.Service, *, session: requests.Session) -> bool:
+    if service.is_logged_in(session=session):
+        log.info('You have already signed in.')
+        return True
+    else:
+        log.warning('You are not signed in.')
+        return False
+
+
 def login(args: 'argparse.Namespace') -> None:
-    # get service
     service = onlinejudge.dispatch.service_from_url(args.url)
     if service is None:
         sys.exit(1)
 
-    # configure
-    kwargs = {}
-    if isinstance(service, onlinejudge.service.yukicoder.YukicoderService):
-        if not args.method:
-            args.method = 'github'
-        if args.method not in ['github', 'twitter']:
-            log.failure('login for yukicoder: invalid option: --method %s', args.method)
-            sys.exit(1)
-        kwargs['method'] = args.method
-    else:
-        if args.method:
-            log.failure('login for %s: invalid option: --method %s', service.get_name(), args.method)
-            sys.exit(1)
-
-    with utils.with_cookiejar(utils.new_session_with_our_user_agent(), path=args.cookie) as sess:
-
-        if args.check:
-            if service.is_logged_in(session=sess):
-                log.info('You have already signed in.')
-            else:
-                log.info('You are not signed in.')
-                sys.exit(1)
+    with utils.with_cookiejar(utils.new_session_with_our_user_agent(), path=args.cookie) as session:
 
+        if is_logged_in_with_message(service, session=session):
+            return
         else:
-            # login
-            def get_credentials() -> Tuple[str, str]:
-                if args.username is None:
-                    args.username = input('Username: ')
-                if args.password is None:
-                    args.password = getpass.getpass()
-                return args.username, args.password
+            if args.check:
+                sys.exit(1)
 
-            log.warning('If you don\'t want to give your password to this program, you can give only your session tokens.')
-            log.info('see: https://github.com/kmyk/online-judge-tools/blob/master/LOGIN_WITH_COOKIES.md')
+        if args.use_browser in ('always', 'auto'):
+            try:
+                login_with_browser(service, session=session)
+            except ImportError:
+                log.error('Selenium is not installed: try $ pip3 install selenium')
+                pass
+            except WebDriverException as e:
+                log.error('%s', e)
+                pass
+            else:
+                if is_logged_in_with_message(service, session=session):
+                    return
+                else:
+                    sys.exit(1)
 
+        if args.use_browser in ('never', 'auto'):
+            if args.use_browser == 'auto':
+                log.warning('use CUI login since Selenium fails')
             try:
-                service.login(get_credentials, session=sess, **kwargs)  # type: ignore
-            except onlinejudge.type.LoginError:
+                login_with_password(service, username=args.username, password=args.password, session=session)
+            except NotImplementedError as e:
+                log.error('%s', e)
                 pass
+            except onlinejudge.type.LoginError:
+                sys.exit(1)
+            else:
+                if is_logged_in_with_message(service, session=session):
+                    return
+                else:
+                    sys.exit(1)
+
+        sys.exit(1)
diff --git a/onlinejudge/_implementation/main.py b/onlinejudge/_implementation/main.py
index 86ca1c5..58051bf 100644
--- a/onlinejudge/_implementation/main.py
+++ b/onlinejudge/_implementation/main.py
@@ -13,8 +13,8 @@ import onlinejudge.__about__ as version
 import onlinejudge._implementation.logging as log
 import onlinejudge._implementation.utils as utils
 from onlinejudge._implementation.command.download import download
+from onlinejudge._implementation.command.generate_input import generate_input
 from onlinejudge._implementation.command.generate_output import generate_output
-from onlinejudge._implementation.command.get_standings import get_standings
 from onlinejudge._implementation.command.login import login
 from onlinejudge._implementation.command.split_input import split_input, split_input_auto_footer
 from onlinejudge._implementation.command.submit import submit
@@ -81,16 +81,12 @@ supported services:
   Topcoder
   HackerRank
   Toph
-
-strings for --method:
-  github                for yukicoder, login via github (default)
-  twitter               for yukicoder, login via twitter (not implementated yet)
 ''')
     subparser.add_argument('url')
     subparser.add_argument('-u', '--username')
     subparser.add_argument('-p', '--password')
     subparser.add_argument('--check', action='store_true', help='check whether you are logged in or not')
-    subparser.add_argument('--method')
+    subparser.add_argument('--use-browser', choices=('always', 'auto', 'never'), default='auto', help='specify whether it uses a GUI web browser to login or not  (default: auto)')
 
     # submit
     subparser = subparsers.add_parser('submit', aliases=['s'], help='submit your solution', formatter_class=argparse.RawTextHelpFormatter, epilog='''\
@@ -172,6 +168,27 @@ tips:
     subparser.add_argument('--no-ignore-backup', action='store_false', dest='ignore_backup')
     subparser.add_argument('--ignore-backup', action='store_true', help='ignore backup files and hidden files (i.e. files like "*~", "\\#*\\#" and ".*") (default)')
 
+    # generate input
+    subparser = subparsers.add_parser('generate-input', aliases=['g/i'], help='generate input files form given generator', formatter_class=argparse.RawTextHelpFormatter, epilog='''\
+format string for --format:
+  %s                    name
+  %e                    extension: "in" or "out"
+  (both %d and %e are required.)
+
+tips:
+  You can do similar things with shell: e.g. `for i in {000..099} ; do ./generate.py > test/random-$i.in ; done`
+''')
+    subparser.add_argument('-f', '--format', default='%s.%e', help='a format string to recognize the relationship of test cases. (default: "%%s.%%e")')
+    subparser.add_argument('-d', '--directory', type=pathlib.Path, default=pathlib.Path('test'), help='a directory name for test cases (default: test/)')
+    subparser.add_argument('-t', '--tle', type=float, help='set the time limit (in second) (default: inf)')
+    subparser.add_argument('-j', '--jobs', type=int, help='run tests in parallel')
+    subparser.add_argument('--width', type=int, default=3, help='specify the width of indices of cases. (default: 3)')
+    subparser.add_argument('--name', help='specify the base name of cases. (default: "random")')
+    subparser.add_argument('-c', '--command', help='specify your solution to generate output')
+    subparser.add_argument('--hack', help='specify your solution to be compared the reference solution given by --command')
+    subparser.add_argument('generator', type=str, help='your program to generate test cases')
+    subparser.add_argument('count', nargs='?', type=int, help='the number of cases to generate (default: 100)')
+
     # split input
     subparser = subparsers.add_parser('split-input', help='split a input file which contains many cases, using your implementation  (experimental)', formatter_class=argparse.RawTextHelpFormatter, epilog='''\
 format string for --output:
@@ -238,14 +255,6 @@ example:
     subparser.add_argument('-c', '--command', default='./a.out', help='your solution to be tested. (default: "./a.out")')
     subparser.add_argument('judge', help='judge program using standard I/O')
 
-    # get standings
-    subparser = subparsers.add_parser('get-standings', help='get and print the standings  (experimental)', formatter_class=argparse.RawTextHelpFormatter, epilog='''\
-supported services:
-  Topcoder (Marathon Match)
-''')
-    subparser.add_argument('url')
-    subparser.add_argument('-f', '--format', choices=['csv', 'tsv', 'json'], default='tsv', help='default: tsv')
-
     return parser
 
 
@@ -269,10 +278,10 @@ def run_program(args: argparse.Namespace, parser: argparse.ArgumentParser) -> No
         test_reactive(args)
     elif args.subcommand in ['generate-output', 'g/o']:
         generate_output(args)
+    elif args.subcommand in ['generate-input', 'g/i']:
+        generate_input(args)
     elif args.subcommand == 'split-input':
         split_input(args)
-    elif args.subcommand == 'get-standings':
-        get_standings(args)
     else:
         parser.print_help(file=sys.stderr)
         sys.exit(1)
@@ -298,6 +307,9 @@ def main(args: Optional[List[str]] = None) -> None:
         log.error(str(e))
         log.debug(traceback.format_exc())
         sys.exit(1)
+    except onlinejudge.type.SampleParseError:
+        log.error('Failed to parse sample.')
+        sys.exit(1)
 
 
 if __name__ == '__main__':
diff --git a/onlinejudge/_implementation/utils.py b/onlinejudge/_implementation/utils.py
index 722857c..2704f8a 100644
--- a/onlinejudge/_implementation/utils.py
+++ b/onlinejudge/_implementation/utils.py
@@ -134,7 +134,10 @@ def textfile(s: str) -> str:  # should have trailing newline
         return s + '\n'
 
 
-def exec_command(command_str: str, *, stdin: IO[Any], timeout: Optional[float] = None, gnu_time: Optional[str] = None) -> Tuple[Dict[str, Any], subprocess.Popen]:
+def exec_command(command_str: str, *, stdin: Optional[IO[Any]] = None, input: Optional[bytes] = None, timeout: Optional[float] = None, gnu_time: Optional[str] = None) -> Tuple[Dict[str, Any], subprocess.Popen]:
+    if input is not None:
+        assert stdin is None
+        stdin = subprocess.PIPE  # type: ignore
     if gnu_time is not None:
         context = tempfile.NamedTemporaryFile(delete=True)  # type: Any
     else:
@@ -157,7 +160,7 @@ def exec_command(command_str: str, *, stdin: IO[Any], timeout: Optional[float] =
             log.error('Permission denied: %s', command)
             sys.exit(1)
         try:
-            answer, _ = proc.communicate(timeout=timeout)
+            answer, _ = proc.communicate(input=input, timeout=timeout)
         except subprocess.TimeoutExpired:
             proc.terminate()
             answer = None
diff --git a/onlinejudge/dispatch.py b/onlinejudge/dispatch.py
index e51ba17..ed091ac 100644
--- a/onlinejudge/dispatch.py
+++ b/onlinejudge/dispatch.py
@@ -22,7 +22,7 @@
 from typing import List, Optional, Type
 
 import onlinejudge._implementation.logging as log
-from onlinejudge.type import Problem, Service, Submission
+from onlinejudge.type import Contest, Problem, Service, Submission
 
 submissions = []  # type: List[Type['Submission']]
 
@@ -61,6 +61,19 @@ def problem_from_url(url: str) -> Optional[Problem]:
     return None
 
 
+contests = []  # type: List[Type['Contest']]
+
+
+def contest_from_url(url: str) -> Optional[Contest]:
+    for cls in contests:
+        contest = cls.from_url(url)
+        if contest is not None:
+            log.status('contest recognized: %s: %s', str(contest), url)
+            return contest
+    log.failure('unknown contest: %s', url)
+    return None
+
+
 services = []  # type: List[Type['Service']]
 
 
diff --git a/onlinejudge/service/anarchygolf.py b/onlinejudge/service/anarchygolf.py
index e30b503..af6414b 100644
--- a/onlinejudge/service/anarchygolf.py
+++ b/onlinejudge/service/anarchygolf.py
@@ -33,10 +33,10 @@ class AnarchyGolfService(onlinejudge.type.Service):
 
 
 class AnarchyGolfProblem(onlinejudge.type.Problem):
-    def __init__(self, problem_id: str):
+    def __init__(self, *, problem_id: str):
         self.problem_id = problem_id
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
         session = session or utils.get_default_session()
         # get
         resp = utils.request('GET', self.get_url(), session=session)
@@ -81,7 +81,7 @@ class AnarchyGolfProblem(onlinejudge.type.Problem):
                 and result.netloc == 'golf.shinh.org' \
                 and utils.normpath(result.path) == '/p.rb' \
                 and result.query:
-            return cls(result.query)
+            return cls(problem_id=result.query)
         return None
 
 
diff --git a/onlinejudge/service/aoj.py b/onlinejudge/service/aoj.py
index 1cadaf3..fcbf6dd 100644
--- a/onlinejudge/service/aoj.py
+++ b/onlinejudge/service/aoj.py
@@ -42,10 +42,10 @@ class AOJProblem(onlinejudge.type.Problem):
     """
     :ivar problem_id: :py:class:`str` like `DSL_1_A` or `2256`
     """
-    def __init__(self, problem_id):
+    def __init__(self, *, problem_id):
         self.problem_id = problem_id
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         session = session or utils.get_default_session()
         # get samples via the official API
         # reference: http://developers.u-aizu.ac.jp/api?key=judgedat%2Ftestcases%2Fsamples%2F%7BproblemId%7D_GET
@@ -62,7 +62,7 @@ class AOJProblem(onlinejudge.type.Problem):
             )]
         return samples
 
-    def download_system_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_system_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         session = session or utils.get_default_session()
 
         # get header
@@ -103,7 +103,7 @@ class AOJProblem(onlinejudge.type.Problem):
                 and querystring.get('id') \
                 and len(querystring['id']) == 1:
             n, = querystring['id']
-            return cls(n)
+            return cls(problem_id=n)
 
         # example: https://onlinejudge.u-aizu.ac.jp/challenges/sources/JAG/Prelim/2881
         # example: https://onlinejudge.u-aizu.ac.jp/courses/library/4/CGL/3/CGL_3_B
@@ -112,7 +112,7 @@ class AOJProblem(onlinejudge.type.Problem):
                 and result.netloc == 'onlinejudge.u-aizu.ac.jp' \
                 and m:
             n = m.group(5)
-            return cls(n)
+            return cls(problem_id=n)
 
         return None
 
@@ -127,14 +127,14 @@ class AOJArenaProblem(onlinejudge.type.Problem):
 
     .. versionadded:: 6.1.0
     """
-    def __init__(self, arena_id, alphabet):
+    def __init__(self, *, arena_id, alphabet):
         assert alphabet in string.ascii_uppercase
         self.arena_id = arena_id
         self.alphabet = alphabet
 
         self._problem_id = None  # Optional[str]
 
-    def get_problem_id(self, session: Optional[requests.Session] = None) -> str:
+    def get_problem_id(self, *, session: Optional[requests.Session] = None) -> str:
         """
         :note: use http://developers.u-aizu.ac.jp/api?key=judgeapi%2Farenas%2F%7BarenaId%7D%2Fproblems_GET
         """
@@ -151,12 +151,18 @@ class AOJArenaProblem(onlinejudge.type.Problem):
                     break
         return self._problem_id
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         log.warning("most of problems in arena have no registered sample cases.")
-        return AOJProblem(self.get_problem_id()).download_sample_cases(session=session)
+        return AOJProblem(problem_id=self.get_problem_id()).download_sample_cases(session=session)
 
-    def download_system_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
-        return AOJProblem(self.get_problem_id()).download_system_cases(session=session)
+    def download_system_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
+        return AOJProblem(problem_id=self.get_problem_id()).download_system_cases(session=session)
+
+    def download_content(self, *, session: Optional[requests.Session] = None):
+        """
+        :raise NotImplementedError:
+        """
+        raise NotImplementedError
 
     def get_url(self) -> str:
         return 'https://onlinejudge.u-aizu.ac.jp/services/room.html#{}/problems/{}'.format(self.arena_id, self.alphabet)
@@ -170,7 +176,7 @@ class AOJArenaProblem(onlinejudge.type.Problem):
                 and utils.normpath(result.path) == '/services/room.html':
             fragment = result.fragment.split('/')
             if len(fragment) == 3 and fragment[1] == 'problems':
-                return cls(fragment[0], fragment[2].upper())
+                return cls(arena_id=fragment[0], alphabet=fragment[2].upper())
         return None
 
     def get_service(self) -> AOJService:
diff --git a/onlinejudge/service/atcoder.py b/onlinejudge/service/atcoder.py
index d7b2fb3..8a09ea6 100644
--- a/onlinejudge/service/atcoder.py
+++ b/onlinejudge/service/atcoder.py
@@ -18,7 +18,6 @@ import itertools
 import posixpath
 import re
 import urllib.parse
-import warnings
 from typing import *
 
 import bs4
@@ -56,7 +55,7 @@ def _request(*args, **kwargs):
 
 
 class AtCoderService(onlinejudge.type.Service):
-    def login(self, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None) -> None:
+    def login(self, *, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None) -> None:
         """
         :raises LoginError:
         """
@@ -90,7 +89,10 @@ class AtCoderService(onlinejudge.type.Service):
             log.failure('Username or Password is incorrect.')
             raise LoginError
 
-    def is_logged_in(self, session: Optional[requests.Session] = None) -> bool:
+    def get_url_of_login_page(self) -> str:
+        return 'https://atcoder.jp/login'
+
+    def is_logged_in(self, *, session: Optional[requests.Session] = None) -> bool:
         session = session or utils.get_default_session()
         url = 'https://atcoder.jp/contests/practice/submit'
         resp = _request('GET', url, session=session, allow_redirects=False)
@@ -117,7 +119,7 @@ class AtCoderService(onlinejudge.type.Service):
             return cls()
         return None
 
-    def iterate_contests(self, lang: str = 'ja', session: Optional[requests.Session] = None) -> Generator['AtCoderContest', None, None]:
+    def iterate_contest_data(self, *, lang: str = 'ja', session: Optional[requests.Session] = None) -> Iterator['AtCoderContestData']:
         """
         :param lang: must be `ja` (default) or `en`.
         :note: `lang=ja` is required to see some Japanese-local contests.
@@ -130,9 +132,12 @@ class AtCoderService(onlinejudge.type.Service):
         for page in itertools.count(1):  # 1-based
             if last_page is not None and page > last_page:
                 break
+
             # get
             url = 'https://atcoder.jp/contests/archive?lang={}&page={}'.format(lang, page)
             resp = _request('GET', url, session=session)
+            timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone()
+
             # parse
             soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
             if last_page is None:
@@ -140,32 +145,167 @@ class AtCoderService(onlinejudge.type.Service):
                 log.debug('last page: %s', last_page)
             tbody = soup.find('tbody')
             for tr in tbody.find_all('tr'):
-                yield AtCoderContest._from_table_row(tr, lang=lang)
+                yield AtCoderContestData._from_table_row(tr, lang=lang, response=resp, session=session, timestamp=timestamp)
+
+    def iterate_contests(self, *, lang: str = 'ja', session: Optional[requests.Session] = None) -> Iterator['AtCoderContest']:
+        for data in self.iterate_contest_data(lang=lang, session=session):
+            yield data.contest
 
     def get_user_history_url(self, user_id: str) -> str:
         return 'https://atcoder.jp/users/{}/history/json'.format(user_id)
 
 
-class AtCoderContest(object):
+class AtCoderContestData(ContestData):
+    """
+    :ivar contest: :py:class:`AtCoderContest`
+    :ivar duration: :py:class:`datetime.timedelta`
+    :ivar lang: :py:class:`str`
+    :ivar name: :py:class:`str`
+    :ivar rated_range: :py:class:`str`
+    :ivar start_time: :py:class:`datetime.datetime`
+    """
+
+    # yapf: disable
+    def __init__(
+            self,
+            *,
+            contest: 'AtCoderContest',
+            duration: datetime.timedelta,
+            lang: str,
+            name: str,
+            rated_range: str,
+            response: requests.Response,
+            session: requests.Session,
+            start_time: datetime.datetime,
+            timestamp: datetime.datetime  # TODO: in Python 3.5, you cannnot use both "*" and trailing ","
+    ):
+        # yapf: enable
+        self._contest = contest
+        self.duration = duration
+        self.lang = lang
+        self._name = name
+        self.rated_range = rated_range
+        self._response = response
+        self._session = session
+        self.start_time = start_time
+        self._timestamp = timestamp
+
+    @property
+    def contest(self) -> 'AtCoderContest':
+        return self._contest
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @property
+    def html(self) -> bytes:
+        return self._response.content
+
+    @property
+    def response(self) -> requests.Response:
+        return self._response
+
+    @property
+    def session(self) -> requests.Session:
+        return self._session
+
+    @property
+    def timestamp(self) -> datetime.datetime:
+        return self._timestamp
+
+    @classmethod
+    def _parse_start_time(cls, url: str) -> datetime.datetime:
+        # TODO: we need to use an ISO-format parser
+        query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
+        assert len(query['iso']) == 1
+        assert query['p1'] == ['248']  # means JST
+        return datetime.datetime.strptime(query['iso'][0], '%Y%m%dT%H%M').replace(tzinfo=utils.tzinfo_jst)
+
+    @classmethod
+    def _from_table_row(cls, tr: bs4.Tag, *, lang: str, response: requests.Response, session: requests.Session, timestamp: datetime.datetime) -> 'AtCoderContestData':
+        tds = tr.find_all('td')
+        assert len(tds) == 4
+        anchors = [tds[0].find('a'), tds[1].find('a')]
+        contest_path = anchors[1]['href']
+        assert contest_path.startswith('/contests/')
+        contest_id = contest_path[len('/contests/'):]
+
+        name = anchors[1].text
+        start_time = cls._parse_start_time(anchors[0]['href'])
+        hours, minutes = map(int, tds[2].text.split(':'))
+        duration = datetime.timedelta(hours=hours, minutes=minutes)
+        rated_range = tds[3].text
+        return AtCoderContestData(
+            contest=AtCoderContest(contest_id=contest_id),
+            duration=duration,
+            lang=lang,
+            name=name,
+            rated_range=rated_range,
+            session=session,
+            start_time=start_time,
+            response=response,
+            timestamp=timestamp,
+        )
+
+
+class AtCoderContestDetailedData(AtCoderContestData):
+    """
+    :ivar can_participate: :py:class:`str`
+    :ivar penalty: :py:class:`datetime.timedelta`
+    """
+    def __init__(self, *, can_participate: str, penalty: datetime.timedelta, **kwargs):
+        super().__init__(**kwargs)
+        self.can_participate = can_participate
+        self.penalty = penalty
+
+    @classmethod
+    def _from_response(cls, *, contest: 'AtCoderContest', lang: str, session: requests.Session, response: requests.Response, timestamp: datetime.datetime):
+        soup = bs4.BeautifulSoup(response.content.decode(response.encoding), utils.html_parser)
+        name, _, _ = soup.find('title').text.rpartition(' - ')
+        contest_duration = soup.find('small', class_='contest-duration')
+        start_time, end_time = [cls._parse_start_time(a['href']) for a in contest_duration.find_all('a')]
+        duration = end_time - start_time
+        _, _, can_participate = soup.find('span', text=re.compile(r'^(Can Participate|参加対象): ')).text.partition(': ')
+        _, _, rated_range = soup.find('span', text=re.compile(r'^(Rated Range|Rated対象): ')).text.partition(': ')
+
+        penalty_text = soup.find('span', text=re.compile(r'^(Penalty|ペナルティ): ')).text
+        if lang == 'en' and penalty_text == 'Penalty: None':
+            minutes = 0
+        elif lang == 'ja' and penalty_text == 'ペナルティ: なし':
+            minutes = 0
+        else:
+            m = re.match(r'(Penalty|ペナルティ): (\d+)( minutes?|分)', penalty_text)
+            assert m
+            minutes = int(m.group(2))
+        penalty = datetime.timedelta(minutes=minutes)
+
+        return AtCoderContestDetailedData(
+            can_participate=can_participate,
+            contest=contest,
+            duration=duration,
+            lang=lang,
+            name=name,
+            penalty=penalty,
+            rated_range=rated_range,
+            response=response,
+            session=session,
+            start_time=start_time,
+            timestamp=timestamp,
+        )
+
+
+class AtCoderContest(onlinejudge.type.Contest):
     """
     :ivar contest_id: :py:class:`str`
     """
-    def __init__(self, contest_id: str):
+    def __init__(self, *, contest_id: str):
         if contest_id.startswith('http'):
             # an exception should be raised since mypy cannot check this kind of failure
             raise ValueError('You should use AtCoderContest.from_url(url) instead of AtCoderContest(url)')
         self.contest_id = contest_id
 
-        # NOTE: some fields remain undefined, comparing `_from_table_row`
-        self._start_time = None  # type: Optional[datetime.datetime]
-        self._contest_name_ja = None  # type: Optional[str]
-        self._contest_name_en = None  # type: Optional[str]
-        self._duration = None  # type: Optional[datetime.timedelta]
-        self._rated_range = None  # type: Optional[str]
-        self._can_participate = None  # type: Optional[str]
-        self._penalty = None  # type: Optional[datetime.timedelta]
-
-    def get_url(self, type: Optional[str] = None, lang: Optional[str] = None) -> str:
+    def get_url(self, *, type: Optional[str] = None, lang: Optional[str] = None) -> str:
         if type is None or type == 'beta':
             url = 'https://atcoder.jp/contests/{}'.format(self.contest_id)
         elif type == 'old':
@@ -190,108 +330,47 @@ class AtCoderContest(object):
         # example: https://kupc2014.contest.atcoder.jp/tasks/kupc2014_d
         if result.scheme in ('', 'http', 'https') and result.hostname.endswith('.contest.atcoder.jp'):
             contest_id = utils.remove_suffix(result.hostname, '.contest.atcoder.jp')
-            return cls(contest_id)
+            return cls(contest_id=contest_id)
 
         # example: https://atcoder.jp/contests/agc030
         if result.scheme in ('', 'http', 'https') and result.hostname in ('atcoder.jp', 'beta.atcoder.jp'):
             m = re.match(r'/contests/([\w\-_]+)/?.*', utils.normpath(result.path))
             if m:
                 contest_id = m.group(1)
-                return cls(contest_id)
+                return cls(contest_id=contest_id)
 
         return None
 
-    @classmethod
-    def _from_table_row(cls, tr: bs4.Tag, lang: str) -> 'AtCoderContest':
-        tds = tr.find_all('td')
-        assert len(tds) == 4
-        anchors = [tds[0].find('a'), tds[1].find('a')]
-        contest_path = anchors[1]['href']
-        assert contest_path.startswith('/contests/')
-        contest_id = contest_path[len('/contests/'):]
-        self = AtCoderContest(contest_id)
-        self._start_time = self._parse_start_time(anchors[0]['href'])
-        if lang == 'ja':
-            self._contest_name_ja = anchors[1].text
-        elif lang == 'en':
-            self._contest_name_en = anchors[1].text
-        else:
-            assert False
-        hours, minutes = map(int, tds[2].text.split(':'))
-        self._duration = datetime.timedelta(hours=hours, minutes=minutes)
-        self._rated_range = tds[3].text
-        return self
-
-    def _parse_start_time(self, url: str) -> datetime.datetime:
-        # TODO: we need to use an ISO-format parser
-        query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
-        assert len(query['iso']) == 1
-        assert query['p1'] == ['248']  # means JST
-        return datetime.datetime.strptime(query['iso'][0], '%Y%m%dT%H%M').replace(tzinfo=utils.tzinfo_jst)
-
-    def _load_details(self, session: Optional[requests.Session] = None, lang: str = 'en'):
+    def download_data(self, *, session: Optional[requests.Session] = None, lang: str = 'en') -> AtCoderContestDetailedData:
         assert lang in ('en', 'ja')
         session = session or utils.get_default_session()
         resp = _request('GET', self.get_url(type='beta', lang=lang), session=session)
-        soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
-
-        contest_name, _, _ = soup.find('title').text.rpartition(' - ')
-        contest_duration = soup.find('small', class_='contest-duration')
-        self._start_time, end_time = [self._parse_start_time(a['href']) for a in contest_duration.find_all('a')]
-        self._duration = end_time - self._start_time
-        if lang == 'en':
-            self._contest_name_en = contest_name
-        elif lang == 'ja':
-            self._contest_name_ja = contest_name
-        else:
-            assert False
-        _, _, self._can_participate = soup.find('span', text=re.compile(r'^(Can Participate|参加対象): ')).text.partition(': ')
-        _, _, self._rated_range = soup.find('span', text=re.compile(r'^(Rated Range|Rated対象): ')).text.partition(': ')
-
-        penalty_text = soup.find('span', text=re.compile(r'^(Penalty|ペナルティ): ')).text
-        if lang == 'en' and penalty_text == 'Penalty: None':
-            minutes = 0
-        elif lang == 'ja' and penalty_text == 'ペナルティ: なし':
-            minutes = 0
-        else:
-            m = re.match(r'(Penalty|ペナルティ): (\d+)( minutes?|分)', penalty_text)
-            assert m
-            minutes = int(m.group(2))
-        self._penalty = datetime.timedelta(minutes=minutes)
-
-    def get_name(self, lang: str = 'en', session: Optional[requests.Session] = None) -> str:
-        if lang == 'en':
-            if self._contest_name_en is None:
-                self._load_details(lang='en', session=session)
-            assert self._contest_name_en is not None
-            return self._contest_name_en
-        elif lang == 'ja':
-            if self._contest_name_ja is None:
-                self._load_details(lang='ja', session=session)
-            assert self._contest_name_ja is not None
-            return self._contest_name_ja
-        else:
-            assert False
+        timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone()
+        return AtCoderContestDetailedData._from_response(contest=self, lang=lang, session=session, response=resp, timestamp=timestamp)
 
-    get_start_time = utils.getter_with_load_details('_start_time', type=datetime.datetime)  # type: Callable[..., datetime.datetime]
-    get_duration = utils.getter_with_load_details('_duration', type=datetime.timedelta)  # type: Callable[..., datetime.timedelta]
-    get_rated_range = utils.getter_with_load_details('_rated_range', type=str)  # type: Callable[..., str]
-    get_can_participate = utils.getter_with_load_details('_can_participate', type=str)  # type: Callable[..., str]
-    get_penalty = utils.getter_with_load_details('_penalty', type=datetime.timedelta)  # type: Callable[..., datetime.timedelta]
+    def get_service(self) -> AtCoderService:
+        return AtCoderService()
 
-    def list_problems(self, session: Optional[requests.Session] = None) -> List['AtCoderProblem']:
+    def list_problem_data(self, *, session: Optional[requests.Session] = None) -> List['AtCoderProblemData']:
         # get
         session = session or utils.get_default_session()
         url = 'https://atcoder.jp/contests/{}/tasks'.format(self.contest_id)
         resp = _request('GET', url, session=session)
+        timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone()
 
         # parse
         soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
         tbody = soup.find('tbody')
-        return [AtCoderProblem._from_table_row(tr) for tr in tbody.find_all('tr')]
+        return [AtCoderProblemData._from_table_row(tr, session=session, response=resp, timestamp=timestamp) for tr in tbody.find_all('tr')]
+
+    # TODO: why does this require "type: ignore"?
+    def list_problems(self, *, session: Optional[requests.Session] = None) -> 'List[AtCoderProblem]':  # type: ignore
+        return [data.problem for data in self.list_problem_data(session=session)]
 
-    def iterate_submissions_where(
+    # yapf: disable
+    def iterate_submission_data_where(
             self,
+            *,
             me: bool = False,
             problem_id: Optional[str] = None,
             language_id: Optional[LanguageId] = None,
@@ -301,8 +380,9 @@ class AtCoderContest(object):
             desc: bool = False,
             lang: Optional[str] = None,
             pages: Optional[Iterator[int]] = None,
-            session: Optional[requests.Session] = None,
-    ) -> Generator['AtCoderSubmission', None, None]:
+            session: Optional[requests.Session] = None  # TODO: in Python 3.5, you cannnot use both "*" and trailing ","
+    ) -> Iterator['AtCoderSubmissionData']:
+        # yapf: enable
         """
         :note: If you use certain combination of options, then the results may not correct when there are new submissions while crawling.
         :param status: must be one of `AC`, `WA`, `TLE`, `MLE`, `RE`, `CLE`, `OLE`, `IE`, `WJ`, `WR`, or `Judging`
@@ -341,13 +421,14 @@ class AtCoderContest(object):
             params_page = ({'page': str(page)} if page >= 2 else {})
             url = base_url + '?' + urllib.parse.urlencode({**params, **params_page})
             resp = _request('GET', url, session=session)
+            timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone()
 
-            submissions = list(self._iterate_submissions_from_response(resp))
+            submissions = list(self._iterate_submission_data_from_response(resp=resp, session=session, timestamp=timestamp))
             if not submissions:
                 break
             yield from submissions
 
-    def _iterate_submissions_from_response(self, resp: requests.Response) -> Generator['AtCoderSubmission', None, None]:
+    def _iterate_submission_data_from_response(self, *, resp: requests.Response, session: requests.Session, timestamp: datetime.datetime) -> Iterator['AtCoderSubmissionData']:
         soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
         tbodies = soup.find_all('tbody')
         if len(tbodies) == 0:
@@ -355,207 +436,320 @@ class AtCoderContest(object):
         assert len(tbodies) == 1
         tbody = tbodies[0]
         for tr in tbody.find_all('tr'):
-            yield AtCoderSubmission._from_table_row(tr, contest_id=self.contest_id)
+            yield AtCoderSubmissionData._from_table_row(tr, response=resp, session=session, timestamp=timestamp)
+
+    def iterate_submissions_where(self, **kwargs) -> Iterator['AtCoderSubmission']:
+        for data in self.iterate_submission_data_where(**kwargs):
+            yield data.submission
 
-    def iterate_submissions(self, session: Optional[requests.Session] = None) -> Generator['AtCoderSubmission', None, None]:
+    def iterate_submissions(self, *, session: Optional[requests.Session] = None) -> Iterator['AtCoderSubmission']:
         """
         :note: in implementation, use "ORDER BY created DESC" to list all submissions even when there are new submissions
         """
         yield from self.iterate_submissions_where(order='created', desc=False, session=session)
 
 
-# TODO: use the new style of NamedTuple added from Pyhon 3.6
-AtCoderProblemContentPartial = NamedTuple('AtCoderProblemContentPartial', [
-    ('alphabet', str),
-    ('memory_limit_byte', int),
-    ('name', str),
-    ('problem', 'AtCoderProblem'),
-    ('time_limit_msec', int),
-])
-
-
-def _AtCoderProblemContentPartial_from_row(tr: bs4.Tag):
-    tds = tr.find_all('td')
-    assert 4 <= len(tds) <= 5
-    path = tds[1].find('a')['href']
-    problem = AtCoderProblem.from_url('https://atcoder.jp' + path)
-    assert problem is not None
-    alphabet = tds[0].text
-    name = tds[1].text
-    if tds[2].text.endswith(' msec'):
-        time_limit_msec = int(utils.remove_suffix(tds[2].text, ' msec'))
-    elif tds[2].text.endswith(' sec'):
-        time_limit_msec = int(float(utils.remove_suffix(tds[2].text, ' sec')) * 1000)
-    else:
-        assert False
-    if tds[3].text.endswith(' KB'):
-        memory_limit_byte = int(float(utils.remove_suffix(tds[3].text, ' KB')) * 1000)
-    elif tds[3].text.endswith(' MB'):
-        memory_limit_byte = int(float(utils.remove_suffix(tds[3].text, ' MB')) * 1000 * 1000)  # TODO: confirm this is MB truly, not MiB
-    else:
-        assert False
-    if len(tds) == 5:
-        assert tds[4].text.strip() in ('', 'Submit', '提出')
-
-    self = AtCoderProblemContentPartial(alphabet, memory_limit_byte, name, problem, time_limit_msec)
-    problem._cached_content = self
-    return self
-
-
-# TODO: use the new style of NamedTuple added from Pyhon 3.6
-AtCoderProblemContent = NamedTuple('AtCoderProblemContent', [
-    ('alphabet', str),
-    ('available_languages', Optional[List[Language]]),
-    ('html', str),
-    ('input_format', Optional[str]),
-    ('memory_limit_byte', int),
-    ('name', str),
-    ('problem', 'AtCoderProblem'),
-    ('sample_cases', List[TestCase]),
-    ('score', Optional[int]),
-    ('time_limit_msec', int),
-])
-
-
-def _AtCoderProblemContent_get_tag_lang(tag: bs4.Tag):
-    assert isinstance(tag, bs4.Tag)
-    for parent in tag.parents:
-        for cls in parent.attrs.get('class') or []:
-            if cls.startswith('lang-'):
-                return cls
-
-
-def _AtCoderProblemContent_find_sample_tags(soup: bs4.BeautifulSoup) -> Generator[Tuple[bs4.Tag, bs4.Tag], None, None]:
-    for pre in soup.find_all('pre'):
-        log.debug('pre tag: %s', str(pre))
-        if not pre.string:
-            continue
-
-        def h3_plus(tag):
-            prv = tag.find_previous_sibling()
-            if prv and prv.name == 'h3' and prv.string:
-                yield (pre, prv)
-
-        # the first format: h3+pre
-        yield from h3_plus(pre)
-
-        # the second format: h3+section pre
-        if pre.parent and pre.parent.name == 'section':
-            # ignore tags which are not samples
-            # example: https://atcoder.jp/contests/abc003/tasks/abc003_4
-            if pre.find_previous_sibling('pre') is None:
-                yield from h3_plus(pre.parent)
-
-
-def _AtCoderProblemContent_parse_sample_cases(soup: bs4.BeautifulSoup) -> List[onlinejudge.type.TestCase]:
-    samples = onlinejudge._implementation.testcase_zipper.SampleZipper()
-    lang = None
-    for pre, h3 in _AtCoderProblemContent_find_sample_tags(soup):
-        s = utils.textfile(utils.dos2unix(pre.string.lstrip()))
-        name = h3.string
-        l = _AtCoderProblemContent_get_tag_lang(pre)
-        if lang is None:
-            lang = l
-        elif lang != l:
-            log.info('skipped due to language: current one is %s, not %s: %s ', lang, l, name)
-            continue
-        samples.add(s.encode(), name)
-    return samples.get()
-
-
-def _AtCoderProblemContent_parse_input_format(soup: bs4.BeautifulSoup) -> Optional[str]:
-    for h3 in soup.find_all('h3', text=re.compile(r'^(入力|Input)$')):
-        if h3.parent.name == 'section':
-            section = h3.parent
+class AtCoderProblemData(ProblemData):
+    """
+    :note: :py:class:`AtCoderProblemData` is obtained the list page (e.g. https://atcoder.jp/contests/agc001/tasks )
+
+    :ivar alphabet: :py:class:`str`
+    :ivar memory_limit_byte: :py:class:`int`
+    :ivar name: :py:class:`str`
+    :ivar problem: :py:class:`AtCoderProblem`
+    :ivar time_limit_msec: :py:class:`str`
+    """
+
+    # yapf: disable
+    def __init__(
+            self,
+            *,
+            alphabet: str,
+            memory_limit_byte: int,
+            name: str,
+            problem: 'AtCoderProblem',
+            response: Optional[requests.Response],
+            session: Optional[requests.Session],
+            time_limit_msec: int,
+            timestamp: Optional[datetime.datetime],
+            html: Optional[bytes] = None  # TODO: in Python 3.5, you cannnot use both "*" and trailing ","
+    ):
+        # yapf: enable
+        self.alphabet = alphabet
+        self.memory_limit_byte = memory_limit_byte
+        self._name = name
+        self._problem = problem
+        self._response = response
+        self._session = session
+        self.time_limit_msec = time_limit_msec
+        self._timestamp = timestamp
+        if html is None:
+            assert response is not None
+            self._html = response.content
         else:
-            section = h3.find_next_sibling('section')
-        if section is None:
-            section = soup.find(class_='io-style')
-        if section is None:
-            log.warning('<section> tag not found. something wrong')
-            return None
-        pre = section.find('pre')
-        if pre is not None:
-            return pre.decode_contents(formatter=None)
-    return None
+            self._html = html
 
+    @property
+    def problem(self) -> 'AtCoderProblem':
+        return self._problem
 
-def _AtCoderProblemContent_parse_available_languages(soup: bs4.BeautifulSoup, problem: 'AtCoderProblem') -> Optional[List[Language]]:
-    form = soup.find('form', action='/contests/{}/submit'.format(problem.contest_id))
-    if form is None:
-        return None
-    select = form.find('div', id='select-lang').find('select', attrs={'name': 'data.LanguageId'})  # NOTE: AtCoder can vary languages depending on tasks, even in one contest. here, ignores this fact.
-    languages = []  # type: List[Language]
-    for option in select.find_all('option'):
-        languages += [Language(option.attrs['value'], option.string)]
-    return languages
-
-
-def _AtCoderProblemContent_parse_partial(soup: bs4.BeautifulSoup, problem: 'AtCoderProblem') -> AtCoderProblemContentPartial:
-    h2 = soup.find('span', class_='h2')
-
-    alphabet, _, name = h2.text.partition(' - ')
-
-    time_limit, memory_limit = h2.find_next_sibling('p').text.split(' / ')
-    for time_limit_prefix in ('実行時間制限: ', 'Time Limit: '):
-        if time_limit.startswith(time_limit_prefix):
-            break
-    else:
-        assert False
-    if time_limit.endswith(' msec'):
-        time_limit_msec = int(utils.remove_suffix(utils.remove_prefix(time_limit, time_limit_prefix), ' msec'))
-    elif time_limit.endswith(' sec'):
-        time_limit_msec = int(float(utils.remove_suffix(utils.remove_prefix(time_limit, time_limit_prefix), ' sec')) * 1000)
-    else:
-        assert False
-
-    for memory_limit_prefix in ('メモリ制限: ', 'Memory Limit: '):
-        if memory_limit.startswith(memory_limit_prefix):
-            break
-    else:
-        assert False
-    if memory_limit.endswith(' KB'):
-        memory_limit_byte = int(float(utils.remove_suffix(utils.remove_prefix(memory_limit, memory_limit_prefix), ' KB')) * 1000)
-    elif memory_limit.endswith(' MB'):
-        memory_limit_byte = int(float(utils.remove_suffix(utils.remove_prefix(memory_limit, memory_limit_prefix), ' MB')) * 1000 * 1000)
-    else:
-        assert False
-
-    return AtCoderProblemContentPartial(alphabet, memory_limit_byte, name, problem, time_limit_msec)
-
-
-def _AtCoderProblemContent_parse_score(soup: bs4.BeautifulSoup) -> Optional[int]:
-    task_statement = soup.find('div', id='task-statement')
-    p = task_statement.find('p')  # first
-    if p is not None and p.text.startswith('配点 : '):
-        score = utils.remove_suffix(utils.remove_prefix(p.text, '配点 : '), ' 点')
-        try:
-            return int(score)
-        except ValueError:
-            # some problems have scores like "<p>配点 : \(100\) 点</p>", not "<p>配点 : 100 点</p>"
-            # example: https://atcoder.jp/contests/wupc2019/tasks/wupc2019_a
-            pass
-    return None
+    @property
+    def name(self) -> str:
+        return self._name
 
+    @property
+    def html(self) -> bytes:
+        return self._html
 
-def _AtCoderProblemContent_from_html(html: str, problem: 'AtCoderProblem') -> AtCoderProblemContent:
-    """
-    :param html: must be a HTML of the new (beta) version of AtCoder
+    @property
+    def response(self) -> Optional[requests.Response]:
+        return self._response
+
+    @property
+    def session(self) -> Optional[requests.Session]:
+        return self._session
+
+    @property
+    def timestamp(self) -> Optional[datetime.datetime]:
+        return self._timestamp
+
+    @classmethod
+    def _from_table_row(cls, tr: bs4.Tag, *, session: requests.Session, response: requests.Response, timestamp: datetime.datetime) -> 'AtCoderProblemData':
+        tds = tr.find_all('td')
+        assert 4 <= len(tds) <= 5
+        path = tds[1].find('a')['href']
+        problem = AtCoderProblem.from_url('https://atcoder.jp' + path)
+        assert problem is not None
+        alphabet = tds[0].text
+        name = tds[1].text
+        if tds[2].text.endswith(' msec'):
+            time_limit_msec = int(utils.remove_suffix(tds[2].text, ' msec'))
+        elif tds[2].text.endswith(' sec'):
+            time_limit_msec = int(float(utils.remove_suffix(tds[2].text, ' sec')) * 1000)
+        else:
+            assert False
+        if tds[3].text.endswith(' KB'):
+            memory_limit_byte = int(float(utils.remove_suffix(tds[3].text, ' KB')) * 1000)
+        elif tds[3].text.endswith(' MB'):
+            memory_limit_byte = int(float(utils.remove_suffix(tds[3].text, ' MB')) * 1000 * 1000)  # TODO: confirm this is MB truly, not MiB
+        else:
+            assert False
+        if len(tds) == 5:
+            assert tds[4].text.strip() in ('', 'Submit', '提出')
+
+        return AtCoderProblemData(
+            alphabet=alphabet,
+            memory_limit_byte=memory_limit_byte,
+            name=name,
+            problem=problem,
+            response=response,
+            session=session,
+            time_limit_msec=time_limit_msec,
+            timestamp=timestamp,
+        )
 
-    .. versionadded:: 6.2.0
+    @classmethod
+    def _from_html(cls, html: bytes, *, problem: 'AtCoderProblem', session: Optional[requests.Session] = None, response: Optional[requests.Response] = None, timestamp: Optional[datetime.datetime] = None) -> 'AtCoderProblemData':
+        soup = bs4.BeautifulSoup(html, utils.html_parser)
+        h2 = soup.find('span', class_='h2')
 
+        alphabet, _, name = h2.text.partition(' - ')
+
+        time_limit, memory_limit = h2.find_next_sibling('p').text.split(' / ')
+        for time_limit_prefix in ('実行時間制限: ', 'Time Limit: '):
+            if time_limit.startswith(time_limit_prefix):
+                break
+        else:
+            assert False
+        if time_limit.endswith(' msec'):
+            time_limit_msec = int(utils.remove_suffix(utils.remove_prefix(time_limit, time_limit_prefix), ' msec'))
+        elif time_limit.endswith(' sec'):
+            time_limit_msec = int(float(utils.remove_suffix(utils.remove_prefix(time_limit, time_limit_prefix), ' sec')) * 1000)
+        else:
+            assert False
+
+        for memory_limit_prefix in ('メモリ制限: ', 'Memory Limit: '):
+            if memory_limit.startswith(memory_limit_prefix):
+                break
+        else:
+            assert False
+        if memory_limit.endswith(' KB'):
+            memory_limit_byte = int(float(utils.remove_suffix(utils.remove_prefix(memory_limit, memory_limit_prefix), ' KB')) * 1000)
+        elif memory_limit.endswith(' MB'):
+            memory_limit_byte = int(float(utils.remove_suffix(utils.remove_prefix(memory_limit, memory_limit_prefix), ' MB')) * 1000 * 1000)
+        else:
+            assert False
+
+        return AtCoderProblemData(
+            alphabet=alphabet,
+            html=html,
+            memory_limit_byte=memory_limit_byte,
+            name=name,
+            problem=problem,
+            response=response,
+            session=session,
+            time_limit_msec=time_limit_msec,
+            timestamp=timestamp,
+        )
+
+
+class AtCoderProblemDetailedData(AtCoderProblemData):
+    """
+    :note: :py:class:`AtCoderProblemDetailedData` is obtained the problem page (e.g. https://atcoder.jp/contests/agc001/tasks/agc001_a )
+
+    :ivar available_languages: :py:class:`Optional` [ :py:class:`List` [ :py:class:`Language` ] ]
+    :ivar input_format: :py:class:`Optional` [ :py:class:`str` ]
+    :ivar sample_cases: :py:class:`Optional` [ :py:class:`List` [ :py:class:`TestCase` ] ]
+    :ivar score: :py:class:`Optional` [ :py:class:`float` ]
     """
 
-    soup = bs4.BeautifulSoup(html, utils.html_parser)
-    sample_cases = _AtCoderProblemContent_parse_sample_cases(soup)
-    input_format = _AtCoderProblemContent_parse_input_format(soup)
-    available_languages = _AtCoderProblemContent_parse_available_languages(soup, problem=problem)
-    partial = _AtCoderProblemContent_parse_partial(soup, problem=problem)
-    score = _AtCoderProblemContent_parse_score(soup)
-    return AtCoderProblemContent(partial.alphabet, available_languages, html, input_format, partial.memory_limit_byte, partial.name, problem, sample_cases, score, partial.time_limit_msec)
+    # yapf: disable
+    def __init__(
+            self,
+            *,
+            available_languages: Optional[List[Language]],
+            input_format: Optional[str],
+            sample_cases: Optional[List[TestCase]],
+            score: Optional[int],
+            **kwargs  # TODO: in Python 3.5, you cannnot use both "*" and trailing ","
+    ):
+        # yapf: enable
+        super().__init__(**kwargs)
+        self.available_languages = available_languages
+        self.input_format = input_format
+        self._sample_cases = sample_cases
+        self.score = score
+
+    @property
+    def sample_cases(self) -> Optional[List[TestCase]]:
+        return self._sample_cases
+
+    @classmethod
+    def _get_tag_lang(cls, tag: bs4.Tag) -> Optional[str]:
+        assert isinstance(tag, bs4.Tag)
+        for parent in tag.parents:
+            for s in parent.attrs.get('class') or []:
+                if s.startswith('lang-'):
+                    return s
+        return None
+
+    @classmethod
+    def _find_sample_tags(cls, soup: bs4.BeautifulSoup) -> Iterator[Tuple[bs4.Tag, bs4.Tag]]:
+        for pre in soup.find_all('pre'):
+            log.debug('pre tag: %s', str(pre))
+            if not pre.string:
+                continue
+
+            def h3_plus(tag):
+                prv = tag.find_previous_sibling()
+                if prv and prv.name == 'h3' and prv.string:
+                    yield (pre, prv)
+
+            # the first format: h3+pre
+            yield from h3_plus(pre)
+
+            # the second format: h3+section pre
+            if pre.parent and pre.parent.name == 'section':
+                # ignore tags which are not samples
+                # example: https://atcoder.jp/contests/abc003/tasks/abc003_4
+                if pre.find_previous_sibling('pre') is None:
+                    yield from h3_plus(pre.parent)
+
+    @classmethod
+    def _parse_sample_cases(cls, soup: bs4.BeautifulSoup) -> List[onlinejudge.type.TestCase]:
+        """
+        :raises SampleParseError:
+        """
+        samples = onlinejudge._implementation.testcase_zipper.SampleZipper()
+        lang = None
+        for pre, h3 in cls._find_sample_tags(soup):
+            s = utils.textfile(utils.dos2unix(pre.string.lstrip()))
+            name = h3.string
+            l = cls._get_tag_lang(pre)
+            if lang is None:
+                lang = l
+            elif lang != l:
+                log.debug('skipped due to language: current one is %s, not %s: %s ', lang, l, name)
+                continue
+            samples.add(s.encode(), name)
+        return samples.get()
+
+    @classmethod
+    def _parse_input_format(cls, soup: bs4.BeautifulSoup) -> Optional[str]:
+        for h3 in soup.find_all('h3', text=re.compile(r'^(入力|Input)$')):
+            if h3.parent.name == 'section':
+                section = h3.parent
+            else:
+                section = h3.find_next_sibling('section')
+            if section is None:
+                section = soup.find(class_='io-style')
+            if section is None:
+                log.warning('<section> tag not found. something wrong')
+                return None
+            pre = section.find('pre')
+            if pre is not None:
+                return pre.decode_contents(formatter=None)
+        return None
+
+    @classmethod
+    def _parse_available_languages(cls, soup: bs4.BeautifulSoup, problem: 'AtCoderProblem') -> Optional[List[Language]]:
+        form = soup.find('form', action='/contests/{}/submit'.format(problem.contest_id))
+        if form is None:
+            return None
+        select = form.find('div', id='select-lang').find('select', attrs={'name': 'data.LanguageId'})  # NOTE: AtCoder can vary languages depending on tasks, even in one contest. here, ignores this fact.
+        languages = []  # type: List[Language]
+        for option in select.find_all('option'):
+            languages += [Language(option.attrs['value'], option.string)]
+        return languages
+
+    @classmethod
+    def _parse_score(cls, soup: bs4.BeautifulSoup) -> Optional[int]:
+        task_statement = soup.find('div', id='task-statement')
+        p = task_statement.find('p')  # first
+        if p is not None and p.text.startswith('配点 : '):
+            score = utils.remove_suffix(utils.remove_prefix(p.text, '配点 : '), ' 点')
+            try:
+                return int(score)
+            except ValueError:
+                # some problems have scores like "<p>配点 : \(100\) 点</p>", not "<p>配点 : 100 点</p>"
+                # example: https://atcoder.jp/contests/wupc2019/tasks/wupc2019_a
+                pass
+        return None
+
+    @classmethod
+    def from_html(cls, html: bytes, *, problem: 'AtCoderProblem', session: Optional[requests.Session] = None, response: Optional[requests.Response] = None, timestamp: Optional[datetime.datetime] = None) -> 'AtCoderProblemDetailedData':
+        """
+        :param html: must be a HTML of the new (beta) version of AtCoder
+
+        .. versionadded:: 6.2.0
 
+        """
 
-AtCoderProblemContent.from_html = _AtCoderProblemContent_from_html  # type: ignore
+        soup = bs4.BeautifulSoup(html, utils.html_parser)
+        try:
+            sample_cases = cls._parse_sample_cases(soup)  # type: Optional[List[TestCase]]
+        except SampleParseError:
+            sample_cases = None
+        input_format = cls._parse_input_format(soup)
+        available_languages = cls._parse_available_languages(soup, problem=problem)
+        score = cls._parse_score(soup)
+
+        data = AtCoderProblemData._from_html(html, problem=problem, session=session, response=response, timestamp=timestamp)
+        return AtCoderProblemDetailedData(
+            alphabet=data.alphabet,
+            available_languages=available_languages,
+            html=data.html,
+            input_format=input_format,
+            memory_limit_byte=data.memory_limit_byte,
+            name=data.name,
+            problem=data.problem,
+            response=data.response,
+            sample_cases=sample_cases,
+            score=score,
+            session=data.session,
+            time_limit_msec=data.time_limit_msec,
+            timestamp=data.timestamp,
+        )
 
 
 class AtCoderProblem(onlinejudge.type.Problem):
@@ -565,37 +759,35 @@ class AtCoderProblem(onlinejudge.type.Problem):
 
     :note: AtCoder has problems independently from contests. Therefore the notions `contest_id`, `alphabet`, and `url` don't belong to problems itself.
     """
-    def __init__(self, contest_id: str, problem_id: str):
+    def __init__(self, *, contest_id: str, problem_id: str):
         self.contest_id = contest_id
         self.problem_id = problem_id  # NOTE: AtCoder calls this as "task_screen_name"
-        self._cached_content = None  # type: Union[None, AtCoderProblemContentPartial, AtCoderProblemContent]
 
-    @classmethod
-    def _from_table_row(cls, tr: bs4.Tag) -> 'AtCoderProblem':
-        return _AtCoderProblemContentPartial_from_row(tr).problem
-
-    def download_content(self, session: Optional[requests.Session] = None) -> AtCoderProblemContent:
+    def download_data(self, *, session: Optional[requests.Session] = None) -> AtCoderProblemDetailedData:
         """
         :raises Exception: if no such problem exists
-
-        .. versionadded:: 6.2.0
         """
 
         session = session or utils.get_default_session()
         resp = _request('GET', self.get_url(type='beta'), raise_for_status=False, session=session)
+        timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone()
         if _list_alert(resp):
             log.warning('are you logged in?')
         resp.raise_for_status()
-        self._cached_content = _AtCoderProblemContent_from_html(resp.content.decode(resp.encoding), problem=self)
-        return self._cached_content
+        html = resp.content.decode(resp.encoding).encode()  # ensure UTF-8
+        return AtCoderProblemDetailedData.from_html(html, problem=self, session=session, response=resp, timestamp=timestamp)
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
         """
-        :raises Exception: if no such problem exists
+        :raises requests.exceptions.HTTPError: if no such problem exists
+        :raises SampleParseError: if parsing failed
         """
-        return self.download_content(session=session).sample_cases
+        session = session or utils.get_default_session()
+        resp = _request('GET', self.get_url(type='beta'), session=session)
+        soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
+        return AtCoderProblemDetailedData._parse_sample_cases(soup)
 
-    def get_url(self, type: Optional[str] = None, lang: Optional[str] = None) -> str:
+    def get_url(self, *, type: Optional[str] = None, lang: Optional[str] = None) -> str:
         if type is None or type == 'beta':
             url = 'https://atcoder.jp/contests/{}/tasks/{}'.format(self.contest_id, self.problem_id)
         elif type == 'old':
@@ -610,7 +802,7 @@ class AtCoderProblem(onlinejudge.type.Problem):
         return AtCoderService()
 
     def get_contest(self) -> AtCoderContest:
-        return AtCoderContest(self.contest_id)
+        return AtCoderContest(contest_id=self.contest_id)
 
     @classmethod
     def from_url(cls, s: str) -> Optional['AtCoderProblem']:
@@ -625,7 +817,7 @@ class AtCoderProblem(onlinejudge.type.Problem):
                 and basename:
             contest_id = result.netloc.split('.')[0]
             problem_id = basename
-            return cls(contest_id, problem_id)
+            return cls(contest_id=contest_id, problem_id=problem_id)
 
         # example: https://beta.atcoder.jp/contests/abc073/tasks/abc073_a
         m = re.match(r'^/contests/([\w\-_]+)/tasks/([\w\-_]+)$', utils.normpath(result.path))
@@ -634,27 +826,27 @@ class AtCoderProblem(onlinejudge.type.Problem):
                 and m:
             contest_id = m.group(1)
             problem_id = m.group(2)
-            return cls(contest_id, problem_id)
+            return cls(contest_id=contest_id, problem_id=problem_id)
 
         return None
 
-    def get_input_format(self, session: Optional[requests.Session] = None) -> Optional[str]:
+    def download_input_format(self, *, session: Optional[requests.Session] = None) -> Optional[str]:
         """
         :raises Exception: if no such problem exists
         """
-        return self.download_content(session=session).input_format
+        return self.download_data(session=session).input_format
 
-    def get_available_languages(self, session: Optional[requests.Session] = None) -> List[Language]:
+    def get_available_languages(self, *, session: Optional[requests.Session] = None) -> List[Language]:
         """
         :raises NotLoggedInError:
         """
-        content = self.download_content(session=session)
-        if content.available_languages is None:
+        data = self.download_data(session=session)
+        if data.available_languages is None:
             log.error('not logged in')
             raise NotLoggedInError
-        return content.available_languages
+        return data.available_languages
 
-    def submit_code(self, code: bytes, language_id: LanguageId, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> 'AtCoderSubmission':
+    def submit_code(self, code: bytes, language_id: LanguageId, *, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> 'AtCoderSubmission':
         """
         :raises NotLoggedInError:
         :raises SubmissionError:
@@ -684,117 +876,173 @@ class AtCoderProblem(onlinejudge.type.Problem):
         form.set('data.LanguageId', str(language_id))
         form.set('sourceCode', code)
         resp = form.request(session=session)
+        timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone()
         _list_alert(resp, print_=True)
 
         # result
         if '/submissions/me' in resp.url:
-            submission = next(AtCoderContest(self.contest_id)._iterate_submissions_from_response(resp))
+            submission = next(AtCoderContest(contest_id=self.contest_id)._iterate_submission_data_from_response(resp=resp, session=session, timestamp=timestamp)).submission
             log.success('success: result: %s', submission.get_url())
             return submission
         else:
             raise SubmissionError('it may be a rate limit')
 
-    def get_name(self, session: Optional[requests.Session] = None) -> str:
-        return self.download_content(session=session).name
+    def get_name(self, *, session: Optional[requests.Session] = None) -> str:
+        return self.download_data(session=session).name
 
-    def iterate_submissions(self, session: Optional[requests.Session] = None) -> Generator['AtCoderSubmission', None, None]:
+    def iterate_submissions(self, *, session: Optional[requests.Session] = None) -> Iterator['AtCoderSubmission']:
         """
         :note: in implementation, use "ORDER BY created DESC" to list all submissions even when there are new submissions
         """
         yield from self.get_contest().iterate_submissions_where(problem_id=self.problem_id, order='created', desc=False, session=session)
 
-    def iterate_submissions_where(self, **kwargs) -> Generator['AtCoderSubmission', None, None]:
+    def iterate_submissions_where(self, **kwargs) -> Iterator['AtCoderSubmission']:
         yield from self.get_contest().iterate_submissions_where(problem_id=self.problem_id, **kwargs)
 
-    def _get_partial_content(self, session: Optional[requests.Session] = None) -> Union[AtCoderProblemContentPartial, AtCoderProblemContent]:
-        if self._cached_content is None:
-            return self.download_content(session=session)
-        else:
-            return self._cached_content
 
-    def _get_content(self, session: Optional[requests.Session] = None) -> AtCoderProblemContent:
-        if isinstance(self._cached_content, AtCoderProblemContent):
-            return self._cached_content
-        else:
-            return self.download_content(session=session)
+class AtCoderSubmissionData(SubmissionData):
+    """
+    :ivar alphabet: :py:class:`str`
+    :ivar memory_limit_byte: :py:class:`int`
+    :ivar name: :py:class:`str`
+    :ivar problem: :py:class:`AtCoderProblem`
+    :ivar time_limit_msec: :py:class:`str`
+    """
 
-    def get_score(self, session: Optional[requests.Session] = None) -> Optional[int]:
-        """
-        :return: :py:data:`None` if the problem has no score  (e.g. https://atcoder.jp/contests/abc012/tasks/abc012_3)
+    # yapf: disable
+    def __init__(
+            self,
+            *,
+            code_size: int,
+            exec_time_msec: Optional[int],
+            language_name: str,
+            memory_byte: Optional[int],
+            problem: AtCoderProblem,
+            problem_id: str,
+            response: requests.Response,
+            score: float,
+            session: requests.Session,
+            status: str,
+            submission: 'AtCoderSubmission',
+            submission_time: datetime.datetime,
+            timestamp: datetime.datetime,
+            user_id: str  # TODO: in Python 3.5, you cannnot use both "*" and trailing ","
+    ):
+        # yapf: enable
+        self.code_size = code_size
+        self.exec_time_msec = exec_time_msec
+        self.language_name = language_name
+        self.memory_byte = memory_byte
+        self._problem = problem
+        self.problem_id = problem_id
+        self._response = response
+        self.score = score
+        self._session = session
+        self._status = status
+        self._submission = submission
+        self.submission_time = submission_time
+        self._timestamp = timestamp
+        self.user_id = user_id
 
-        .. deprecated:: 6.2.0
-        """
-        warnings.warn('deprecated', DeprecationWarning)
-        return self._get_content(session=session).score
+    @property
+    def status(self) -> str:
+        return self._status
 
-    def get_time_limit_msec(self, session: Optional[requests.Session] = None) -> int:
-        """
-        .. deprecated:: 6.2.0
-        """
-        warnings.warn('deprecated', DeprecationWarning)
-        return self._get_partial_content(session=session).time_limit_msec
+    @property
+    def submission(self) -> 'AtCoderSubmission':
+        return self._submission
 
-    def get_memory_limit_byte(self, session: Optional[requests.Session] = None) -> int:
-        """
-        .. deprecated:: 6.2.0
-        """
-        warnings.warn('deprecated', DeprecationWarning)
-        return self._get_partial_content(session=session).memory_limit_byte
+    @property
+    def problem(self) -> AtCoderProblem:
+        return AtCoderProblem(problem_id=self.problem_id, contest_id=self.submission.contest_id)
 
-    def get_alphabet(self, session: Optional[requests.Session] = None) -> str:
-        """
-        .. deprecated:: 6.2.0
-        """
-        warnings.warn('deprecated', DeprecationWarning)
-        return self._get_partial_content(session=session).alphabet
+    @property
+    def response(self) -> Optional[requests.Response]:
+        return self._response
 
+    @property
+    def session(self) -> Optional[requests.Session]:
+        return self._session
 
-class AtCoderSubmission(onlinejudge.type.Submission):
-    """
-    :ivar contest_id: :py:class:`str`
-    :ivar submission_id: :py:class:`str`
-    """
-    def __init__(self, contest_id: str, submission_id: int, problem_id: Optional[str] = None):
-        self.contest_id = contest_id
-        self.submission_id = submission_id
-        self._problem_id = problem_id
-        self._source_code = None  # type: Optional[bytes]
-        self._submission_time = None  # type: Optional[datetime.datetime]
-        self._user_id = None  # type: Optional[str]
-        self._language_name = None  # type: Optional[str]
-        self._score = None  # type: Optional[float]
-        self._code_size = None  # type: Optional[int]
-        self._status = None  # type: Optional[str]
-        self._exec_time_msec = None  # type: Optional[int]
-        self._memory_byte = None  # type: Optional[int]
-        self._compile_error = None  # type: Optional[str]
-        self._test_sets = None  # type: Optional[List[AtCoderSubmissionTestSet]]
-        self._test_cases = None  # type: Optional[List[AtCoderSubmissionTestCaseResult]]
+    @property
+    def timestamp(self) -> Optional[datetime.datetime]:
+        return self._timestamp
 
     @classmethod
-    def _from_table_row(cls, tr: bs4.Tag, contest_id: str) -> 'AtCoderSubmission':
+    def _from_table_row(cls, tr: bs4.Tag, *, session: requests.Session, response: requests.Response, timestamp: datetime.datetime) -> 'AtCoderSubmissionData':
         tds = tr.find_all('td')
         assert len(tds) in (8, 10)
 
-        self = cls.from_url('https://atcoder.jp' + tds[-1].find('a')['href'])
+        submission = AtCoderSubmission.from_url('https://atcoder.jp' + tds[-1].find('a')['href'])
         problem = AtCoderProblem.from_url('https://atcoder.jp' + tds[1].find('a')['href'])
-        assert self is not None
+        assert submission is not None
         assert problem is not None
 
-        self._submission_time = datetime.datetime.strptime(tds[0].text, '%Y-%m-%d %H:%M:%S+0900').replace(tzinfo=utils.tzinfo_jst)
-        self._problem_id = problem.problem_id
-        self._user_id = tds[2].find_all('a')[0]['href'].split('/')[-1]
-        self._language_name = tds[3].text
-        self._score = float(tds[4].text)
-        self._code_size = int(utils.remove_suffix(tds[5].text, ' Byte'))
-        self._status = tds[6].text
+        submission_time = datetime.datetime.strptime(tds[0].text, '%Y-%m-%d %H:%M:%S+0900').replace(tzinfo=utils.tzinfo_jst)
+        problem_id = problem.problem_id
+        user_id = tds[2].find_all('a')[0]['href'].split('/')[-1]
+        language_name = tds[3].text
+        score = float(tds[4].text)
+        code_size = int(utils.remove_suffix(tds[5].text, ' Byte'))
+        status = tds[6].text
         if len(tds) == 10:
-            self._exec_time_msec = int(utils.remove_suffix(tds[7].text, ' ms'))
-            self._memory_byte = int(utils.remove_suffix(tds[8].text, ' KB')) * 1000
-        return self
+            exec_time_msec = int(utils.remove_suffix(tds[7].text, ' ms'))  # type: Optional[int]
+            memory_byte = int(utils.remove_suffix(tds[8].text, ' KB')) * 1000  # type: Optional[int]
+        else:
+            exec_time_msec = None
+            memory_byte = None
+        return AtCoderSubmissionData(
+            code_size=code_size,
+            exec_time_msec=exec_time_msec,
+            language_name=language_name,
+            memory_byte=memory_byte,
+            problem_id=problem_id,
+            problem=problem,
+            response=response,
+            score=score,
+            session=session,
+            status=status,
+            submission=submission,
+            submission_time=submission_time,
+            timestamp=timestamp,
+            user_id=user_id,
+        )
+
+
+class AtCoderSubmissionDetailedData(AtCoderSubmissionData):
+    # yapf: disable
+    def __init__(
+            self,
+            *,
+            source_code: bytes,
+            compile_error: Optional[str],
+            test_sets: Optional[List['AtCoderSubmissionTestSet']],
+            test_cases: Optional[List['AtCoderSubmissionTestCaseResult']],
+            **kwargs  # TODO: in Python 3.5, you cannnot use both "*" and trailing ","
+    ):
+        # yapf: enable
+        super().__init__(**kwargs)
+        self._source_code = source_code
+        self.compile_error = compile_error
+        self.test_sets = test_sets
+        self.test_cases = test_cases
+
+    @property
+    def source_code(self) -> bytes:
+        return self._source_code
+
+
+class AtCoderSubmission(onlinejudge.type.Submission):
+    """
+    :ivar contest_id: :py:class:`str`
+    :ivar submission_id: :py:class:`str`
+    """
+    def __init__(self, *, contest_id: str, submission_id: int):
+        self.contest_id = contest_id
+        self.submission_id = submission_id
 
     @classmethod
-    def from_url(cls, s: str, problem_id: Optional[str] = None) -> Optional['AtCoderSubmission']:
+    def from_url(cls, s: str) -> Optional['AtCoderSubmission']:
         submission_id = None  # type: Optional[int]
 
         # example: http://agc001.contest.atcoder.jp/submissions/1246803
@@ -812,7 +1060,7 @@ class AtCoderSubmission(onlinejudge.type.Submission):
                 pass
                 submission_id = None
             if submission_id is not None:
-                return cls(contest_id, submission_id, problem_id=problem_id)
+                return cls(contest_id=contest_id, submission_id=submission_id)
 
         # example: https://beta.atcoder.jp/contests/abc073/submissions/1592381
         m = re.match(r'^/contests/([\w\-_]+)/submissions/(\d+)$', utils.normpath(result.path))
@@ -825,11 +1073,11 @@ class AtCoderSubmission(onlinejudge.type.Submission):
             except ValueError:
                 submission_id = None
             if submission_id is not None:
-                return cls(contest_id, submission_id, problem_id=problem_id)
+                return cls(contest_id=contest_id, submission_id=submission_id)
 
         return None
 
-    def get_url(self, type: Optional[str] = None, lang: Optional[str] = None) -> str:
+    def get_url(self, *, type: Optional[str] = None, lang: Optional[str] = None) -> str:
         if type is None or type == 'beta':
             url = 'https://atcoder.jp/contests/{}/submissions/{}'.format(self.contest_id, self.submission_id)
         elif type == 'old':
@@ -843,13 +1091,26 @@ class AtCoderSubmission(onlinejudge.type.Submission):
     def get_service(self) -> AtCoderService:
         return AtCoderService()
 
-    def download_code(self, session: Optional[requests.Session] = None) -> bytes:
-        return self.get_source_code(session=session)
+    def download_problem(self, *, session: Optional[requests.Session] = None) -> AtCoderProblem:
+        problem_id = self.download_data(session=session).problem_id
+        return AtCoderProblem(contest_id=self.contest_id, problem_id=problem_id)
 
-    def _load_details(self, session: Optional[requests.Session] = None) -> None:
+    def get_problem(self) -> AtCoderProblem:
+        """
+        :raises Exception:
+        :note: There is no way to reconstruct problem_id without networking
+        """
+        raise Exception
+
+    def download_data(self, *, session: Optional[requests.Session] = None) -> AtCoderSubmissionDetailedData:
+        """
+        :note: `Exec Time` is undefined when the status is `RE` or `TLE`
+        :note: `Memory` is undefined when the status is `RE` or `TLE`
+        """
         session = session or utils.get_default_session()
         resp = _request('GET', self.get_url(type='beta', lang='en'), session=session)
         soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
+        timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone()
 
         # Submission #N
         id_, = soup.find_all('span', class_='h2')
@@ -857,7 +1118,7 @@ class AtCoderSubmission(onlinejudge.type.Submission):
 
         # Source Code
         source_code = soup.find(id='submission-code')
-        self._source_code = source_code.text.encode()
+        source_code = source_code.text.encode()
 
         # get tables
         tables = soup.find_all('table')
@@ -872,6 +1133,7 @@ class AtCoderSubmission(onlinejudge.type.Submission):
 
         # Submission Info
         data = {}  # type: Dict[str, str]
+        problem_id = None  # type: Optional[str]
         for tr in submission_info.find_all('tr'):
             key = tr.find('th').text.strip()
             value = tr.find('td').text.strip()
@@ -880,69 +1142,64 @@ class AtCoderSubmission(onlinejudge.type.Submission):
             if key == 'Task':
                 problem = AtCoderProblem.from_url('https://atcoder.jp' + tr.find('a')['href'])
                 assert problem is not None
-                self._problem_id = problem.problem_id
-
-        self._submission_time = datetime.datetime.strptime(data['Submission Time'], '%Y-%m-%d %H:%M:%S+0900').replace(tzinfo=utils.tzinfo_jst)
-        self._user_id = data['User']
-        self._language_name = data['Language']
-        self._score = float(data['Score'])
-        self._code_size = int(utils.remove_suffix(data['Code Size'], ' Byte'))
-        self._status = data['Status']
+                problem_id = problem.problem_id
+
+        assert problem_id is not None
+        submission_time = datetime.datetime.strptime(data['Submission Time'], '%Y-%m-%d %H:%M:%S+0900').replace(tzinfo=utils.tzinfo_jst)
+        user_id = data['User']
+        language_name = data['Language']
+        score = float(data['Score'])
+        code_size = int(utils.remove_suffix(data['Code Size'], ' Byte'))
+        status = data['Status']
         if 'Exec Time' in data:
-            self._exec_time_msec = int(utils.remove_suffix(data['Exec Time'], ' ms'))
+            exec_time_msec = int(utils.remove_suffix(data['Exec Time'], ' ms'))  # type: Optional[int]
+        else:
+            exec_time_msec = None
         if 'Memory' in data:
-            self._memory_byte = int(utils.remove_suffix(data['Memory'], ' KB')) * 1000  # TODO: confirm this is KB truly, not KiB
+            # TODO: confirm this is KB truly, not KiB
+            memory_byte = int(utils.remove_suffix(data['Memory'], ' KB')) * 1000  # type: Optional[int]
+        else:
+            memory_byte = None
 
         # Compile Error
-        compile_error = soup.find('h4', text='Compile Error')
-        if compile_error is None:
-            self.compile_error = ''
+        compile_error_tag = soup.find('h4', text='Compile Error')
+        if compile_error_tag is not None:
+            compile_error = compile_error_tag.find_next_sibling('pre').text
         else:
-            compile_error = compile_error.find_next_sibling('pre')
-            self.compile_error = compile_error.text
+            compile_error = None
 
         # Test Cases
         if test_cases_summary is not None:
             trs = test_cases_summary.find('tbody').find_all('tr')
-            self._test_sets = [AtCoderSubmissionTestSet._from_table_row(tr) for tr in trs]
+            test_sets = [AtCoderSubmissionTestSet._from_table_row(tr) for tr in trs]  # type: Optional[List[AtCoderSubmissionTestSet]]
+        else:
+            test_sets = None
         if test_cases_data is not None:
             trs = test_cases_data.find('tbody').find_all('tr')
-            self._test_cases = [AtCoderSubmissionTestCaseResult._from_table_row(tr) for tr in trs]
-
-    def get_problem(self, session: Optional[requests.Session] = None) -> AtCoderProblem:
-        if self._problem_id is None:
-            self._load_details(session=session)
-        assert self._problem_id is not None
-        return AtCoderProblem(self.contest_id, self._problem_id)
-
-    def get_exec_time_msec(self, session: Optional[requests.Session] = None) -> Optional[int]:
-        """
-        :note: `Exec Time` is undefined when the status is `RE` or `TLE`
-        """
-        if self._status is None:
-            self._load_details(session=session)
-            assert self._status is not None
-        return self._exec_time_msec
-
-    def get_memory_byte(self, session: Optional[requests.Session] = None) -> Optional[int]:
-        """
-        :note: `Memory` is undefined when the status is `RE` or `TLE`
-        """
-        if self._status is None:
-            self._load_details(session=session)
-            assert self._status is not None
-        return self._memory_byte
-
-    get_source_code = utils.getter_with_load_details('_source_code', type=bytes)  # type: Callable[..., bytes]
-    get_compile_error = utils.getter_with_load_details('_compiler_error', type=str)  # type: Callable[..., str]
-    get_user_id = utils.getter_with_load_details('_user_id', type=str)  # type: Callable[..., str]
-    get_submission_time = utils.getter_with_load_details('_submission_time', type=datetime.datetime)  # type: Callable[..., datetime.datetime]
-    get_language_name = utils.getter_with_load_details('_language_name', type=str)  # type: Callable[..., str]
-    get_score = utils.getter_with_load_details('_score', type=float)  # type: Callable[..., int]
-    get_code_size = utils.getter_with_load_details('_code_size', type=int)  # type: Callable[..., int]
-    get_status = utils.getter_with_load_details('_status', type=str)  # type: Callable[..., str]
-    get_test_sets = utils.getter_with_load_details('_test_sets', type='List[AtCoderSubmissionTestSet]')  # type: Callable[..., List[AtCoderSubmissionTestSet]]
-    get_test_cases = utils.getter_with_load_details('_test_cases', type='List[AtCoderSubmissionTestCaseResult]')  # type: Callable[..., List[AtCoderSubmissionTestCaseResult]]
+            test_cases = [AtCoderSubmissionTestCaseResult._from_table_row(tr) for tr in trs]  # type: Optional[List[AtCoderSubmissionTestCaseResult]]
+        else:
+            test_cases = None
+
+        return AtCoderSubmissionDetailedData(
+            code_size=code_size,
+            compile_error=compile_error,
+            exec_time_msec=exec_time_msec,
+            language_name=language_name,
+            memory_byte=memory_byte,
+            problem=AtCoderProblem(contest_id=self.contest_id, problem_id=problem_id),
+            problem_id=problem_id,
+            response=resp,
+            score=score,
+            session=session,
+            source_code=source_code,
+            status=status,
+            submission=self,
+            submission_time=submission_time,
+            test_cases=test_cases,
+            test_sets=test_sets,
+            timestamp=timestamp,
+            user_id=user_id,
+        )
 
 
 class AtCoderSubmissionTestSet(object):
@@ -952,7 +1209,7 @@ class AtCoderSubmissionTestSet(object):
     :ivar max_score: :py:class:`float`
     :ivar test_case_names: :py:class:`List` [ :py:class:`str` ]
     """
-    def __init__(self, set_name: str, score: float, max_score: float, test_case_names: List[str]):
+    def __init__(self, *, set_name: str, score: float, max_score: float, test_case_names: List[str]):
         self.set_name = set_name
         self.score = score
         self.max_score = max_score
@@ -965,7 +1222,7 @@ class AtCoderSubmissionTestSet(object):
         set_name = tds[0].text
         score, max_score = [float(s) for s in tds[1].text.split('/')]
         test_case_names = tds[2].text.split(', ')
-        return AtCoderSubmissionTestSet(set_name, score, max_score, test_case_names)
+        return AtCoderSubmissionTestSet(set_name=set_name, score=score, max_score=max_score, test_case_names=test_case_names)
 
 
 class AtCoderSubmissionTestCaseResult(object):
@@ -975,7 +1232,7 @@ class AtCoderSubmissionTestCaseResult(object):
     :ivar exec_time_msec: :py:class:`int` in millisecond
     :ivar memory_byte: :py:class:`int` in byte
     """
-    def __init__(self, case_name: str, status: str, exec_time_msec: Optional[int], memory_byte: Optional[int]):
+    def __init__(self, *, case_name: str, status: str, exec_time_msec: Optional[int], memory_byte: Optional[int]):
         self.case_name = case_name
         self.status = status
         self.exec_time_msec = exec_time_msec
@@ -993,7 +1250,7 @@ class AtCoderSubmissionTestCaseResult(object):
             memory_byte = int(utils.remove_suffix(tds[3].text, ' KB')) * 1000  # TODO: confirm this is KB truly, not KiB
         else:
             assert len(tds) == 2
-        return AtCoderSubmissionTestCaseResult(case_name, status, exec_time_msec, memory_byte)
+        return AtCoderSubmissionTestCaseResult(case_name=case_name, status=status, exec_time_msec=exec_time_msec, memory_byte=memory_byte)
 
 
 onlinejudge.dispatch.services += [AtCoderService]
diff --git a/onlinejudge/service/codeforces.py b/onlinejudge/service/codeforces.py
index 838b251..9dfda14 100644
--- a/onlinejudge/service/codeforces.py
+++ b/onlinejudge/service/codeforces.py
@@ -5,6 +5,8 @@ the module for Codeforces (https://codeforces.com/)
 :note: There is the offcial API https://codeforces.com/api/help
 """
 
+import datetime
+import json
 import re
 import string
 import urllib.parse
@@ -21,7 +23,7 @@ from onlinejudge.type import *
 
 
 class CodeforcesService(onlinejudge.type.Service):
-    def login(self, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None) -> None:
+    def login(self, *, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None) -> None:
         """
         :raises LoginError:
         """
@@ -50,7 +52,10 @@ class CodeforcesService(onlinejudge.type.Service):
             log.failure('Invalid handle or password.')
             raise LoginError('Invalid handle or password.')
 
-    def is_logged_in(self, session: Optional[requests.Session] = None) -> bool:
+    def get_url_of_login_page(self) -> str:
+        return 'https://codeforces.com/enter'
+
+    def is_logged_in(self, *, session: Optional[requests.Session] = None) -> bool:
         session = session or utils.get_default_session()
         url = 'https://codeforces.com/enter'
         resp = utils.request('GET', url, session=session, allow_redirects=False)
@@ -72,6 +77,214 @@ class CodeforcesService(onlinejudge.type.Service):
             return cls()
         return None
 
+    def iterate_contest_data(self, *, is_gym: bool = False, session: Optional[requests.Session] = None) -> Iterator['CodeforcesContestData']:
+        session = session or utils.get_default_session()
+        url = 'https://codeforces.com/api/contest.list?gym={}'.format('true' if is_gym else 'false')
+        resp = utils.request('GET', url, session=session)
+        timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone()
+        data = json.loads(resp.content.decode(resp.encoding))
+        assert data['status'] == 'OK'
+        for row in data['result']:
+            yield CodeforcesContestData._from_json(row, response=resp, session=session, timestamp=timestamp)
+
+    def iterate_contests(self, *, is_gym: bool = False, session: Optional[requests.Session] = None) -> Iterator['CodeforcesContest']:
+        for data in self.iterate_contest_data(is_gym=is_gym, session=session):
+            yield data.contest
+
+
+class CodeforcesContestData(ContestData):
+    # yapf: disable
+    def __init__(
+            self,
+            *,
+            contest: 'CodeforcesContest',
+            duration_seconds: int,
+            frozen: bool,
+            name: str,
+            phase: str,
+            relative_time_seconds: int,
+            response: requests.Response,
+            session: requests.Session,
+            start_time_seconds: int,
+            timestamp: datetime.datetime,
+            type: str  # TODO: in Python 3.5, you cannnot use both "*" and trailing ","
+    ):
+        # yapf: enable
+        self._contest = contest
+        self.duration_seconds = duration_seconds
+        self.frozen = frozen
+        self._name = name
+        self.phase = phase
+        self.relative_time_seconds = relative_time_seconds
+        self._response = response
+        self._session = session
+        self.start_time_seconds = start_time_seconds
+        self._timestamp = timestamp
+        self.type = type
+
+    @property
+    def contest(self) -> 'CodeforcesContest':
+        return self._contest
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @property
+    def json(self) -> bytes:
+        return self._response.content
+
+    @property
+    def response(self) -> requests.Response:
+        return self._response
+
+    @property
+    def session(self) -> requests.Session:
+        return self._session
+
+    @property
+    def timestamp(self) -> datetime.datetime:
+        return self._timestamp
+
+    @classmethod
+    def _from_json(cls, row: Dict[str, Any], *, response: requests.Response, session: requests.Session, timestamp: datetime.datetime) -> 'CodeforcesContestData':
+        return CodeforcesContestData(
+            contest=CodeforcesContest(contest_id=row['id']),
+            duration_seconds=int(row['durationSeconds']),
+            frozen=row['frozen'],
+            name=row['name'],
+            phase=row['phase'],
+            relative_time_seconds=int(row['relativeTimeSeconds']),
+            response=response,
+            session=session,
+            start_time_seconds=int(row['startTimeSeconds']),
+            timestamp=timestamp,
+            type=row['type'],
+        )
+
+
+class CodeforcesContest(onlinejudge.type.Contest):
+    """
+    :ivar contest_id: :py:class:`int`
+    :ivar kind: :py:class:`str` must be `contest` or `gym`
+    """
+    def __init__(self, *, contest_id: int, kind: Optional[str] = None):
+        assert kind in (None, 'contest', 'gym')
+        self.contest_id = contest_id
+        if kind is None:
+            if self.contest_id < 100000:
+                kind = 'contest'
+            else:
+                kind = 'gym'
+        self.kind = kind
+
+    def get_url(self) -> str:
+        return 'https://codeforces.com/{}/{}'.format(self.kind, self.contest_id)
+
+    @classmethod
+    def from_url(cls, url: str) -> Optional['CodeforcesContest']:
+        result = urllib.parse.urlparse(url)
+        if result.scheme in ('', 'http', 'https') \
+                and result.netloc == 'codeforces.com':
+            table = {}
+            table['contest'] = r'/contest/([0-9]+).*'.format()  # example: https://codeforces.com/contest/538
+            table['gym'] = r'/gym/([0-9]+).*'.format()  # example: https://codeforces.com/gym/101021
+            for kind, expr in table.items():
+                m = re.match(expr, utils.normpath(result.path))
+                if m:
+                    return cls(contest_id=int(m.group(1)), kind=kind)
+        return None
+
+    def get_service(self) -> CodeforcesService:
+        return CodeforcesService()
+
+    def list_problem_data(self, *, session: Optional[requests.Session] = None) -> List['CodeforcesProblemData']:
+        session = session or utils.get_default_session()
+        url = 'https://codeforces.com/api/contest.standings?contestId={}&from=1&count=1'.format(self.contest_id)
+        resp = utils.request('GET', url, session=session)
+        timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone()
+        data = json.loads(resp.content.decode(resp.encoding))
+        assert data['status'] == 'OK'
+        return [CodeforcesProblemData._from_json(row, response=resp, session=session, timestamp=timestamp) for row in data['result']['problems']]
+
+    # TODO: why is "type: ignore" required?
+    def list_problems(self, *, session: Optional[requests.Session] = None) -> List['CodeforcesProblem']:  # type: ignore
+        return [data.problem for data in self.list_problem_data(session=session)]
+
+    def download_data(self, *, session: Optional[requests.Session] = None) -> CodeforcesContestData:
+        session = session or utils.get_default_session()
+        url = 'https://codeforces.com/api/contest.standings?contestId={}&from=1&count=1'.format(self.contest_id)
+        resp = utils.request('GET', url, session=session)
+        timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone()
+        data = json.loads(resp.content.decode(resp.encoding))
+        assert data['status'] == 'OK'
+        return CodeforcesContestData._from_json(data['result']['contest'], response=resp, session=session, timestamp=timestamp)
+
+
+class CodeforcesProblemData(ProblemData):
+    # yapf: disable
+    def __init__(
+            self,
+            *,
+            name: str,
+            points: Optional[int],
+            problem: 'CodeforcesProblem',
+            rating: int,
+            response: requests.Response,
+            session: requests.Session,
+            tags: List[str],
+            timestamp: datetime.datetime,
+            type: str  # TODO: in Python 3.5, you cannnot use both "*" and trailing ","
+    ):
+        # yapf: enable
+        self._name = name
+        self.points = points
+        self._problem = problem
+        self.rating = rating
+        self._response = response
+        self._session = session
+        self.tags = tags
+        self._timestamp = timestamp
+        self.type = type
+
+    @property
+    def problem(self) -> 'CodeforcesProblem':
+        return self._problem
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @property
+    def json(self) -> bytes:
+        return self._response.content
+
+    @property
+    def response(self) -> requests.Response:
+        return self._response
+
+    @property
+    def session(self) -> requests.Session:
+        return self._session
+
+    @property
+    def timestamp(self) -> datetime.datetime:
+        return self._timestamp
+
+    @classmethod
+    def _from_json(cls, row: Dict[str, Any], response: requests.Response, session: requests.Session, timestamp: datetime.datetime) -> 'CodeforcesProblemData':
+        return CodeforcesProblemData(
+            name=row['name'],
+            points=(int(row['points']) if 'points' in row else None),
+            problem=CodeforcesProblem(contest_id=row['contestId'], index=row['index']),
+            rating=int(row['rating']),
+            response=response,
+            session=session,
+            tags=row['tags'],
+            timestamp=timestamp,
+            type=row['type'],
+        )
+
 
 # NOTE: Codeforces has its API: https://codeforces.com/api/help
 class CodeforcesProblem(onlinejudge.type.Problem):
@@ -80,7 +293,7 @@ class CodeforcesProblem(onlinejudge.type.Problem):
     :ivar index: :py:class:`str`
     :ivar kind: :py:class:`str` must be `contest` or `gym`
     """
-    def __init__(self, contest_id: int, index: str, kind: Optional[str] = None):
+    def __init__(self, *, contest_id: int, index: str, kind: Optional[str] = None):
         assert isinstance(contest_id, int)
         assert 1 <= len(index) <= 2
         assert index[0] in string.ascii_uppercase
@@ -96,7 +309,7 @@ class CodeforcesProblem(onlinejudge.type.Problem):
                 kind = 'gym'
         self.kind = kind  # It seems 'gym' is specialized, 'contest' and 'problemset' are the same thing
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
         session = session or utils.get_default_session()
         # get
         resp = utils.request('GET', self.get_url(), session=session)
@@ -119,7 +332,7 @@ class CodeforcesProblem(onlinejudge.type.Problem):
             samples.add(s.encode(), title.string)
         return samples.get()
 
-    def get_available_languages(self, session: Optional[requests.Session] = None) -> List[Language]:
+    def get_available_languages(self, *, session: Optional[requests.Session] = None) -> List[Language]:
         """
         :raises NotLoggedInError:
         """
@@ -137,7 +350,7 @@ class CodeforcesProblem(onlinejudge.type.Problem):
             languages += [Language(option.attrs['value'], option.string)]
         return languages
 
-    def submit_code(self, code: bytes, language_id: LanguageId, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> onlinejudge.type.Submission:
+    def submit_code(self, code: bytes, language_id: LanguageId, *, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> onlinejudge.type.Submission:
         """
         :raises NotLoggedInError:
         :raises SubmissionError:
@@ -184,6 +397,10 @@ class CodeforcesProblem(onlinejudge.type.Problem):
     def get_service(self) -> CodeforcesService:
         return CodeforcesService()
 
+    def get_contest(self) -> CodeforcesContest:
+        assert self.kind != 'problemset'
+        return CodeforcesContest(contest_id=self.contest_id, kind=self.kind)
+
     @classmethod
     def from_url(cls, url: str) -> Optional['CodeforcesProblem']:
         result = urllib.parse.urlparse(url)
@@ -203,9 +420,16 @@ class CodeforcesProblem(onlinejudge.type.Problem):
                         index = 'A'  # NOTE: This is broken if there was "A1".
                     else:
                         index = m.group(2).upper()
-                    return cls(int(m.group(1)), index, kind=kind)
+                    return cls(contest_id=int(m.group(1)), index=index, kind=kind)
         return None
 
+    def download_data(self, *, session: Optional[requests.Session] = None) -> CodeforcesProblemData:
+        for data in self.get_contest().list_problem_data(session=session):
+            if data.problem.get_url() == self.get_url():
+                return data
+        assert False
+
 
 onlinejudge.dispatch.services += [CodeforcesService]
+onlinejudge.dispatch.contests += [CodeforcesContest]
 onlinejudge.dispatch.problems += [CodeforcesProblem]
diff --git a/onlinejudge/service/csacademy.py b/onlinejudge/service/csacademy.py
index 3a790ea..2da1f15 100644
--- a/onlinejudge/service/csacademy.py
+++ b/onlinejudge/service/csacademy.py
@@ -34,11 +34,11 @@ class CSAcademyService(onlinejudge.type.Service):
 
 
 class CSAcademyProblem(onlinejudge.type.Problem):
-    def __init__(self, contest_name: str, task_name: str):
+    def __init__(self, *, contest_name: str, task_name: str):
         self.contest_name = contest_name
         self.task_name = task_name
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         session = session or utils.get_default_session()
         base_url = self.get_url()
 
@@ -115,7 +115,7 @@ class CSAcademyProblem(onlinejudge.type.Problem):
                 and result.netloc in ('csacademy.com', 'www.csacademy.com'):
             m = re.match(r'^/contest/([0-9A-Za-z_-]+)/task/([0-9A-Za-z_-]+)(|/statement|/solution|/discussion|/statistics|/submissions)/?$', utils.normpath(result.path))
             if m:
-                return cls(m.group(1), m.group(2))
+                return cls(contest_name=m.group(1), task_name=m.group(2))
         return None
 
 
diff --git a/onlinejudge/service/facebook.py b/onlinejudge/service/facebook.py
index b578670..47de5f6 100644
--- a/onlinejudge/service/facebook.py
+++ b/onlinejudge/service/facebook.py
@@ -41,7 +41,7 @@ class FacebookHackerCupProblem(onlinejudge.type.Problem):
     def __init__(self, *, problem_id: int):
         self.problem_id = problem_id
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         session = session or utils.get_default_session()
         url_format = 'https://www.facebook.com/hackercup/example/?problem_id={}&type={}'
         resp_in = utils.request('GET', url_format.format(self.problem_id, 'input'), session=session)
diff --git a/onlinejudge/service/hackerrank.py b/onlinejudge/service/hackerrank.py
index 73fae6e..5f10924 100644
--- a/onlinejudge/service/hackerrank.py
+++ b/onlinejudge/service/hackerrank.py
@@ -20,42 +20,10 @@ from onlinejudge.type import *
 
 
 class HackerRankService(onlinejudge.type.Service):
-    def login(self, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None) -> None:
-        """
-        :raises LoginError:
-        """
-
-        session = session or utils.get_default_session()
-        url = 'https://www.hackerrank.com/auth/login'
-        # get
-        resp = utils.request('GET', url, session=session)
-        if resp.url != url:
-            log.info('You have already signed in.')
-            return
-        # parse
-        soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
-        csrftoken = soup.find('meta', attrs={'name': 'csrf-token'}).attrs['content']
-        tag = soup.find('input', attrs={'name': 'username'})
-        while tag.name != 'form':
-            tag = tag.parent
-        form = tag
-        # post
-        username, password = get_credentials()
-        form = utils.FormSender(form, url=resp.url)
-        form.set('login', username)
-        form.set('password', password)
-        form.set('remember_me', 'true')
-        form.set('fallback', 'true')
-        resp = form.request(session, method='POST', action='/rest/auth/login', headers={'X-CSRF-Token': csrftoken})
-        resp.raise_for_status()
-        # result
-        if '/auth' not in resp.url:
-            log.success('You signed in.')
-        else:
-            log.failure('You failed to sign in. Wrong user ID or password.')
-            raise LoginError('You failed to sign in. Wrong user ID or password.')
+    def get_url_of_login_page(self) -> str:
+        return 'https://www.hackerrank.com/auth/login'
 
-    def is_logged_in(self, session: Optional[requests.Session] = None) -> bool:
+    def is_logged_in(self, *, session: Optional[requests.Session] = None) -> bool:
         session = session or utils.get_default_session()
         url = 'https://www.hackerrank.com/auth/login'
         resp = utils.request('GET', url, session=session)
@@ -86,14 +54,14 @@ class HackerRankProblem(onlinejudge.type.Problem):
         self.contest_slug = contest_slug
         self.challenge_slug = challenge_slug
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         """
         :raises NotImplementedError:
         """
         log.warning('use --system option')
         raise NotImplementedError
 
-    def download_system_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_system_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         session = session or utils.get_default_session()
         # example: https://www.hackerrank.com/rest/contests/hourrank-1/challenges/beautiful-array/download_testcases
         url = 'https://www.hackerrank.com/rest/contests/{}/challenges/{}/download_testcases'.format(self.contest_slug, self.challenge_slug)
@@ -121,13 +89,13 @@ class HackerRankProblem(onlinejudge.type.Problem):
                 and result.netloc in ('hackerrank.com', 'www.hackerrank.com'):
             m = re.match(r'^/contests/([0-9A-Za-z-]+)/challenges/([0-9A-Za-z-]+)$', utils.normpath(result.path))
             if m:
-                return cls(m.group(1), m.group(2))
+                return cls(contest_slug=m.group(1), challenge_slug=m.group(2))
             m = re.match(r'^/challenges/([0-9A-Za-z-]+)$', utils.normpath(result.path))
             if m:
-                return cls('master', m.group(1))
+                return cls(contest_slug='master', challenge_slug=m.group(1))
         return None
 
-    def _get_model(self, session: Optional[requests.Session] = None) -> Dict[str, Any]:
+    def _get_model(self, *, session: Optional[requests.Session] = None) -> Dict[str, Any]:
         """
         :raises SubmissionError:
         """
@@ -144,7 +112,7 @@ class HackerRankProblem(onlinejudge.type.Problem):
             raise SubmissionError
         return it['model']
 
-    def _get_lang_display_mapping(self, session: Optional[requests.Session] = None) -> Dict[str, str]:
+    def _get_lang_display_mapping(self, *, session: Optional[requests.Session] = None) -> Dict[str, str]:
         session = session or utils.get_default_session()
         # get
         url = 'https://hrcdn.net/hackerrank/assets/codeshell/dist/codeshell-cdffcdf1564c6416e1a2eb207a4521ce.js'  # at "Mon Feb  4 14:51:27 JST 2019"
@@ -163,7 +131,7 @@ class HackerRankProblem(onlinejudge.type.Problem):
         log.debug('lang_display_mapping (parsed): %s', lang_display_mapping)
         return lang_display_mapping
 
-    def get_available_languages(self, session: Optional[requests.Session] = None) -> List[Language]:
+    def get_available_languages(self, *, session: Optional[requests.Session] = None) -> List[Language]:
         session = session or utils.get_default_session()
         info = self._get_model(session=session)
         lang_display_mapping = self._get_lang_display_mapping()
@@ -176,7 +144,7 @@ class HackerRankProblem(onlinejudge.type.Problem):
             result += [Language(lang, descr)]
         return result
 
-    def submit_code(self, code: bytes, language_id: LanguageId, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> onlinejudge.type.Submission:
+    def submit_code(self, code: bytes, language_id: LanguageId, *, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> onlinejudge.type.Submission:
         """
         :raises NotLoggedInError:
         :raises SubmissionError:
diff --git a/onlinejudge/service/kattis.py b/onlinejudge/service/kattis.py
index 55240d0..d9b13f8 100644
--- a/onlinejudge/service/kattis.py
+++ b/onlinejudge/service/kattis.py
@@ -40,12 +40,12 @@ class KattisService(onlinejudge.type.Service):
 
 
 class KattisProblem(onlinejudge.type.Problem):
-    def __init__(self, problem_id: str, contest_id: Optional[str] = None, domain: str = 'open.kattis.com'):
+    def __init__(self, *, problem_id: str, contest_id: Optional[str] = None, domain: str = 'open.kattis.com'):
         self.domain = domain
         self.contest_id = contest_id
         self.problem_id = problem_id
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
         session = session or utils.get_default_session()
         # get
         url = self.get_url(contests=False) + '/file/statement/samples.zip'
@@ -58,7 +58,7 @@ class KattisProblem(onlinejudge.type.Problem):
         # parse
         return onlinejudge._implementation.testcase_zipper.extract_from_zip(resp.content, '%s.%e', out='ans')
 
-    def get_url(self, contests: bool = True) -> str:
+    def get_url(self, *, contests: bool = True) -> str:
         if contests and self.contest_id is not None:
             # the URL without "/contests/{}" also works
             return 'https://{}/contests/{}/problems/{}'.format(self.domain, self.contest_id, self.problem_id)
@@ -80,7 +80,7 @@ class KattisProblem(onlinejudge.type.Problem):
             if m:
                 contest_id = m.group(1) or None
                 problem_id = m.group(2)
-                return cls(problem_id, contest_id=contest_id, domain=result.netloc)
+                return cls(problem_id=problem_id, contest_id=contest_id, domain=result.netloc)
         return None
 
 
diff --git a/onlinejudge/service/poj.py b/onlinejudge/service/poj.py
index 98f14c5..991f2ae 100644
--- a/onlinejudge/service/poj.py
+++ b/onlinejudge/service/poj.py
@@ -34,10 +34,10 @@ class POJService(onlinejudge.type.Service):
 
 
 class POJProblem(onlinejudge.type.Problem):
-    def __init__(self, problem_id: int):
+    def __init__(self, *, problem_id: int):
         self.problem_id = problem_id
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         session = session or utils.get_default_session()
         # get
         resp = utils.request('GET', self.get_url(), session=session)
@@ -99,7 +99,7 @@ class POJProblem(onlinejudge.type.Problem):
             if 'id' in query and len(query['id']) == 1:
                 try:
                     n = int(query['id'][0])
-                    return cls(n)
+                    return cls(problem_id=n)
                 except ValueError:
                     pass
         return None
diff --git a/onlinejudge/service/topcoder.py b/onlinejudge/service/topcoder.py
index 59a13ec..835747b 100644
--- a/onlinejudge/service/topcoder.py
+++ b/onlinejudge/service/topcoder.py
@@ -23,7 +23,7 @@ from onlinejudge.type import *
 
 
 class TopcoderService(onlinejudge.type.Service):
-    def login(self, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None) -> None:
+    def login(self, *, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None) -> None:
         """
         :raises LoginError:
         """
@@ -47,7 +47,10 @@ class TopcoderService(onlinejudge.type.Service):
             log.failure('Failure')
             raise LoginError
 
-    def is_logged_in(self, session: Optional[requests.Session] = None) -> bool:
+    def get_url_of_login_page(self):
+        return 'https://accounts.topcoder.com/member'
+
+    def is_logged_in(self, *, session: Optional[requests.Session] = None) -> bool:
         """
         .. versionadded:: 6.2.0
         """
@@ -118,7 +121,7 @@ TopcoderLongContestProblemIndividualResultsFeed = NamedTuple('TopcoderLongContes
 
 
 class TopcoderLongContestProblem(onlinejudge.type.Problem):
-    def __init__(self, rd, cd=None, compid=None, pm=None):
+    def __init__(self, *, rd, cd=None, compid=None, pm=None):
         self.rd = rd
         self.cd = cd
         self.compid = compid
@@ -130,7 +133,7 @@ class TopcoderLongContestProblem(onlinejudge.type.Problem):
     def get_service(self) -> TopcoderService:
         return TopcoderService()
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
         """
         :raises NotImplementedError:
         """
@@ -155,7 +158,7 @@ class TopcoderLongContestProblem(onlinejudge.type.Problem):
                 return cls(**kwargs)
         return None
 
-    def get_available_languages(self, session: Optional[requests.Session] = None) -> List[Language]:
+    def get_available_languages(self, *, session: Optional[requests.Session] = None) -> List[Language]:
         session = session or utils.get_default_session()
 
         return [
@@ -166,7 +169,7 @@ class TopcoderLongContestProblem(onlinejudge.type.Problem):
             Language(LanguageId('6'), 'Python 2'),
         ]
 
-    def submit_code(self, code: bytes, language_id: LanguageId, filename: Optional[str] = None, session: Optional[requests.Session] = None, kind: str = 'example') -> onlinejudge.type.Submission:
+    def submit_code(self, code: bytes, language_id: LanguageId, *, filename: Optional[str] = None, session: Optional[requests.Session] = None, kind: str = 'example') -> onlinejudge.type.Submission:
         """
         :param kind: must be one of `example` (default) or `full`
         :raises NotLoggedInError:
@@ -237,7 +240,7 @@ class TopcoderLongContestProblem(onlinejudge.type.Problem):
             log.failure('%s', messages)
             raise SubmissionError('it may be a rate limit: ' + messages)
 
-    def download_standings(self, session: Optional[requests.Session] = None) -> List[TopcoderLongContestProblemStandingsRow]:
+    def download_standings(self, *, session: Optional[requests.Session] = None) -> List[TopcoderLongContestProblemStandingsRow]:
         """
         :raises Exception: if redirected to `module=ViewOverview` page
 
@@ -282,7 +285,7 @@ class TopcoderLongContestProblem(onlinejudge.type.Problem):
 
         return rows
 
-    def download_overview(self, session: Optional[requests.Session] = None) -> List[TopcoderLongContestProblemOverviewRow]:
+    def download_overview(self, *, session: Optional[requests.Session] = None) -> List[TopcoderLongContestProblemOverviewRow]:
         """
         .. versionadded:: 6.2.0
             This method may be deleted in future.
@@ -316,7 +319,7 @@ class TopcoderLongContestProblem(onlinejudge.type.Problem):
             overview += [TopcoderLongContestProblemOverviewRow(rank, handle, provisional_rank, provisional_score, final_score, language, cr=int(query['cr']))]
         return overview
 
-    def download_individual_results_feed(self, cr: int, session: Optional[requests.Session] = None) -> TopcoderLongContestProblemIndividualResultsFeed:
+    def download_individual_results_feed(self, *, cr: int, session: Optional[requests.Session] = None) -> TopcoderLongContestProblemIndividualResultsFeed:
         """
         .. versionadded:: 6.2.0
             This method may be deleted in future.
@@ -355,7 +358,7 @@ class TopcoderLongContestProblem(onlinejudge.type.Problem):
             testcases += [TopcoderLongContestProblemIndividualResultsFeedTestCase(test_case_id, score, processing_time, fatal_error_ind)]
         return TopcoderLongContestProblemIndividualResultsFeed(round_id, coder_id, handle, submissions, testcases)
 
-    def download_system_test(self, test_case_id: int, session: Optional[requests.Session] = None) -> str:
+    def download_system_test(self, *, test_case_id: int, session: Optional[requests.Session] = None) -> str:
         """
         :raises NotLoggedInError:
         :note: You need to parse this result manually.
diff --git a/onlinejudge/service/toph.py b/onlinejudge/service/toph.py
index b745d49..9fa8f75 100644
--- a/onlinejudge/service/toph.py
+++ b/onlinejudge/service/toph.py
@@ -20,38 +20,10 @@ from onlinejudge.type import *
 
 
 class TophService(onlinejudge.type.Service):
-    def login(self, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None) -> None:
-        """
-        :raises LoginError:
-        """
-        session = session or utils.get_default_session()
-        url = 'https://toph.co/login'
-        # get
-        resp = utils.request('GET', url, session=session)
-        if resp.url != url:  # redirected
-            log.info('You are already logged in.')
-            return
-        # parse
-        soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
-        form = soup.find('form', class_='login-form')
-        log.debug('form: %s', str(form))
-        username, password = get_credentials()
-        form['action'] = '/login'  # to avoid KeyError inside form.request method as Toph does not have any defined action
-        form = utils.FormSender(form, url=resp.url)
-        form.set('handle', username)
-        form.set('password', password)
-        # post
-        resp = form.request(session)
-        resp.raise_for_status()
-
-        resp = utils.request('GET', url, session=session)  # Toph's Location header is not getting the expected value
-        if resp.url != url:
-            log.success('Welcome, %s.', username)
-        else:
-            log.failure('Invalid handle/email or password.')
-            raise LoginError('Invalid handle/email or password.')
+    def get_url_of_login_page(self) -> str:
+        return 'https://toph.co/login'
 
-    def is_logged_in(self, session: Optional[requests.Session] = None) -> bool:
+    def is_logged_in(self, *, session: Optional[requests.Session] = None) -> bool:
         session = session or utils.get_default_session()
         url = 'https://toph.co/login'
         resp = utils.request('GET', url, session=session, allow_redirects=False)
@@ -79,13 +51,13 @@ class TophProblem(onlinejudge.type.Problem):
     :ivar problem_id: :py:class:`str`
     :ivar contest_id: :py:class:`Optional` [ :py:class:`str` ]
     """
-    def __init__(self, problem_id: str, contest_id: Optional[str] = None):
+    def __init__(self, *, problem_id: str, contest_id: Optional[str] = None):
         assert isinstance(problem_id, str)
         if contest_id is not None:
             raise NotImplementedError
         self.problem_id = problem_id
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
         session = session or utils.get_default_session()
         resp = utils.request('GET', self.get_url(), session=session)
         soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
@@ -103,7 +75,7 @@ class TophProblem(onlinejudge.type.Problem):
             samples.add(output_pre.text.lstrip().encode(), "Output")
         return samples.get()
 
-    def get_available_languages(self, session: Optional[requests.Session] = None) -> List[Language]:
+    def get_available_languages(self, *, session: Optional[requests.Session] = None) -> List[Language]:
         """
         :raises NotImplementedError:
         """
@@ -120,7 +92,7 @@ class TophProblem(onlinejudge.type.Problem):
             languages += [Language(LanguageId(option.attrs['value']), option.string.strip())]
         return languages
 
-    def submit_code(self, code: bytes, language_id: LanguageId, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> Submission:
+    def submit_code(self, code: bytes, language_id: LanguageId, *, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> Submission:
         """
         :raises NotImplementedError:
         :raises SubmissionError:
@@ -173,7 +145,7 @@ class TophProblem(onlinejudge.type.Problem):
                 and dirname == '/p' \
                 and basename:
             problem_id = basename
-            return cls(problem_id)
+            return cls(problem_id=problem_id)
 
         return None
 
diff --git a/onlinejudge/service/yukicoder.py b/onlinejudge/service/yukicoder.py
index 04dd07b..c78053e 100644
--- a/onlinejudge/service/yukicoder.py
+++ b/onlinejudge/service/yukicoder.py
@@ -21,57 +21,10 @@ from onlinejudge.type import *
 
 
 class YukicoderService(onlinejudge.type.Service):
-    def login(self, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None, method: Optional[str] = None) -> None:
-        if method == 'github':
-            return self.login_with_github(get_credentials, session=session)
-        elif method == 'twitter':
-            return self.login_with_twitter(get_credentials, session=session)
-        else:
-            assert False
-
-    def login_with_github(self, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None) -> None:
-        """
-        :raise LoginError:
-        """
-
-        session = session or utils.get_default_session()
-        url = 'https://yukicoder.me/auth/github'
-        # get
-        resp = utils.request('GET', url, session=session)
-        if urllib.parse.urlparse(resp.url).hostname == 'yukicoder.me':
-            log.info('You have already signed in.')
-            return
-        # redirect to github.com
-        # parse
-        soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
-        form = soup.find('form')
-        if not form:
-            log.error('form not found')
-            raise LoginError('something wrong')
-        log.debug('form: %s', str(form))
-        # post
-        username, password = get_credentials()
-        form = utils.FormSender(form, url=resp.url)
-        form.set('login', username)
-        form.set('password', password)
-        resp = form.request(session)
-        resp.raise_for_status()
-        if urllib.parse.urlparse(resp.url).hostname == 'yukicoder.me':
-            log.success('You signed in.')
-        else:
-            log.failure('You failed to sign in. Wrong user ID or password.')
-            raise LoginError
-
-    def login_with_twitter(self, get_credentials: onlinejudge.type.CredentialsProvider, session: Optional[requests.Session] = None) -> None:
-        """
-        :raise NotImplementedError: always raised
-        """
-
-        session = session or utils.get_default_session()
-        url = 'https://yukicoder.me/auth/twitter'
-        raise NotImplementedError
+    def get_url_of_login_page(self):
+        return self.get_url()
 
-    def is_logged_in(self, session: Optional[requests.Session] = None, method: Optional[str] = None) -> bool:
+    def is_logged_in(self, *, session: Optional[requests.Session] = None, method: Optional[str] = None) -> bool:
         session = session or utils.get_default_session()
         url = 'https://yukicoder.me/auth/github'
         resp = utils.request('GET', url, session=session, allow_redirects=False)
@@ -93,7 +46,7 @@ class YukicoderService(onlinejudge.type.Service):
             return cls()
         return None
 
-    def _issue_official_api(self, api: str, id: Optional[int] = None, name: Optional[str] = None, session: Optional[requests.Session] = None) -> Any:
+    def _issue_official_api(self, api: str, id: Optional[int] = None, name: Optional[str] = None, *, session: Optional[requests.Session] = None) -> Any:
         assert (id is not None) != (name is not None)
         if id is not None:
             assert isinstance(id, int)
@@ -128,7 +81,7 @@ class YukicoderService(onlinejudge.type.Service):
         return self._issue_official_api('solved', *args, **kwargs)
 
     # example: https://yukicoder.me/users/237/favorite
-    def get_user_favorite(self, id: int, session: Optional[requests.Session] = None) -> List[Any]:
+    def get_user_favorite(self, id: int, *, session: Optional[requests.Session] = None) -> List[Any]:
         """
         .. deprecated:: 6.0.0
             This method may be deleted in future.
@@ -173,7 +126,7 @@ class YukicoderService(onlinejudge.type.Service):
         return rows
 
     # example: https://yukicoder.me/users/1786/favoriteWiki
-    def get_user_favorite_wiki(self, id: int, session: Optional[requests.Session] = None) -> List[Any]:
+    def get_user_favorite_wiki(self, id: int, *, session: Optional[requests.Session] = None) -> List[Any]:
         """
         .. deprecated:: 6.0.0
             This method may be deleted in future.
@@ -190,7 +143,7 @@ class YukicoderService(onlinejudge.type.Service):
     # example: https://yukicoder.me/submissions?page=4220
     # example: https://yukicoder.me/submissions?page=2192&status=AC
     # NOTE: 1ページしか読まない 全部欲しい場合は呼び出し側で頑張る
-    def get_submissions(self, page: int, status: Optional[str] = None, session: Optional[requests.Session] = None) -> List[Any]:
+    def get_submissions(self, *, page: int, status: Optional[str] = None, session: Optional[requests.Session] = None) -> List[Any]:
         """
         .. deprecated:: 6.0.0
             This method may be deleted in future.
@@ -216,7 +169,7 @@ class YukicoderService(onlinejudge.type.Service):
 
     # example: https://yukicoder.me/problems?page=2
     # NOTE: loginしてると
-    def get_problems(self, page: int, comp_problem: bool = True, other: bool = False, sort: Optional[str] = None, session: Optional[requests.Session] = None) -> List[Any]:
+    def get_problems(self, *, page: int, comp_problem: bool = True, other: bool = False, sort: Optional[str] = None, session: Optional[requests.Session] = None) -> List[Any]:
         """
         .. deprecated:: 6.0.0
             This method may be deleted in future.
@@ -257,7 +210,7 @@ class YukicoderService(onlinejudge.type.Service):
                     row[column] = row[column].text.strip()
         return rows
 
-    def _get_and_parse_the_table(self, url: str, session: Optional[requests.Session] = None) -> Tuple[List[Any], List[Dict[str, bs4.Tag]]]:
+    def _get_and_parse_the_table(self, url: str, *, session: Optional[requests.Session] = None) -> Tuple[List[Any], List[Dict[str, bs4.Tag]]]:
         # get
         session = session or utils.get_default_session()
         resp = utils.request('GET', url, session=session)
@@ -281,14 +234,14 @@ class YukicoderService(onlinejudge.type.Service):
 
 
 class YukicoderProblem(onlinejudge.type.Problem):
-    def __init__(self, problem_no=None, problem_id=None):
+    def __init__(self, *, problem_no=None, problem_id=None):
         assert problem_no or problem_id
         assert not problem_no or isinstance(problem_no, int)
         assert not problem_id or isinstance(problem_id, int)
         self.problem_no = problem_no
         self.problem_id = problem_id
 
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         session = session or utils.get_default_session()
         # get
         resp = utils.request('GET', self.get_url(), session=session)
@@ -303,7 +256,7 @@ class YukicoderProblem(onlinejudge.type.Problem):
                 samples.add(data.encode(), name)
         return samples.get()
 
-    def download_system_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_system_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         """
         :raises NotLoggedInError:
         """
@@ -328,7 +281,7 @@ class YukicoderProblem(onlinejudge.type.Problem):
             return utils.textfile(s.lstrip()), pprv.string + ' ' + prv.string
         return None
 
-    def submit_code(self, code: bytes, language_id: LanguageId, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> onlinejudge.type.Submission:
+    def submit_code(self, code: bytes, language_id: LanguageId, *, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> onlinejudge.type.Submission:
         """
         :raises NotLoggedInError:
         """
@@ -362,7 +315,7 @@ class YukicoderProblem(onlinejudge.type.Problem):
                 log.warning('yukicoder says: "%s"', div.string)
             raise SubmissionError
 
-    def get_available_languages(self, session: Optional[requests.Session] = None) -> List[Language]:
+    def get_available_languages(self, *, session: Optional[requests.Session] = None) -> List[Language]:
         session = session or utils.get_default_session()
         # get
         # We use the problem page since it is available without logging in
@@ -407,7 +360,7 @@ class YukicoderProblem(onlinejudge.type.Problem):
     def get_service(self) -> YukicoderService:
         return YukicoderService()
 
-    def get_input_format(self, session: Optional[requests.Session] = None) -> Optional[str]:
+    def get_input_format(self, *, session: Optional[requests.Session] = None) -> Optional[str]:
         session = session or utils.get_default_session()
         # get
         resp = utils.request('GET', self.get_url(), session=session)
diff --git a/onlinejudge/type.py b/onlinejudge/type.py
index 055b999..ef601a5 100644
--- a/onlinejudge/type.py
+++ b/onlinejudge/type.py
@@ -1,6 +1,14 @@
 # Python Version: 3.x
+"""
+the module containing base types
+
+:note: Some methods are not implemented in subclasses.
+    Please check the definitions of subclasses under :py:mod:`onlinejudge.service`.
+"""
+
+import datetime
 from abc import ABC, abstractmethod
-from typing import Callable, List, NamedTuple, NewType, Optional, Tuple
+from typing import Callable, Iterator, List, NamedTuple, NewType, Optional, Tuple
 
 import requests
 
@@ -12,14 +20,20 @@ class LoginError(RuntimeError):
 
 
 class Service(ABC):
-    def login(self, get_credentials: CredentialsProvider, session: Optional[requests.Session] = None) -> None:
+    def login(self, *, get_credentials: CredentialsProvider, session: Optional[requests.Session] = None) -> None:
         """
         :param get_credentials: returns a tuple of (username, password)
         :raises LoginError:
         """
         raise NotImplementedError
 
-    def is_logged_in(self, session: Optional[requests.Session] = None) -> bool:
+    def get_url_of_login_page(self) -> str:
+        """
+        .. versionadded:: 7.0.0
+        """
+        raise NotImplementedError
+
+    def is_logged_in(self, *, session: Optional[requests.Session] = None) -> bool:
         raise NotImplementedError
 
     @abstractmethod
@@ -50,14 +64,23 @@ class Service(ABC):
     def from_url(self, s: str) -> Optional['Service']:
         pass
 
+    def iterate_contests(self, *, session: Optional[requests.Session] = None) -> Iterator['Contest']:
+        """
+        .. versionadded:: 7.0.0
+        """
+        raise NotImplementedError
+
 
 TestCase = NamedTuple('TestCase', [
     ('name', str),
     ('input_name', str),
     ('input_data', bytes),
-    ('output_name', Optional[str]),
-    ('output_data', Optional[bytes]),
+    ('output_name', str),
+    ('output_data', bytes),
 ])
+"""
+.. versionchanged:: 7.0.0
+"""
 
 LanguageId = NewType('LanguageId', str)
 """
@@ -78,25 +101,162 @@ class NotLoggedInError(RuntimeError):
     pass
 
 
+class SampleParseError(RuntimeError):
+    """
+    .. versionadded:: 7.0.0
+    """
+    pass
+
+
 class SubmissionError(RuntimeError):
     pass
 
 
+class DownloadedData(ABC):
+    """
+    :note: :py:class:`DownloadedData` and its subclasses represent contents which are obtained by network access. The values may depends your session.
+           :py:class:`DownloadedData` とそのサブクラスは、ネットワークアクセスの結果得られるようなデータを表現します。その値はログイン状況などにより接続のたびに変化することがあります。
+
+    .. versionadded:: 7.0.0
+    """
+    @property
+    @abstractmethod
+    def url(self) -> str:
+        raise NotImplementedError
+
+    @property
+    def json(self) -> Optional[bytes]:
+        return None
+
+    @property
+    def html(self) -> Optional[bytes]:
+        return None
+
+    @property
+    def timestamp(self) -> Optional[datetime.datetime]:
+        return None
+
+    @property
+    def session(self) -> Optional[requests.Session]:
+        return None
+
+    @property
+    def response(self) -> Optional[requests.Response]:
+        return None
+
+
+class ContestData(DownloadedData):
+    """
+    .. versionadded:: 7.0.0
+    """
+    def url(self) -> str:
+        return self.contest.get_url()
+
+    @property
+    @abstractmethod
+    def contest(self) -> 'Contest':
+        raise NotImplementedError
+
+    @property
+    def service(self) -> Service:
+        return self.contest.get_service()
+
+    @property
+    @abstractmethod
+    def name(self) -> str:
+        raise NotImplementedError
+
+
+class Contest(ABC):
+    """
+    :note: :py:class:`Contest` represents just a URL of a contest, without the data of the contest.
+
+    .. versionadded:: 7.0.0
+    """
+    def list_problems(self, *, session: Optional[requests.Session] = None) -> List['Problem']:
+        raise NotImplementedError
+
+    def download_data(self, *, session: Optional[requests.Session] = None) -> ContestData:
+        raise NotImplementedError
+
+    def iterate_submissions(self, *, session: Optional[requests.Session] = None) -> Iterator['Submission']:
+        """
+        .. versionadded:: 7.0.0
+        """
+        raise NotImplementedError
+
+    @abstractmethod
+    def get_url(self) -> str:
+        raise NotImplementedError
+
+    @abstractmethod
+    def get_service(self) -> Service:
+        raise NotImplementedError
+
+    @classmethod
+    @abstractmethod
+    def from_url(self, s: str) -> Optional['Contest']:
+        pass
+
+
+class ProblemData(DownloadedData):
+    """
+    .. versionadded:: 7.0.0
+    """
+    def url(self) -> str:
+        return self.problem.get_url()
+
+    @property
+    @abstractmethod
+    def problem(self) -> 'Problem':
+        raise NotImplementedError
+
+    @property
+    def contest(self) -> Contest:
+        return self.problem.get_contest()
+
+    @property
+    def service(self) -> Service:
+        return self.problem.get_service()
+
+    @property
+    @abstractmethod
+    def name(self) -> str:
+        """
+        for example of :py:class:`Problem`:
+
+        -   `器物損壊！高橋君`
+        -   `AtCoDeerくんと変なじゃんけん / AtCoDeer and Rock-Paper`
+        -   `Xor Sum`
+        """
+        raise NotImplementedError
+
+    @property
+    def sample_cases(self) -> Optional[List[TestCase]]:
+        raise NotImplementedError
+
+
 class Problem(ABC):
     """
     :note: :py:class:`Problem` represents just a URL of a problem, without the data of the problem.
+           :py:class:`Problem` はちょうど問題の URL のみを表現します。キャッシュや内部状態は持ちません。
     """
     @abstractmethod
-    def download_sample_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
+        """
+        :raises SampleParseError:
+
+        .. versionchanged:: 7.0.0
+        """
         raise NotImplementedError
 
-    def download_system_cases(self, session: Optional[requests.Session] = None) -> List[TestCase]:
+    def download_system_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
         """
         :raises NotLoggedInError:
         """
         raise NotImplementedError
 
-    def submit_code(self, code: bytes, language_id: LanguageId, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> 'Submission':
+    def submit_code(self, code: bytes, language_id: LanguageId, *, filename: Optional[str] = None, session: Optional[requests.Session] = None) -> 'Submission':
         """
         :param code:
         :arg language_id: :py:class:`LanguageId`
@@ -105,32 +265,24 @@ class Problem(ABC):
         """
         raise NotImplementedError
 
-    def get_available_languages(self, session: Optional[requests.Session] = None) -> List[Language]:
+    def get_available_languages(self, *, session: Optional[requests.Session] = None) -> List[Language]:
         raise NotImplementedError
 
     @abstractmethod
     def get_url(self) -> str:
         raise NotImplementedError
 
-    @abstractmethod
-    def get_service(self) -> Service:
+    def get_contest(self) -> Contest:
         raise NotImplementedError
 
-    def get_name(self) -> str:
-        """
-        example:
-
-        -   `器物損壊！高橋君`
-        -   `AtCoDeerくんと変なじゃんけん / AtCoDeer and Rock-Paper`
-        -   `Xor Sum`
-        """
+    @abstractmethod
+    def get_service(self) -> Service:
         raise NotImplementedError
 
-    def get_input_format(self, session: Optional[requests.Session] = None) -> Optional[str]:
+    def download_data(self, *, session: Optional[requests.Session] = None) -> ProblemData:
         """
-        :return: the HTML in the `<pre>` tag as :py:class:`str`
+        .. versionadded:: 7.0.0
         """
-
         raise NotImplementedError
 
     def __repr__(self) -> str:
@@ -145,9 +297,45 @@ class Problem(ABC):
         pass
 
 
-class Submission(ABC):
+class SubmissionData(DownloadedData):
+    """
+    .. versionadded:: 7.0.0
+    """
+    def url(self) -> str:
+        return self.submission.get_url()
+
+    @property
     @abstractmethod
-    def download_code(self, session: Optional[requests.Session] = None) -> bytes:
+    def submission(self) -> 'Submission':
+        raise NotImplementedError
+
+    @property
+    def problem(self) -> Problem:
+        return self.submission.get_problem()
+
+    @property
+    def contest(self) -> Contest:
+        return self.submission.get_contest()
+
+    @property
+    def service(self) -> Service:
+        return self.submission.get_service()
+
+    @property
+    def source_code(self) -> bytes:
+        raise NotImplementedError
+
+    @property
+    @abstractmethod
+    def status(self) -> str:
+        raise NotImplementedError
+
+
+class Submission(ABC):
+    def download_data(self, *, session: Optional[requests.Session] = None) -> SubmissionData:
+        """
+        .. versionadded:: 7.0.0
+        """
         raise NotImplementedError
 
     @abstractmethod
@@ -158,6 +346,9 @@ class Submission(ABC):
     def get_problem(self) -> Problem:
         raise NotImplementedError
 
+    def get_contest(self) -> Contest:
+        return self.get_problem().get_contest()
+
     @abstractmethod
     def get_service(self) -> Service:
         return self.get_problem().get_service()
diff --git a/readme.md b/readme.md
index 9ed0421..a0bc7a8 100644
--- a/readme.md
+++ b/readme.md
@@ -33,9 +33,9 @@ Tools for online judge services. Downloading sample cases, Testing/Submitting yo
 -   Download system test cases
     -   yukicoder
     -   Aizu Online Judge
--   Login
+-   Login (with CUI and/or GUI)
     -   AtCoder
-    -   yukicoder (via github.com or [session token](https://github.com/kmyk/online-judge-tools/blob/master/LOGIN_WITH_COOKIES.md))
+    -   yukicoder
     -   Codeforces
     -   HackerRank
     -   Topcoder
@@ -52,6 +52,7 @@ Tools for online judge services. Downloading sample cases, Testing/Submitting yo
     -   yukicoder
 -   Test your solution
 -   Test your solution for reactive problem
+-   Generate input files from generators
 -   Generate output files from input and reference implementation
 -   Split an input file with many cases to files
 
@@ -185,7 +186,7 @@ AtCoder Beginner Contest 118: B - Foods Loved by Everyone    kotatsugame  (37 by
 -   Can I specify problems by their IDs or names, instead of URLs?
     -   No. I have tried it once, but it is actually not so convenient and only increases the maintenance cost.
 -   I don't want to give my password to this program.
-    -   You can use this giving only your session tokens. Please see [here](https://github.com/kmyk/online-judge-tools/blob/master/LOGIN_WITH_COOKIES.md).
+    -   This program stores only your session tokens. Use `oj login --use-brower=always ...` and read [onlinejudge/_implementation/command/login.py](https://github.com/kmyk/online-judge-tools/blob/master/onlinejudge/_implementation/command/login.py).
 
 For other questions, use [Gitter](https://gitter.im/online-judge-tools/community) or other SNSs.
 
diff --git a/setup.cfg b/setup.cfg
index a6c5f4a..cfcc103 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -15,6 +15,8 @@ classifiers =
     Topic :: Utilities
 
 [options.extras_require]
+selenium =
+    selenium >= 3.141.0
 dev =
     isort >= 4.3.4
     mypy >= 0.660
@@ -44,6 +46,9 @@ ignore_missing_imports = True
 [mypy-colorama.*]
 ignore_missing_imports = True
 
+[mypy-selenium.*]
+ignore_missing_imports = True
+
 [mypy-setuptools.*]
 ignore_missing_imports = True
 


Test patch:
diff --git a/onlinejudge/_implementation/testcase_zipper.py b/onlinejudge/_implementation/testcase_zipper.py
index f4be98d..69845df 100644
--- a/onlinejudge/_implementation/testcase_zipper.py
+++ b/onlinejudge/_implementation/testcase_zipper.py
@@ -33,6 +33,7 @@ class SampleZipper(object):
     def get(self) -> List[TestCase]:
         if self._dangling is not None:
             log.error('dangling sample string: %s', self._dangling[1])
+            raise SampleParseError
         return self._testcases
 
 
diff --git a/tests/command_download_atcoder.py b/tests/command_download_atcoder.py
index 096c21a..5481158 100644
--- a/tests/command_download_atcoder.py
+++ b/tests/command_download_atcoder.py
@@ -3,6 +3,8 @@ import unittest
 import requests.exceptions
 import tests.command_download
 
+from onlinejudge.type import SampleParseError
+
 
 class DownloadAtCoderTest(unittest.TestCase):
     def snippet_call_download(self, *args, **kwargs):
@@ -131,3 +133,7 @@ class DownloadAtCoderTest(unittest.TestCase):
 
     def test_call_download_invalid_url(self):
         self.snippet_call_download_raises(requests.exceptions.HTTPError, 'http://abc001.contest.atcoder.jp/tasks/abc001_100')
+
+    def test_call_download_413(self):
+        # This task is not supported.
+        self.snippet_call_download_raises(SampleParseError, 'https://chokudai001.contest.atcoder.jp/tasks/chokudai_001_a')
diff --git a/tests/command_generate_input.py b/tests/command_generate_input.py
new file mode 100644
index 0000000..278f04f
--- /dev/null
+++ b/tests/command_generate_input.py
@@ -0,0 +1,115 @@
+import os
+import unittest
+
+import tests.utils
+
+
+class GenerateInputTest(unittest.TestCase):
+    def snippet_call_generate_input(self, args, input_files, expected_values, disallowed_files=None):
+        with tests.utils.sandbox(input_files) as tempdir:
+            tests.utils.run(['generate-input'] + args, check=True)
+            for expect in expected_values:
+                self.assertTrue(os.path.exists(expect['path']))
+                if expect['data'] is not None:
+                    with open(expect['path']) as f:
+                        self.assertEqual(''.join(f.readlines()), expect['data'])
+            if disallowed_files is not None:
+                for file in disallowed_files:
+                    self.assertFalse(os.path.exists(file))
+
+    def test_call_generate_input_parallel(self):
+        self.snippet_call_generate_input(
+            args=[tests.utils.python_script('generate.py'), '-j', '4'],
+            input_files=[
+                {
+                    'path': 'generate.py',
+                    'data': 'print("Hello, world!")\n'
+                },
+            ],
+            expected_values=[{
+                'path': 'test/random-{}.in'.format(str(i).zfill(3)),
+                'data': 'Hello, world!\n'
+            } for i in range(100)],
+            disallowed_files=[
+                'test/random-100.in',
+            ],
+        )
+
+    def test_call_generate_input_with_output(self):
+        expected_values = []
+        for i in range(20):
+            expected_values += [{
+                'path': 'test/random-{}.in'.format(str(i).zfill(3)),
+                'data': None,
+            }]
+            expected_values += [{
+                'path': 'test/random-{}.out'.format(str(i).zfill(3)),
+                'data': None,
+            }]
+        self.snippet_call_generate_input(
+            args=[tests.utils.python_script('generate.py'), '20', '--command', tests.utils.python_script('main.py')],
+            input_files=[
+                {
+                    'path': 'generate.py',
+                    'data': 'import random\nprint(random.randrange(100))\n'
+                },
+                {
+                    'path': 'main.py',
+                    'data': 'print(int(input()) * 2)\n'
+                },
+            ],
+            expected_values=expected_values,
+            disallowed_files=[
+                'test/random-100.in',
+            ],
+        )
+
+    def test_call_generate_input_hack(self):
+        self.snippet_call_generate_input(args=[tests.utils.python_script('generate.py'), '--command', tests.utils.python_script('ac.py'), '--hack', tests.utils.python_script('wa.py')], input_files=[
+            {
+                'path': 'generate.py',
+                'data': 'import random\nprint(random.randint(1, 10))\n'
+            },
+            {
+                'path': 'ac.py',
+                'data': 'print(int(input()) // 10)\n'
+            },
+            {
+                'path': 'wa.py',
+                'data': 'print(0)\n'
+            },
+        ], expected_values=[
+            {
+                'path': 'test/hack-000.in',
+                'data': '10\n'
+            },
+            {
+                'path': 'test/hack-000.out',
+                'data': '1\n'
+            },
+        ])
+
+    def test_call_generate_input_hack_parallel(self):
+        self.snippet_call_generate_input(args=[tests.utils.python_script('generate.py'), '--command', tests.utils.python_script('ac.py'), '--hack', tests.utils.python_script('wa.py'), '-j', '4'], input_files=[
+            {
+                'path': 'generate.py',
+                'data': 'import random\nprint(random.randint(1, 100))\n'
+            },
+            {
+                'path': 'ac.py',
+                'data': 'print(int(input()) // 100)\n'
+            },
+            {
+                'path': 'wa.py',
+                'data': 'print(0)\n'
+            },
+        ], expected_values=[
+            {
+                'path': 'test/hack-000.in',
+                'data': '100\n'
+            },
+            {
+                'path': 'test/hack-000.out',
+                'data': '1\n'
+            },
+        ])
diff --git a/tests/service_atcoder.py b/tests/service_atcoder.py
index 661f0ba..a9eb419 100644
--- a/tests/service_atcoder.py
+++ b/tests/service_atcoder.py
@@ -3,7 +3,7 @@ import unittest
 
 import requests
 
-from onlinejudge.service.atcoder import AtCoderContest, AtCoderProblem, AtCoderProblemContent, AtCoderService, AtCoderSubmission
+from onlinejudge.service.atcoder import AtCoderContest, AtCoderProblem, AtCoderProblemDetailedData, AtCoderService, AtCoderSubmission
 from onlinejudge.type import TestCase
 
 
@@ -22,12 +22,13 @@ class AtCoderSerivceTest(unittest.TestCase):
         self.assertIn('abc100', contest_ids)
         self.assertIn('kupc2012', contest_ids)
         contest, = [contest for contest in contests if contest.contest_id == 'utpc2013']
-        self.assertEqual(contest.get_start_time().year, 2014)
-        self.assertEqual(contest.get_start_time().month, 3)
-        self.assertEqual(contest.get_start_time().day, 2)
-        self.assertEqual(contest.get_name(), '東京大学プログラミングコンテスト2013')
-        self.assertEqual(contest.get_duration().total_seconds(), 5 * 60 * 60)
-        self.assertEqual(contest.get_rated_range(), 'All')
+        data = contest.download_data()
+        self.assertEqual(data.start_time.year, 2014)
+        self.assertEqual(data.start_time.month, 3)
+        self.assertEqual(data.start_time.day, 2)
+        self.assertEqual(data.name, '東京大学プログラミングコンテスト2013')
+        self.assertEqual(data.duration.total_seconds(), 5 * 60 * 60)
+        self.assertEqual(data.rated_range, 'All')
 
 
 class AtCoderContestTest(unittest.TestCase):
@@ -38,42 +39,44 @@ class AtCoderContestTest(unittest.TestCase):
 
     def test_load_details(self):
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/keyence2019')
-        self.assertEqual(contest.get_name(lang='en'), 'KEYENCE Programming Contest 2019')
-        self.assertEqual(contest.get_name(lang='ja'), 'キーエンス プログラミング コンテスト 2019')
-        self.assertEqual(contest.get_start_time().year, 2019)
-        self.assertEqual(contest.get_start_time().month, 1)
-        self.assertEqual(contest.get_start_time().day, 13)
-        self.assertEqual(contest.get_duration().total_seconds(), 2 * 60 * 60)
-        self.assertEqual(contest.get_can_participate(), 'All')
-        self.assertEqual(contest.get_rated_range(), ' ~ 2799')
-        self.assertEqual(contest.get_penalty().total_seconds(), 5 * 60)
+        self.assertEqual(contest.download_data(lang='en').name, 'KEYENCE Programming Contest 2019')
+        self.assertEqual(contest.download_data(lang='ja').name, 'キーエンス プログラミング コンテスト 2019')
+        data = contest.download_data()
+        self.assertEqual(data.start_time.year, 2019)
+        self.assertEqual(data.start_time.month, 1)
+        self.assertEqual(data.start_time.day, 13)
+        self.assertEqual(data.duration.total_seconds(), 2 * 60 * 60)
+        self.assertEqual(data.can_participate, 'All')
+        self.assertEqual(data.rated_range, ' ~ 2799')
+        self.assertEqual(data.penalty.total_seconds(), 5 * 60)
 
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/dp')
-        self.assertEqual(contest.get_name(lang='ja'), 'Educational DP Contest / DP まとめコンテスト')
-        self.assertEqual(contest.get_name(lang='en'), 'Educational DP Contest')
-        self.assertEqual(contest.get_start_time().year, 2019)
-        self.assertEqual(contest.get_start_time().month, 1)
-        self.assertEqual(contest.get_start_time().day, 6)
-        self.assertEqual(contest.get_duration().total_seconds(), 5 * 60 * 60)
-        self.assertEqual(contest.get_can_participate(), 'All')
-        self.assertEqual(contest.get_rated_range(), '-')
-        self.assertEqual(contest.get_penalty().total_seconds(), 5 * 60)
+        self.assertEqual(contest.download_data(lang='ja').name, 'Educational DP Contest / DP まとめコンテスト')
+        self.assertEqual(contest.download_data(lang='en').name, 'Educational DP Contest')
+        data = contest.download_data()
+        self.assertEqual(data.start_time.year, 2019)
+        self.assertEqual(data.start_time.month, 1)
+        self.assertEqual(data.start_time.day, 6)
+        self.assertEqual(data.duration.total_seconds(), 5 * 60 * 60)
+        self.assertEqual(data.can_participate, 'All')
+        self.assertEqual(data.rated_range, '-')
+        self.assertEqual(data.penalty.total_seconds(), 5 * 60)
 
     def test_get_penalty_a_singular_form(self):
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/chokudai_S002')
-        self.assertEqual(contest.get_penalty().total_seconds(), 60)  # Penalty is written as "1 minute", not  "1 minutes"
+        self.assertEqual(contest.download_data().penalty.total_seconds(), 60)  # Penalty is written as "1 minute", not  "1 minutes"
 
     def test_list_problems(self):
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/agc028')
         problems = contest.list_problems()
         self.assertEqual(len(problems), 7)
-        self.assertEqual(problems[0].get_alphabet(), 'A')
-        self.assertEqual(problems[0].get_name(), 'Two Abbreviations')
-        self.assertEqual(problems[0].get_time_limit_msec(), 2000)
-        self.assertEqual(problems[0].get_memory_limit_byte(), 1024 * 1000 * 1000)
-        self.assertEqual(problems[5].get_alphabet(), 'F')
+        self.assertEqual(problems[0].download_data().alphabet, 'A')
+        self.assertEqual(problems[0].download_data().name, 'Two Abbreviations')
+        self.assertEqual(problems[0].download_data().time_limit_msec, 2000)
+        self.assertEqual(problems[0].download_data().memory_limit_byte, 1024 * 1000 * 1000)
+        self.assertEqual(problems[5].download_data().alphabet, 'F')
         self.assertEqual(problems[5].problem_id, 'agc028_f')
-        self.assertEqual(problems[6].get_alphabet(), 'F2')
+        self.assertEqual(problems[6].download_data().alphabet, 'F2')
         self.assertEqual(problems[6].problem_id, 'agc028_f2')
 
     def test_list_problems_with_float_values(self):
@@ -84,41 +87,41 @@ class AtCoderContestTest(unittest.TestCase):
 
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/dwacon2018-final-open')
         problems = contest.list_problems()
-        self.assertEqual(problems[0].get_time_limit_msec(), 2525)
-        self.assertEqual(problems[0].get_memory_limit_byte(), int(252.525 * 1000 * 1000))
-        self.assertEqual(problems[1].get_time_limit_msec(), 5252)
-        self.assertEqual(problems[1].get_memory_limit_byte(), int(525.252 * 1000 * 1000))
+        self.assertEqual(problems[0].download_data().time_limit_msec, 2525)
+        self.assertEqual(problems[0].download_data().memory_limit_byte, int(252.525 * 1000 * 1000))
+        self.assertEqual(problems[1].download_data().time_limit_msec, 5252)
+        self.assertEqual(problems[1].download_data().memory_limit_byte, int(525.252 * 1000 * 1000))
 
     def test_list_problems_time_limit_is_less_than_msec(self):
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/joi2019ho')
         problems = contest.list_problems()
-        self.assertEqual(problems[0].get_time_limit_msec(), 1000)
-        self.assertEqual(problems[1].get_time_limit_msec(), 1000)
-        self.assertEqual(problems[2].get_time_limit_msec(), 500)
-        self.assertEqual(problems[3].get_time_limit_msec(), 1000)
-        self.assertEqual(problems[4].get_time_limit_msec(), 2000)
+        self.assertEqual(problems[0].download_data().time_limit_msec, 1000)
+        self.assertEqual(problems[1].download_data().time_limit_msec, 1000)
+        self.assertEqual(problems[2].download_data().time_limit_msec, 500)
+        self.assertEqual(problems[3].download_data().time_limit_msec, 1000)
+        self.assertEqual(problems[4].download_data().time_limit_msec, 2000)
 
     def test_list_problems_memory_limit_is_zero(self):
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/future-contest-2019-final-open')
         problems = contest.list_problems()
-        self.assertEqual(problems[0].get_memory_limit_byte(), 1024 * 1000 * 1000)  # 1024 MB
-        self.assertEqual(problems[1].get_memory_limit_byte(), 0)  # 0 KB
+        self.assertEqual(problems[0].download_data().memory_limit_byte, 1024 * 1000 * 1000)  # 1024 MB
+        self.assertEqual(problems[1].download_data().memory_limit_byte, 0)  # 0 KB
 
     def test_iterate_submissions(self):
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/code-festival-2014-exhibition-open')
         submissions = list(contest.iterate_submissions())
         self.assertGreater(len(submissions), 300)
-        self.assertEqual(submissions[0].get_code_size(), 276)
-        self.assertEqual(submissions[0].get_status(), 'WA')
-        self.assertEqual(submissions[1].get_user_id(), 'snuke')
-        self.assertEqual(submissions[1].get_status(), 'WA')
+        self.assertEqual(submissions[0].download_data().code_size, 276)
+        self.assertEqual(submissions[0].download_data().status, 'WA')
+        self.assertEqual(submissions[1].download_data().user_id, 'snuke')
+        self.assertEqual(submissions[1].download_data().status, 'WA')
 
     def test_get_contest_without_penalty(self):
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/otemae2019')
-        self.assertEqual(contest.get_name('ja'), '大手前プロコン 2019')
-        self.assertEqual(contest.get_penalty().total_seconds(), 0)  # This contest has no penalty
-        self.assertEqual(contest.get_name('en'), 'Otemae High School Programming Contest 2019')
-        self.assertEqual(contest.get_penalty().total_seconds(), 0)  # This contest has no penalty
+        self.assertEqual(contest.download_data(lang='ja').name, '大手前プロコン 2019')
+        self.assertEqual(contest.download_data().penalty.total_seconds(), 0)  # This contest has no penalty
+        self.assertEqual(contest.download_data(lang='en').name, 'Otemae High School Programming Contest 2019')
+        self.assertEqual(contest.download_data().penalty.total_seconds(), 0)  # This contest has no penalty
 
 
 class AtCoderProblemTest(unittest.TestCase):
@@ -129,9 +132,9 @@ class AtCoderProblemTest(unittest.TestCase):
         self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/agc030/tasks/agc030_c').problem_id, 'agc030_c')
 
     def test_repr(self):
-        self.assertEqual(repr(AtCoderProblem('kupc2014', 'kupc2014_d')), "AtCoderProblem.from_url('https://atcoder.jp/contests/kupc2014/tasks/kupc2014_d')")
-        self.assertEqual(repr(AtCoderProblem('agc030', 'agc030_c')), "AtCoderProblem.from_url('https://atcoder.jp/contests/agc030/tasks/agc030_c')")
-        self.assertEqual(repr(AtCoderProblem('xxxxxx', 'yyyyyy')), "AtCoderProblem.from_url('https://atcoder.jp/contests/xxxxxx/tasks/yyyyyy')")
+        self.assertEqual(repr(AtCoderProblem(contest_id='kupc2014', problem_id='kupc2014_d')), "AtCoderProblem.from_url('https://atcoder.jp/contests/kupc2014/tasks/kupc2014_d')")
+        self.assertEqual(repr(AtCoderProblem(contest_id='agc030', problem_id='agc030_c')), "AtCoderProblem.from_url('https://atcoder.jp/contests/agc030/tasks/agc030_c')")
+        self.assertEqual(repr(AtCoderProblem(contest_id='xxxxxx', problem_id='yyyyyy')), "AtCoderProblem.from_url('https://atcoder.jp/contests/xxxxxx/tasks/yyyyyy')")
 
     def test_eq(self):
         self.assertEqual(AtCoderProblem.from_url('https://kupc2014.contest.atcoder.jp/tasks/kupc2014_d'), AtCoderProblem.from_url('https://atcoder.jp/contests/kupc2014/tasks/kupc2014_d'))
@@ -139,19 +142,20 @@ class AtCoderProblemTest(unittest.TestCase):
 
     def test_load_details(self):
         problem = AtCoderProblem.from_url('https://atcoder.jp/contests/abc118/tasks/abc118_a')
-        self.assertEqual(problem.get_alphabet(), 'A')
-        self.assertEqual(problem.get_name(), 'B +/- A')
-        self.assertEqual(problem.get_time_limit_msec(), 2000)
-        self.assertEqual(problem.get_memory_limit_byte(), 1024 * 1000 * 1000)
-        self.assertEqual(problem.get_score(), 100)
+        data = problem.download_data()
+        self.assertEqual(data.alphabet, 'A')
+        self.assertEqual(data.name, 'B +/- A')
+        self.assertEqual(data.time_limit_msec, 2000)
+        self.assertEqual(data.memory_limit_byte, 1024 * 1000 * 1000)
+        self.assertEqual(data.score, 100)
 
     def test_get_alphabet(self):
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/agc028/tasks/agc028_f').get_alphabet(), 'F')
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/agc028/tasks/agc028_f2').get_alphabet(), 'F2')
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/agc028/tasks/agc028_f').download_data().alphabet, 'F')
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/agc028/tasks/agc028_f2').download_data().alphabet, 'F2')
 
     def test_get_score(self):
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/future-contest-2018-final/tasks/future_contest_2018_final_a').get_score(), 50000000)
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/abc001/tasks/abc001_4').get_score(), None)
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/future-contest-2018-final/tasks/future_contest_2018_final_a').download_data().score, 50000000)
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/abc001/tasks/abc001_4').download_data().score, None)
 
     def test_get_score_latex(self):
         """
@@ -159,22 +163,22 @@ class AtCoderProblemTest(unittest.TestCase):
             https://github.com/kmyk/online-judge-tools/issues/411
         """
 
-        self.assertIsNone(AtCoderProblem.from_url('https://atcoder.jp/contests/wupc2019/tasks/wupc2019_a').get_score())
+        self.assertIsNone(AtCoderProblem.from_url('https://atcoder.jp/contests/wupc2019/tasks/wupc2019_a').download_data().score)
 
     def test_get_time_limit_is_less_than_msec(self):
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/joi2019ho/tasks/joi2019ho_c').get_time_limit_msec(), 500)
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/future-contest-2019-qual/tasks/future_contest_2019_qual_b').get_time_limit_msec(), 0)
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/joi2019ho/tasks/joi2019ho_c').download_data().time_limit_msec, 500)
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/future-contest-2019-qual/tasks/future_contest_2019_qual_b').download_data().time_limit_msec, 0)
 
     def test_get_memory_limit_is_zero(self):
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/future-contest-2019-qual/tasks/future_contest_2019_qual_b').get_memory_limit_byte(), 0)
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/future-contest-2019-qual/tasks/future_contest_2019_qual_b').download_data().memory_limit_byte, 0)
 
     def test_iterate_submissions(self):
         problem = AtCoderProblem.from_url('https://atcoder.jp/contests/abc119/tasks/abc119_c')
         submissions = problem.iterate_submissions()
-        self.assertEqual(next(submissions).get_score(), 300)
-        self.assertEqual(next(submissions).get_code_size(), 1208)
-        self.assertEqual(next(submissions).get_exec_time_msec(), 2)
-        self.assertEqual(next(submissions).get_memory_byte(), 256 * 1000)
+        self.assertEqual(next(submissions).download_data().score, 300)
+        self.assertEqual(next(submissions).download_data().code_size, 1208)
+        self.assertEqual(next(submissions).download_data().exec_time_msec, 2)
+        self.assertEqual(next(submissions).download_data().memory_byte, 256 * 1000)
 
 
 class AtCoderSubmissionTest(unittest.TestCase):
@@ -186,49 +190,52 @@ class AtCoderSubmissionTest(unittest.TestCase):
 
     def test_submission_info(self):
         submission = AtCoderSubmission.from_url('https://atcoder.jp/contests/agc030/submissions/3904911')
-        self.assertEqual(submission.get_submission_time().year, 2018)
-        self.assertEqual(submission.get_submission_time().month, 12)
-        self.assertEqual(submission.get_submission_time().day, 31)
-        self.assertEqual(submission.get_user_id(), 'kimiyuki')
-        self.assertEqual(submission.get_problem().problem_id, 'agc030_b')
-        self.assertEqual(submission.get_language_name(), 'C++14 (GCC 5.4.1)')
-        self.assertEqual(submission.get_score(), 800)
-        self.assertEqual(submission.get_code_size(), 1457)
-        self.assertEqual(submission.get_status(), 'AC')
-        self.assertEqual(submission.get_exec_time_msec(), 85)
-        self.assertEqual(submission.get_memory_byte(), 3328 * 1000)
+        data = submission.download_data()
+        self.assertEqual(data.submission_time.year, 2018)
+        self.assertEqual(data.submission_time.month, 12)
+        self.assertEqual(data.submission_time.day, 31)
+        self.assertEqual(data.user_id, 'kimiyuki')
+        self.assertEqual(data.problem.problem_id, 'agc030_b')
+        self.assertEqual(data.language_name, 'C++14 (GCC 5.4.1)')
+        self.assertEqual(data.score, 800)
+        self.assertEqual(data.code_size, 1457)
+        self.assertEqual(data.status, 'AC')
+        self.assertEqual(data.exec_time_msec, 85)
+        self.assertEqual(data.memory_byte, 3328 * 1000)
 
     def test_submission_info_compile_error(self):
         submission = AtCoderSubmission.from_url('https://atcoder.jp/contests/abc124/submissions/4943518')
-        self.assertEqual(submission.get_submission_time().year, 2019)
-        self.assertEqual(submission.get_submission_time().month, 4)
-        self.assertEqual(submission.get_submission_time().day, 13)
-        self.assertEqual(submission.get_user_id(), 'pekempey')
-        self.assertEqual(submission.get_problem().problem_id, 'abc124_d')
-        self.assertEqual(submission.get_language_name(), 'Rust (1.15.1)')
-        self.assertEqual(submission.get_score(), 0)
-        self.assertEqual(submission.get_code_size(), 787)
-        self.assertEqual(submission.get_status(), 'CE')
-        self.assertEqual(submission.get_exec_time_msec(), None)
-        self.assertEqual(submission.get_memory_byte(), None)
+        data = submission.download_data()
+        self.assertEqual(data.submission_time.year, 2019)
+        self.assertEqual(data.submission_time.month, 4)
+        self.assertEqual(data.submission_time.day, 13)
+        self.assertEqual(data.user_id, 'pekempey')
+        self.assertEqual(data.problem.problem_id, 'abc124_d')
+        self.assertEqual(data.language_name, 'Rust (1.15.1)')
+        self.assertEqual(data.score, 0)
+        self.assertEqual(data.code_size, 787)
+        self.assertEqual(data.status, 'CE')
+        self.assertEqual(data.exec_time_msec, None)
+        self.assertEqual(data.memory_byte, None)
 
     def test_submission_info_compile_warnings(self):
         submission = AtCoderSubmission.from_url('https://atcoder.jp/contests/agc032/submissions/4675493')
-        self.assertEqual(submission.get_submission_time().year, 2019)
-        self.assertEqual(submission.get_submission_time().month, 3)
-        self.assertEqual(submission.get_submission_time().day, 23)
-        self.assertEqual(submission.get_user_id(), 'yutaka1999')
-        self.assertEqual(submission.get_problem().problem_id, 'agc032_e')
-        self.assertEqual(submission.get_language_name(), 'C++14 (GCC 5.4.1)')
-        self.assertEqual(submission.get_score(), 0)
-        self.assertEqual(submission.get_code_size(), 1682)
-        self.assertEqual(submission.get_status(), 'WA')
-        self.assertEqual(submission.get_exec_time_msec(), 392)
-        self.assertEqual(submission.get_memory_byte(), 7168 * 1000)
+        data = submission.download_data()
+        self.assertEqual(data.submission_time.year, 2019)
+        self.assertEqual(data.submission_time.month, 3)
+        self.assertEqual(data.submission_time.day, 23)
+        self.assertEqual(data.user_id, 'yutaka1999')
+        self.assertEqual(data.problem.problem_id, 'agc032_e')
+        self.assertEqual(data.language_name, 'C++14 (GCC 5.4.1)')
+        self.assertEqual(data.score, 0)
+        self.assertEqual(data.code_size, 1682)
+        self.assertEqual(data.status, 'WA')
+        self.assertEqual(data.exec_time_msec, 392)
+        self.assertEqual(data.memory_byte, 7168 * 1000)
 
     def test_get_test_sets(self):
         submission = AtCoderSubmission.from_url('https://atcoder.jp/contests/arc028/submissions/223928')
-        test_cases = submission.get_test_sets()
+        test_cases = submission.download_data().test_sets
         self.assertEqual(len(test_cases), 3)
         self.assertEqual(test_cases[0].set_name, 'Sample')
         self.assertEqual(test_cases[0].score, 0)
@@ -245,7 +252,7 @@ class AtCoderSubmissionTest(unittest.TestCase):
 
     def test_get_test_cases(self):
         submission = AtCoderSubmission.from_url('https://atcoder.jp/contests/tricky/submissions/119944')
-        test_cases = submission.get_test_cases()
+        test_cases = submission.download_data().test_cases
         self.assertEqual(len(test_cases), 2)
         self.assertEqual(test_cases[0].case_name, 'input_01.txt')
         self.assertEqual(test_cases[0].status, 'TLE')
@@ -258,43 +265,43 @@ class AtCoderSubmissionTest(unittest.TestCase):
 
     def test_get_source_code(self):
         submission = AtCoderSubmission.from_url('https://atcoder.jp/contests/abc100/submissions/3082514')
-        self.assertEqual(submission.get_source_code(), b'/9\\|\\B/c:(\ncYay!')
-        self.assertEqual(submission.get_code_size(), 16)
+        self.assertEqual(submission.download_data().source_code, b'/9\\|\\B/c:(\ncYay!')
+        self.assertEqual(submission.download_data().code_size, 16)
 
         submission = AtCoderSubmission.from_url('https://atcoder.jp/contests/abc100/submissions/4069980')
-        self.assertEqual(submission.get_source_code(), b'/9\\|\\B/c:(\r\ncYay!')
-        self.assertEqual(submission.get_code_size(), 17)
+        self.assertEqual(submission.download_data().source_code, b'/9\\|\\B/c:(\r\ncYay!')
+        self.assertEqual(submission.download_data().code_size, 17)
 
         submission = AtCoderSubmission.from_url('https://atcoder.jp/contests/abc100/submissions/4317534')
-        self.assertEqual(submission.get_source_code(), b'/9\\|\\B/c:(\r\ncYay!\r\n')
-        self.assertEqual(submission.get_code_size(), 19)
+        self.assertEqual(submission.download_data().source_code, b'/9\\|\\B/c:(\r\ncYay!\r\n')
+        self.assertEqual(submission.download_data().code_size, 19)
 
     def test_get_score_float(self):
         submission = AtCoderSubmission.from_url('https://atcoder.jp/contests/pakencamp-2018-day3/submissions/4583531')
-        self.assertAlmostEqual(submission.get_score(), 32.53)
+        self.assertAlmostEqual(submission.download_data().score, 32.53)
 
 
-class AtCoderProblemContentTest(unittest.TestCase):
+class AtCoderProblemDataTest(unittest.TestCase):
     def test_from_html(self):
         url = 'https://atcoder.jp/contests/abc114/tasks/abc114_d'
         resp = requests.get(url)
         html = resp.content.decode(resp.apparent_encoding)
-        content = AtCoderProblemContent.from_html(html, problem=AtCoderProblem.from_url(url))
-
-        self.assertEqual(content.alphabet, 'D')
-        self.assertEqual(content.available_languages, None)
-        self.assertEqual(content.html, html)
-        self.assertEqual(content.input_format, '<var>N</var>\r\n')
-        self.assertEqual(content.memory_limit_byte, 1024 * 1000 * 1000)
-        self.assertEqual(content.name, '756')
-        self.assertEqual(content.problem, AtCoderProblem.from_url(url))
-        self.assertEqual(content.sample_cases, [
+        data = AtCoderProblemDetailedData.from_html(html, problem=AtCoderProblem.from_url(url))
+
+        self.assertEqual(data.alphabet, 'D')
+        self.assertEqual(data.available_languages, None)
+        self.assertEqual(data.html, html)
+        self.assertEqual(data.input_format, '<var>N</var>\r\n')
+        self.assertEqual(data.memory_limit_byte, 1024 * 1000 * 1000)
+        self.assertEqual(data.name, '756')
+        self.assertEqual(data.problem, AtCoderProblem.from_url(url))
+        self.assertEqual(data.sample_cases, [
             TestCase(name='sample-1', input_name='入力例 1', input_data=b'9\n', output_name='出力例 1', output_data=b'0\n'),
             TestCase(name='sample-2', input_name='入力例 2', input_data=b'10\n', output_name='出力例 2', output_data=b'1\n'),
             TestCase(name='sample-3', input_name='入力例 3', input_data=b'100\n', output_name='出力例 3', output_data=b'543\n'),
         ])
-        self.assertEqual(content.score, 400)
-        self.assertEqual(content.time_limit_msec, 2 * 1000)
+        self.assertEqual(data.score, 400)
+        self.assertEqual(data.time_limit_msec, 2 * 1000)
 
 
 class AtCoderProblemGetInputFormatTest(unittest.TestCase):
@@ -319,13 +326,13 @@ class AtCoderProblemGetInputFormatTest(unittest.TestCase):
             </div>
         """
 
-        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/agc001/tasks/agc001_d').get_input_format(), '<var>N</var> <var>M</var>\r\n<var>A_1</var> <var>A_2</var> <var>...</var> <var>A_M</var>\r\n')
-        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/agc002/tasks/agc002_d').get_input_format(), '\r\n<var>N</var> <var>M</var>\r\n<var>a_1</var> <var>b_1</var>\r\n<var>a_2</var> <var>b_2</var>\r\n<var>:</var>\r\n<var>a_M</var> <var>b_M</var>\r\n<var>Q</var>\r\n<var>x_1</var> <var>y_1</var> <var>z_1</var>\r\n<var>x_2</var> <var>y_2</var> <var>z_2</var>\r\n<var>:</var>\r\n<var>x_Q</var> <var>y_Q</var> <var>z_Q</var>\r\n')
-        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/agc003/tasks/agc003_d').get_input_format(), '<var>N</var>\r\n<var>s_1</var>\r\n:\r\n<var>s_N</var>\r\n')
-        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/agc004/tasks/agc004_d').get_input_format(), '<var>N</var> <var>K</var>\r\n<var>a_1</var> <var>a_2</var> <var>...</var> <var>a_N</var>\r\n')
-        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/agc005/tasks/agc005_d').get_input_format(), '<var>N</var> <var>K</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/agc001/tasks/agc001_d').download_data().input_format, '<var>N</var> <var>M</var>\r\n<var>A_1</var> <var>A_2</var> <var>...</var> <var>A_M</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/agc002/tasks/agc002_d').download_data().input_format, '\r\n<var>N</var> <var>M</var>\r\n<var>a_1</var> <var>b_1</var>\r\n<var>a_2</var> <var>b_2</var>\r\n<var>:</var>\r\n<var>a_M</var> <var>b_M</var>\r\n<var>Q</var>\r\n<var>x_1</var> <var>y_1</var> <var>z_1</var>\r\n<var>x_2</var> <var>y_2</var> <var>z_2</var>\r\n<var>:</var>\r\n<var>x_Q</var> <var>y_Q</var> <var>z_Q</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/agc003/tasks/agc003_d').download_data().input_format, '<var>N</var>\r\n<var>s_1</var>\r\n:\r\n<var>s_N</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/agc004/tasks/agc004_d').download_data().input_format, '<var>N</var> <var>K</var>\r\n<var>a_1</var> <var>a_2</var> <var>...</var> <var>a_N</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/agc005/tasks/agc005_d').download_data().input_format, '<var>N</var> <var>K</var>\r\n')
 
-        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/arc083/tasks/arc083_a').get_input_format(), '<var>A</var> <var>B</var> <var>C</var> <var>D</var> <var>E</var> <var>F</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://beta.atcoder.jp/contests/arc083/tasks/arc083_a').download_data().input_format, '<var>A</var> <var>B</var> <var>C</var> <var>D</var> <var>E</var> <var>F</var>\r\n')
 
     def test_old_problem(self):
         """
@@ -342,9 +349,9 @@ class AtCoderProblemGetInputFormatTest(unittest.TestCase):
             </section>
         """
 
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/arc001/tasks/arc001_1').get_input_format(), '\r\n<var>N</var>\r\n<var>c_1c_2c_3…c_N</var>\r\n')
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/arc002/tasks/arc002_3').get_input_format(), '\r\n<var>N</var>\r\n<var>c_{1}c_{2}...c_{N}</var>\r\n')
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/arc034/tasks/arc034_4').get_input_format(), '\r\n<var>A</var> <var>B</var> <var>C</var>\r\n<var>a_1</var> <var>a_2</var> .. <var>a_A</var>\r\n<var>b_1</var> <var>b_2</var> .. <var>b_B</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/arc001/tasks/arc001_1').download_data().input_format, '\r\n<var>N</var>\r\n<var>c_1c_2c_3…c_N</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/arc002/tasks/arc002_3').download_data().input_format, '\r\n<var>N</var>\r\n<var>c_{1}c_{2}...c_{N}</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/arc034/tasks/arc034_4').download_data().input_format, '\r\n<var>A</var> <var>B</var> <var>C</var>\r\n<var>a_1</var> <var>a_2</var> .. <var>a_A</var>\r\n<var>b_1</var> <var>b_2</var> .. <var>b_B</var>\r\n')
 
     def test_dwacon_problem(self):
         """
@@ -361,14 +368,14 @@ class AtCoderProblemGetInputFormatTest(unittest.TestCase):
             </div>
         """
 
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/dwacon2018-final/tasks/dwacon2018_final_a').get_input_format(), '\r\n<var>H</var> <var>M</var> <var>S</var>\r\n<var>C_1</var> <var>C_2</var>\r\n')
-        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/dwacon2018-final/tasks/dwacon2018_final_b').get_input_format(), '\r\n<var>N</var> <var>K</var>\r\n<var>v_1</var> <var>...</var> <var>v_N</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/dwacon2018-final/tasks/dwacon2018_final_a').download_data().input_format, '\r\n<var>H</var> <var>M</var> <var>S</var>\r\n<var>C_1</var> <var>C_2</var>\r\n')
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/dwacon2018-final/tasks/dwacon2018_final_b').download_data().input_format, '\r\n<var>N</var> <var>K</var>\r\n<var>v_1</var> <var>...</var> <var>v_N</var>\r\n')
 
     def test_problem_without_input(self):
-        self.assertIsNone(AtCoderProblem.from_url('https://atcoder.jp/contests/tenka1-2013-quala/tasks/tenka1_2013_qualA_a').get_input_format())
+        self.assertIsNone(AtCoderProblem.from_url('https://atcoder.jp/contests/tenka1-2013-quala/tasks/tenka1_2013_qualA_a').download_data().input_format)
 
     def test_problem_without_input_format(self):
-        self.assertIsNone(AtCoderProblem.from_url('https://atcoder.jp/contests/joi2006ho/tasks/joi2006ho_a').get_input_format())
+        self.assertIsNone(AtCoderProblem.from_url('https://atcoder.jp/contests/joi2006ho/tasks/joi2006ho_a').download_data().input_format)
 
 
 if __name__ == '__main__':
diff --git a/tests/service_codeforces.py b/tests/service_codeforces.py
index 6e5b071..6ccd7e0 100644
--- a/tests/service_codeforces.py
+++ b/tests/service_codeforces.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 import unittest
 
-from onlinejudge.service.codeforces import CodeforcesProblem, CodeforcesService
+from onlinejudge.service.codeforces import CodeforcesContest, CodeforcesProblem, CodeforcesService
 
 
 class CodeforcesSerivceTest(unittest.TestCase):
@@ -12,6 +12,36 @@ class CodeforcesSerivceTest(unittest.TestCase):
         self.assertIsNone(CodeforcesService.from_url('https://atcoder.jp/'))
 
 
+class CodeforcesContestTest(unittest.TestCase):
+    def test_from_url(self):
+        self.assertEqual(CodeforcesContest.from_url('http://codeforces.com/contest/542').contest_id, 542)
+        self.assertEqual(CodeforcesContest.from_url('http://codeforces.com/gym/101025').contest_id, 101025)
+        self.assertIsNone(CodeforcesContest.from_url('https://atcoder.jp/contests/abc120'))
+
+    def test_list_problems(self):
+        contest = CodeforcesContest.from_url('https://codeforces.com/contest/1157')
+        problems = contest.list_problem_data()
+        self.assertEqual(len(problems), 8)
+        self.assertEqual(problems[0].name, 'Reachable Numbers')
+        self.assertEqual(problems[1].problem.index, 'B')
+        self.assertEqual(problems[2].name, 'Increasing Subsequence (easy version)')
+        self.assertEqual(problems[3].problem.index, 'C2')
+        self.assertEqual(problems[4].name, 'N Problems During K Days')
+        self.assertEqual(problems[5].problem.index, 'E')
+        self.assertEqual(problems[6].tags, ['constructive algorithms', 'dp', 'greedy', 'two pointers'])
+        self.assertEqual(problems[7].tags, ['brute force', 'constructive algorithms'])
+
+    def test_download_data(self):
+        contest = CodeforcesContest.from_url('http://codeforces.com/contest/1200')
+        data = contest.download_data()
+        self.assertEqual(data.duration_seconds, 7200)
+        self.assertEqual(data.frozen, False)
+        self.assertEqual(data.name, 'Codeforces Round #578 (Div. 2)')
+        self.assertEqual(data.phase, 'FINISHED')
+        self.assertEqual(data.start_time_seconds, 1565526900)
+        self.assertEqual(data.type, 'CF')
+
+
 class CodeforcesProblemTest(unittest.TestCase):
     def test_from_url(self):
         self.assertEqual(CodeforcesProblem.from_url('http://codeforces.com/problemset/problem/700/B').contest_id, 700)
@@ -37,3 +67,12 @@ class CodeforcesProblemTest(unittest.TestCase):
         self.assertEqual(CodeforcesProblem.from_url('https://codeforces.com/contest/1133/problem/E').index, 'E')
         self.assertEqual(CodeforcesProblem.from_url('https://codeforces.com/contest/1133/problem/F1').index, 'F1')
         self.assertEqual(CodeforcesProblem.from_url('https://codeforces.com/contest/1133/problem/F2').index, 'F2')
+
+    def test_download_problem(self):
+        problem = CodeforcesProblem.from_url('http://codeforces.com/contest/1205/problem/D')
+        data = problem.download_data()
+        self.assertEqual(data.name, 'Almost All')
+        self.assertEqual(data.points, 2000)
+        self.assertEqual(data.rating, 2800)
+        self.assertEqual(data.tags, ['constructive algorithms', 'trees'])
+        self.assertEqual(data.type, 'PROGRAMMING')
diff --git a/tests/service_topcoder.py b/tests/service_topcoder.py
index 4e8861c..50cf2d8 100644
--- a/tests/service_topcoder.py
+++ b/tests/service_topcoder.py
@@ -36,7 +36,7 @@ class TopcoderLongConrestProblemTest(unittest.TestCase):
     def test_download_individual_results_feed(self):
         problem = TopcoderLongContestProblem.from_url('https://community.topcoder.com/longcontest/?module=ViewProblemStatement&rd=17143&pm=14889')
         cr = 22924522
-        feed = problem.download_individual_results_feed(cr)
+        feed = problem.download_individual_results_feed(cr=cr)
         self.assertEqual(feed.round_id, problem.rd)
         self.assertEqual(feed.coder_id, cr)
         self.assertEqual(feed.handle, 'hakomo')
diff --git a/tests/utils.py b/tests/utils.py
index ff2c8c0..ad6959c 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -68,3 +68,8 @@ def cat():
 def python_c(cmd):
     assert '"' not in cmd
     return '{} -c "{}"'.format(sys.executable, cmd)
+
+
+def python_script(path):
+    assert '"' not in path
+    return '{} "{}"'.format(sys.executable, path)