Repository: online-judge-tools/api-client

Issue:
Run CI once a week even if no commits 
api-client sometimes breaks even when we don't change anything. So we need to run CI even when we don't make any commits, using something like cron.
https://help.github.com/en/actions/reference/events-that-trigger-workflows#scheduled-events-schedule

Comments:

Patch:
diff --git a/README.md b/README.md
index 395adbc..1777a3f 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ $ pip3 install online-judge-api-client
 | [Library Checker](https://judge.yosupo.jp/)                                    | :heavy_check_mark: | :heavy_check_mark: |                    |                    | :white_check_mark: |                    |
 | [PKU JudgeOnline](http://poj.org/)                                             | :heavy_check_mark: |                    |                    |                    | :white_check_mark: |                    |
 | [Sphere Online Judge](https://www.spoj.com/)                                   | :heavy_check_mark: |                    |                    |                    | :white_check_mark: |                    |
+| [Topcoder](https://arena.topcoder.com/)                                        | :white_check_mark: |                    | :white_check_mark: |                    | :white_check_mark: |                    |
 | [Toph](https://toph.co/)                                                       | :heavy_check_mark: |                    |                    |                    | :white_check_mark: | :heavy_check_mark: |
 | [yukicoder](https://yukicoder.me/)                                             | :heavy_check_mark: | :heavy_check_mark: |                    |                    | :white_check_mark: | :heavy_check_mark: |
 
diff --git a/onlinejudge/service/__init__.py b/onlinejudge/service/__init__.py
index 68194bb..997be7d 100644
--- a/onlinejudge/service/__init__.py
+++ b/onlinejudge/service/__init__.py
@@ -12,5 +12,6 @@ import onlinejudge.service.kattis
 import onlinejudge.service.library_checker
 import onlinejudge.service.poj
 import onlinejudge.service.spoj
+import onlinejudge.service.topcoder
 import onlinejudge.service.toph
 import onlinejudge.service.yukicoder
diff --git a/onlinejudge/service/topcoder.py b/onlinejudge/service/topcoder.py
new file mode 100644
index 0000000..1f3e683
--- /dev/null
+++ b/onlinejudge/service/topcoder.py
@@ -0,0 +1,209 @@
+"""
+the module for Topcoder (https://topcoder.com/)
+
+.. versionadded:: 10.1.0
+"""
+
+import urllib.parse
+from typing import *
+
+import bs4
+import requests
+
+import onlinejudge._implementation.logging as log
+import onlinejudge._implementation.utils as utils
+import onlinejudge.type
+from onlinejudge.type import SampleParseError, TestCase
+
+
+class TopcoderService(onlinejudge.type.Service):
+    def get_url(self) -> str:
+        return 'https://arena.topcoder.com/'
+
+    def get_name(self) -> str:
+        return 'Topcoder'
+
+    @classmethod
+    def from_url(cls, url: str) -> Optional['TopcoderService']:
+        # example: https://arena.topcoder.com/
+        # example: https://community.topcoder.com/stat?c=problem_statement&pm=10760
+        result = urllib.parse.urlparse(url)
+        if result.scheme in ('', 'http', 'https'):
+            if result.netloc in ('topcoder.com', 'arena.topcoder.com', 'community.topcoder.com'):
+                return cls()
+        return None
+
+
+# class _TopcoderData(onlinejudge.type.ProblemData):
+class _TopcoderData:
+    def __init__(self, *, definition: Dict[str, str], raw_sample_cases: List[Tuple[List[str], str]], sample_cases: List[TestCase]):
+        self.definition = definition
+        self.raw_sample_cases = sample_cases
+        self.sample_cases = sample_cases
+
+
+def _convert_to_greed(x: str) -> str:
+    # example: `{1, 0, 1, 1, 0}` -> `5 1 0 1 1 0`
+    # example: `"foo"` -> `foo`
+    # example: `2` -> `2`
+    # example: `{"aa", "bb", "cc"}` -> 3 aa bb cc
+    if x.startswith('{') and x.endswith('}'):
+        ys = x[1:-1].split(',')
+        return ' '.join([str(len(ys)), *map(lambda y: _convert_to_greed(y.strip()), ys)])
+    elif x.startswith('"') and x.endswith('"'):
+        return x[1:-1]
+    else:
+        return x
+
+
+class TopcoderProblem(onlinejudge.type.Problem):
+    """
+    :ivar problem_id: :py:class:`int`
+    """
+    def __init__(self, *, problem_id: int):
+        self.problem_id = problem_id
+
+    def _download_data(self, *, session: Optional[requests.Session] = None) -> _TopcoderData:
+        session = session or utils.get_default_session()
+
+        # download HTML
+        url = 'https://community.topcoder.com/stat?c=problem_statement&pm={}'.format(self.problem_id)
+        resp = utils.request('GET', url, session=session)
+
+        # parse HTML
+        soup = bs4.BeautifulSoup(resp.content.decode(resp.encoding), utils.html_parser)
+
+        problem_texts = soup.find_all('td', class_='problemText')
+        if len(problem_texts) != 1:
+            raise SampleParseError("""<td class="problemText"> is not found or not unique""")
+        problem_text = problem_texts[0]
+
+        # parse Definition section
+        # format:
+        #     <tr>...<h3>Definition</h3>...<tr>
+        #     <tr><td>...</td>
+        #         <td><table>
+        #             ...
+        #             <tr><td>Class:</td><td>...</td></tr>
+        #             <tr><td>Method:</td><td>...</td></tr>
+        #             ...
+        #         </table></td></tr>
+        log.debug('parse Definition section')
+        h3 = problem_text.find('h3', text='Definition')
+        if h3 is None:
+            raise SampleParseError("""<h3>Definition</h3> is not found""")
+        definition = {}
+        for text, key in {
+                'Class:': 'class',
+                'Method:': 'method',
+                'Parameters:': 'parameters',
+                'Returns:': 'returns',
+                'Method signature:': 'method_signature',
+        }.items():
+            td = h3.parent.parent.next_sibling.find('td', class_='statText', text=text)
+            log.debug('%s', td.parent)
+            definition[key] = td.next_sibling.string
+
+        # parse Examples section
+        # format:
+        #     <tr>...<h3>Examples</h3>...<tr>
+        #     <tr><td>0)</td><td></td></tr>
+        #     <tr><td></td>
+        #         <td><table>
+        #             ...
+        #             <pre>{5, 8}</pre>
+        #             <pre>"foo"</pre>
+        #             <pre>3.5</pre>
+        #             <pre>Returns: 40.0</pre>
+        #             ...
+        #         </table></td></tr>
+        #     <tr><td>1)</td><td></td></tr>
+        #     ...
+        log.debug('parse Examples section')
+        h3 = problem_text.find('h3', text='Examples')
+        if h3 is None:
+            raise SampleParseError("""<h3>Examples</h3> is not found""")
+
+        raw_sample_cases = []  # type: List[Tuple[List[str], str]]
+        cursor = h3.parent.parent
+        while True:
+            # read the header like "0)"
+            cursor = cursor.next_sibling
+            log.debug('%s', cursor)
+            if not cursor or cursor.name != 'tr':
+                break
+            if cursor.find('td').string != '{})'.format(len(raw_sample_cases)):
+                raise SampleParseError("""<td ...>){})</td> is expected, but not found""".format(len(raw_sample_cases)))
+
+            # collect <pre>s
+            cursor = cursor.next_sibling
+            log.debug('%s', cursor)
+            if not cursor or cursor.name != 'tr':
+                raise SampleParseError("""<tr>...</tr> is expected, but not found""")
+            input_items = []
+            for pre in cursor.find_all('pre'):
+                marker = 'Returns: '
+                if pre.string.startswith(marker):
+                    output_item = pre.string[len(marker):]
+                    break
+                else:
+                    input_items.append(pre.string)
+            else:
+                raise SampleParseError("""<pre>Returns: ...</pre> is expected, but not found""")
+            raw_sample_cases.append((input_items, output_item))
+
+        # convert samples cases to the Greed format
+        sample_cases = []
+        for i, (input_items, output_item) in enumerate(raw_sample_cases):
+            sample_cases.append(TestCase(
+                'example-{}'.format(i),
+                'input',
+                ('\n'.join(map(_convert_to_greed, input_items)) + '\n').encode(),
+                'output',
+                (_convert_to_greed(output_item) + '\n').encode(),
+            ))
+
+        return _TopcoderData(definition=definition, raw_sample_cases=raw_sample_cases, sample_cases=sample_cases)
+
+    def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[TestCase]:
+        return self._download_data(session=session).sample_cases
+
+    def get_url(self) -> str:
+        return 'https://community.topcoder.com/stat?c=problem_statement&pm={}'.format(self.problem_id)
+
+    @classmethod
+    def from_url(cls, url: str) -> Optional['TopcoderProblem']:
+        # example: https://arena.topcoder.com/index.html#/u/practiceCode/14230/10838/10760/1/303803
+        # example: https://community.topcoder.com/stat?c=problem_statement&pm=10760
+        result = urllib.parse.urlparse(url)
+        if result.scheme in ('', 'http', 'https'):
+            if result.netloc == 'arena.topcoder.com' and utils.normpath(result.path) in ('/', '/index.html'):
+                dirs = utils.normpath(result.fragment).split('/')
+                if len(dirs) == 8 and dirs[0] == '' and dirs[1] == 'u' and dirs[2] == 'practiceCode':
+                    try:
+                        _ = int(dirs[3])  # round_id
+                        _ = int(dirs[4])  # component_id
+                        problem_id = int(dirs[5])
+                        _ = int(dirs[6])  # division_id
+                        _ = int(dirs[7])  # room_id
+                    except ValueError:
+                        pass
+                    else:
+                        return cls(problem_id=problem_id)
+            if result.netloc == 'community.topcoder.com' and utils.normpath(result.path) == '/stat':
+                query = urllib.parse.parse_qs(result.query)
+                if query.get('c') == ['problem_statement'] and len(query.get('pm', [])) == 1:
+                    try:
+                        problem_id = int(query['pm'][0])
+                    except ValueError:
+                        pass
+                    else:
+                        return cls(problem_id=problem_id)
+        return None
+
+    def get_service(self) -> TopcoderService:
+        return TopcoderService()
+
+
+onlinejudge.dispatch.services += [TopcoderService]
+onlinejudge.dispatch.problems += [TopcoderProblem]
diff --git a/onlinejudge_api/get_problem.py b/onlinejudge_api/get_problem.py
index 704da06..487e884 100644
--- a/onlinejudge_api/get_problem.py
+++ b/onlinejudge_api/get_problem.py
@@ -3,6 +3,7 @@ from typing import *
 
 from onlinejudge.service.atcoder import AtCoderProblem
 from onlinejudge.service.codeforces import CodeforcesProblem
+from onlinejudge.service.topcoder import TopcoderProblem
 from onlinejudge.type import *
 
 logger = getLogger()
@@ -340,6 +341,14 @@ def main(problem: Problem, *, is_system: bool, is_compatibility: bool, is_full:
                 "json": data.json.decode(),
             }
 
+    elif isinstance(problem, TopcoderProblem):
+        definition = problem._download_data(session=session).definition
+        result["name"] = definition["class"]
+        if is_full:
+            result["raw"] = {
+                "definition": definition,
+            }
+
     else:
         result["context"] = {}
 
diff --git a/setup.cfg b/setup.cfg
index 515ab49..c56af2e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -52,6 +52,7 @@ disable =
     missing-class-docstring,
     missing-function-docstring,
     missing-module-docstring,
+    no-else-break,
     no-else-return,
     no-member,
     no-self-use,


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5a04071..297843c 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,6 +1,9 @@
 name: test
 
-on: [pull_request]
+on:
+  pull_request:
+  schedule:
+    - cron: '30 18 * * MON'  # At 03:30 on Tuesday in JST
 
 jobs:
   test:
diff --git a/tests/get_problem_topcoder.py b/tests/get_problem_topcoder.py
new file mode 100644
index 0000000..04d90cd
--- /dev/null
+++ b/tests/get_problem_topcoder.py
@@ -0,0 +1,66 @@
+import unittest
+
+from onlinejudge_api.main import main
+
+
+class GetProblemTopcoderTest(unittest.TestCase):
+    def test_10760(self):
+        # `double` is used and one of values is a scientific form `1.0E50`.
+        url = 'https://community.topcoder.com/stat?c=problem_statement&pm=10760'
+        expected = {
+            "status": "ok",
+            "messages": [],
+            "result": {
+                "url": "https://community.topcoder.com/stat?c=problem_statement&pm=10760",
+                "tests": [{
+                    "input": "2 5 8\n",
+                    "output": "40.0\n"
+                }, {
+                    "input": "2 1.5 1.8\n",
+                    "output": "3.3\n"
+                }, {
+                    "input": "3 8.26 7.54 3.2567\n",
+                    "output": "202.82857868\n"
+                }, {
+                    "input": "4 1.5 1.7 1.6 1.5\n",
+                    "output": "9.920000000000002\n"
+                }, {
+                    "input": "50 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10\n",
+                    "output": "1.0E50\n"
+                }],
+                "name": "Nisoku"
+            },
+        }
+        actual = main(['get-problem', url], debug=True)
+        self.assertEqual(expected, actual)
+
+    def test_11026(self):
+        # `String[]` is used.
+        # The type of the return value is a list.
+        url = 'https://community.topcoder.com/stat?c=problem_statement&pm=11026'
+        expected = {
+            "status": "ok",
+            "messages": [],
+            "result": {
+                "url": "https://community.topcoder.com/stat?c=problem_statement&pm=11026",
+                "tests": [{
+                    "input": "1 00\n1 00\n1 58\n",
+                    "output": "2 0.38461538461538464 0.6153846153846154\n"
+                }, {
+                    "input": "2 00 00\n2 00 00\n2 21 11\n",
+                    "output": "2 0.5888888888888889 0.4111111111111111\n"
+                }, {
+                    "input": "3 0000 0000 0000\n3 2284 0966 9334\n3 1090 3942 4336\n",
+                    "output": "\n{0.19685958571981937, 0.24397246802233483, 0.31496640865458775, 0.24420153760325805 }\n"
+                }, {
+                    "input": "5 01010110 00011000 00001000 10001010 10111110\n5 22218214 32244284 68402430 18140323 29043145\n5 87688689 36101317 69474068 29337374 87255881\n",
+                    "output": "\n{0.11930766223754977, 0.14033271060661345, 0.0652282589028571, 0.14448118133046356, 0.1981894622733832, 0.10743462836879789, 0.16411823601857622, 0.06090786026175882 }\n"
+                }, {
+                    "input": "1 10\n1 00\n1 00\n",
+                    "output": "2 1.0 0.0\n"
+                }],
+                "name": "RandomApple"
+            },
+        }
+        actual = main(['get-problem', url], debug=True)
+        self.assertEqual(expected, actual)
diff --git a/tests/service_topcoder.py b/tests/service_topcoder.py
new file mode 100644
index 0000000..66c82d9
--- /dev/null
+++ b/tests/service_topcoder.py
@@ -0,0 +1,18 @@
+import unittest
+
+from onlinejudge.service.topcoder import TopcoderProblem, TopcoderService
+
+
+class TopcoderSerivceTest(unittest.TestCase):
+    def test_from_url(self):
+        self.assertIsInstance(TopcoderService.from_url('https://topcoder.com/'), TopcoderService)
+        self.assertIsInstance(TopcoderService.from_url('https://arena.topcoder.com/'), TopcoderService)
+        self.assertIsInstance(TopcoderService.from_url('https://community.topcoder.com/'), TopcoderService)
+        self.assertIsNone(TopcoderService.from_url('https://atcoder.jp/'))
+
+
+class TopcoderProblemTest(unittest.TestCase):
+    def test_from_url(self):
+        self.assertEqual(TopcoderProblem.from_url('https://arena.topcoder.com/index.html#/u/practiceCode/14230/10838/10760/1/303803').problem_id, 10760)
+        self.assertEqual(TopcoderProblem.from_url('https://community.topcoder.com/stat?c=problem_statement&pm=10760').problem_id, 10760)
+        self.assertIsNone(TopcoderProblem.from_url('https://atcoder.jp/contests/abc141/tasks/abc141_b'))