AtCoderStudyBooster 0.2__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atcdr/download.py +251 -240
- atcdr/generate.py +184 -193
- atcdr/login.py +133 -0
- atcdr/logout.py +24 -0
- atcdr/main.py +24 -17
- atcdr/markdown.py +22 -22
- atcdr/open.py +30 -30
- atcdr/submit.py +297 -0
- atcdr/test.py +382 -244
- atcdr/util/execute.py +52 -52
- atcdr/util/filetype.py +81 -67
- atcdr/util/gpt.py +102 -96
- atcdr/util/parse.py +206 -0
- atcdr/util/problem.py +94 -91
- atcdr/util/session.py +140 -0
- atcoderstudybooster-0.3.1.dist-info/METADATA +205 -0
- atcoderstudybooster-0.3.1.dist-info/RECORD +21 -0
- {atcoderstudybooster-0.2.dist-info → atcoderstudybooster-0.3.1.dist-info}/WHEEL +1 -1
- atcdr/util/cost.py +0 -120
- atcoderstudybooster-0.2.dist-info/METADATA +0 -96
- atcoderstudybooster-0.2.dist-info/RECORD +0 -17
- {atcoderstudybooster-0.2.dist-info → atcoderstudybooster-0.3.1.dist-info}/entry_points.txt +0 -0
atcdr/util/parse.py
ADDED
@@ -0,0 +1,206 @@
|
|
1
|
+
import re
|
2
|
+
from typing import Dict, Iterator, List, Optional
|
3
|
+
|
4
|
+
from bs4 import BeautifulSoup as bs
|
5
|
+
from bs4 import Tag
|
6
|
+
from markdownify import MarkdownConverter
|
7
|
+
|
8
|
+
|
9
|
+
class HTML:
|
10
|
+
def __init__(self, html: str) -> None:
|
11
|
+
self.soup = bs(html, 'html.parser')
|
12
|
+
self.title = self._get_title()
|
13
|
+
self.link = self._find_link()
|
14
|
+
|
15
|
+
def _get_title(self) -> str:
|
16
|
+
title_tag = self.soup.title
|
17
|
+
return title_tag.string.strip() if title_tag else ''
|
18
|
+
|
19
|
+
def _find_link(self) -> str:
|
20
|
+
meta_tag = self.soup.find('meta', property='og:url')
|
21
|
+
if isinstance(meta_tag, Tag) and 'content' in meta_tag.attrs:
|
22
|
+
content = meta_tag['content']
|
23
|
+
if isinstance(content, list):
|
24
|
+
return content[0] # 必要に応じて、最初の要素を返す
|
25
|
+
return content
|
26
|
+
return ''
|
27
|
+
|
28
|
+
@property
|
29
|
+
def html(self) -> str:
|
30
|
+
return str(self.soup)
|
31
|
+
|
32
|
+
def __str__(self) -> str:
|
33
|
+
return self.html
|
34
|
+
|
35
|
+
def __bool__(self) -> bool:
|
36
|
+
return bool(self.html)
|
37
|
+
|
38
|
+
|
39
|
+
class CustomMarkdownConverter(MarkdownConverter):
|
40
|
+
def convert_var(self, el, text, convert_as_inline):
|
41
|
+
var_text = el.text.strip()
|
42
|
+
return f'\\({var_text}\\)'
|
43
|
+
|
44
|
+
def convert_pre(self, el, text, convert_as_inline):
|
45
|
+
pre_text = el.text.strip()
|
46
|
+
return f'```\n{pre_text}\n```'
|
47
|
+
|
48
|
+
|
49
|
+
class ProblemForm(Tag):
|
50
|
+
def find_submit_link(self) -> str:
|
51
|
+
action = self['action']
|
52
|
+
submit_url = f'https://atcoder.jp{action}'
|
53
|
+
return submit_url
|
54
|
+
|
55
|
+
def find_task_screen_name(self) -> str:
|
56
|
+
task_input = self.find('input', {'name': 'data.TaskScreenName'})
|
57
|
+
task_screen_name = task_input['value']
|
58
|
+
return task_screen_name
|
59
|
+
|
60
|
+
def get_languages_options(self) -> Dict[str, int]:
|
61
|
+
options: Iterator[Tag] = self.find_all('option')
|
62
|
+
|
63
|
+
options = filter(
|
64
|
+
lambda option: 'value' in option.attrs and option['value'].isdigit(),
|
65
|
+
options,
|
66
|
+
)
|
67
|
+
return {option.text.strip(): int(option['value']) for option in options}
|
68
|
+
|
69
|
+
|
70
|
+
class ProblemHTML(HTML):
|
71
|
+
def repair_me(self) -> None:
|
72
|
+
html = self.html.replace('//img.atcoder.jp', 'https://img.atcoder.jp')
|
73
|
+
html = html.replace(
|
74
|
+
'<meta http-equiv="Content-Language" content="en">',
|
75
|
+
'<meta http-equiv="Content-Language" content="ja">',
|
76
|
+
)
|
77
|
+
html = html.replace('LANG = "en"', 'LANG="ja"')
|
78
|
+
self.soup = bs(html, 'html.parser')
|
79
|
+
|
80
|
+
def abstract_problem_part(self, lang: str) -> Optional[Tag]:
|
81
|
+
task_statement = self.soup.find('div', {'id': 'task-statement'})
|
82
|
+
if not isinstance(task_statement, Tag):
|
83
|
+
return None
|
84
|
+
|
85
|
+
if lang == 'ja':
|
86
|
+
lang_class = 'lang-ja'
|
87
|
+
elif lang == 'en':
|
88
|
+
lang_class = 'lang-en'
|
89
|
+
else:
|
90
|
+
raise ValueError(f'言語は {lang} に対応していません')
|
91
|
+
span = task_statement.find('span', {'class': lang_class})
|
92
|
+
return span
|
93
|
+
|
94
|
+
def make_problem_markdown(self, lang: str) -> str:
|
95
|
+
title = self.title
|
96
|
+
problem_part = self.abstract_problem_part(lang)
|
97
|
+
if problem_part is None:
|
98
|
+
return ''
|
99
|
+
|
100
|
+
problem_md = CustomMarkdownConverter().convert(str(problem_part))
|
101
|
+
problem_md = f'# {title}\n{problem_md}'
|
102
|
+
problem_md = re.sub(r'\n\s*\n\s*\n+', '\n\n', problem_md).strip()
|
103
|
+
return problem_md
|
104
|
+
|
105
|
+
def load_labeled_testcase(self) -> List:
|
106
|
+
from atcdr.test import LabeledTestCase, TestCase
|
107
|
+
|
108
|
+
problem_part = self.abstract_problem_part('en')
|
109
|
+
if problem_part is None:
|
110
|
+
return []
|
111
|
+
|
112
|
+
sample_inputs = problem_part.find_all(
|
113
|
+
'h3', text=re.compile(r'Sample Input \d+')
|
114
|
+
)
|
115
|
+
ltest_cases = []
|
116
|
+
for i, sample_input_section in enumerate(sample_inputs, start=1):
|
117
|
+
# 対応する Sample Output を取得
|
118
|
+
sample_output_section = problem_part.find('h3', text=f'Sample Output {i}')
|
119
|
+
if not sample_input_section or not sample_output_section:
|
120
|
+
break
|
121
|
+
|
122
|
+
sample_input_pre = sample_input_section.find_next('pre')
|
123
|
+
sample_output_pre = sample_output_section.find_next('pre')
|
124
|
+
|
125
|
+
# 入力と出力をテキスト形式で取得
|
126
|
+
sample_input = (
|
127
|
+
sample_input_pre.get_text(strip=True)
|
128
|
+
if sample_input_pre is not None
|
129
|
+
else ''
|
130
|
+
)
|
131
|
+
sample_output = (
|
132
|
+
sample_output_pre.get_text(strip=True)
|
133
|
+
if sample_output_pre is not None
|
134
|
+
else ''
|
135
|
+
)
|
136
|
+
|
137
|
+
ltest_cases.append(
|
138
|
+
LabeledTestCase(
|
139
|
+
f'Sample Case {i}', TestCase(sample_input, sample_output)
|
140
|
+
)
|
141
|
+
)
|
142
|
+
|
143
|
+
return ltest_cases
|
144
|
+
|
145
|
+
@property
|
146
|
+
def form(self) -> ProblemForm:
|
147
|
+
form = self.soup.find('form', class_='form-code-submit')
|
148
|
+
if not isinstance(form, Tag):
|
149
|
+
raise ValueError('問題ページにフォームが存在しません')
|
150
|
+
form.__class__ = ProblemForm
|
151
|
+
return form
|
152
|
+
|
153
|
+
|
154
|
+
def get_username_from_html(html: str) -> str:
|
155
|
+
soup = bs(html, 'html.parser')
|
156
|
+
script_tags = soup.find_all('script')
|
157
|
+
|
158
|
+
user_screen_name = ''
|
159
|
+
for script in script_tags:
|
160
|
+
# <script>タグに内容がある場合のみ処理を行う
|
161
|
+
if script.string:
|
162
|
+
# 正規表現でuserScreenNameの値を抽出
|
163
|
+
match = re.search(r'userScreenName\s*=\s*"([^"]+)"', script.string)
|
164
|
+
if match:
|
165
|
+
user_screen_name = match.group(1)
|
166
|
+
break # 見つかったらループを抜ける
|
167
|
+
|
168
|
+
return user_screen_name
|
169
|
+
|
170
|
+
|
171
|
+
def get_csrf_token(html_content: str) -> str:
|
172
|
+
soup = bs(html_content, 'html.parser')
|
173
|
+
csrf_token = soup.find('input', {'name': 'csrf_token'})['value']
|
174
|
+
return csrf_token if csrf_token else ''
|
175
|
+
|
176
|
+
|
177
|
+
def get_problem_urls_from_tasks(html_content: str) -> list[tuple[str, str]]:
|
178
|
+
soup = bs(html_content, 'html.parser')
|
179
|
+
table = soup.find('table')
|
180
|
+
if not table:
|
181
|
+
raise ValueError('問題のテーブルが見つかりませんでした.')
|
182
|
+
|
183
|
+
# tbodyタグを見つける
|
184
|
+
tbody = table.find('tbody')
|
185
|
+
if not tbody:
|
186
|
+
raise ValueError('tbodyが見つかりませんでした.')
|
187
|
+
|
188
|
+
# tbody内の1列目のaタグのリンクと中身を取得
|
189
|
+
links = []
|
190
|
+
for row in tbody.find_all('tr'):
|
191
|
+
first_column = row.find('td')
|
192
|
+
a_tag = first_column.find('a')
|
193
|
+
if a_tag and 'href' in a_tag.attrs:
|
194
|
+
link = 'https://atcoder.jp' + a_tag['href']
|
195
|
+
label = a_tag.text.strip()
|
196
|
+
links.append((label, link))
|
197
|
+
|
198
|
+
return links
|
199
|
+
|
200
|
+
|
201
|
+
def get_submission_id(html_content: str) -> Optional[int]:
|
202
|
+
soup = bs(html_content, 'html.parser')
|
203
|
+
first_tr = soup.select_one('tbody > tr')
|
204
|
+
data_id_td = first_tr.find(lambda tag: tag.has_attr('data-id'))
|
205
|
+
data_id = int(data_id_td['data-id']) if data_id_td else None
|
206
|
+
return data_id
|
atcdr/util/problem.py
CHANGED
@@ -1,91 +1,94 @@
|
|
1
|
-
import
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
from
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
1
|
+
from typing import List, Optional
|
2
|
+
|
3
|
+
import requests
|
4
|
+
|
5
|
+
from atcdr.util.parse import get_problem_urls_from_tasks
|
6
|
+
|
7
|
+
|
8
|
+
class Contest:
|
9
|
+
def __init__(self, name: str, number: Optional[int] = None):
|
10
|
+
if not name:
|
11
|
+
raise ValueError('nameは必須です')
|
12
|
+
self._name = name
|
13
|
+
self._number = number
|
14
|
+
if number and number > 0:
|
15
|
+
self._contest = f'{name}{number:03}'
|
16
|
+
else:
|
17
|
+
self._contest = name
|
18
|
+
|
19
|
+
@property
|
20
|
+
def contest(self) -> str:
|
21
|
+
return self._contest
|
22
|
+
|
23
|
+
@property
|
24
|
+
def number(self) -> Optional[int]:
|
25
|
+
return self._number
|
26
|
+
|
27
|
+
@property
|
28
|
+
def url(self) -> str:
|
29
|
+
return f'https://atcoder.jp/contests/{self.contest}/tasks'
|
30
|
+
|
31
|
+
def __str__(self) -> str:
|
32
|
+
return f'{self.contest}'
|
33
|
+
|
34
|
+
def __repr__(self) -> str:
|
35
|
+
return f'Contest(name={self._name}, number={self._number})'
|
36
|
+
|
37
|
+
def problems(self, session: Optional[requests.Session] = None) -> List['Problem']:
|
38
|
+
session = session or requests.Session()
|
39
|
+
response = session.get(self.url)
|
40
|
+
|
41
|
+
if response.status_code != 200:
|
42
|
+
return []
|
43
|
+
|
44
|
+
return [
|
45
|
+
Problem(self, label=label, url=url)
|
46
|
+
for label, url in get_problem_urls_from_tasks(response.text)
|
47
|
+
]
|
48
|
+
|
49
|
+
|
50
|
+
class Diff(str):
|
51
|
+
def __new__(cls, diff: str) -> 'Diff':
|
52
|
+
if isinstance(diff, str) and len(diff) == 1 and diff.isalpha():
|
53
|
+
return super().__new__(cls, diff.upper())
|
54
|
+
raise ValueError('diffは英大文字または小文字の1文字である必要があります')
|
55
|
+
|
56
|
+
def __repr__(self) -> str:
|
57
|
+
return f"Diff('{self}')"
|
58
|
+
|
59
|
+
|
60
|
+
class Problem:
|
61
|
+
def __init__(
|
62
|
+
self,
|
63
|
+
contest: Contest,
|
64
|
+
difficulty: Optional[Diff] = None,
|
65
|
+
label: Optional[str] = None,
|
66
|
+
url: Optional[str] = None,
|
67
|
+
):
|
68
|
+
self._contest = contest
|
69
|
+
if difficulty:
|
70
|
+
self._label = difficulty.upper()
|
71
|
+
self._url = contest.url + f'/{contest}_{difficulty.lower()}'
|
72
|
+
elif label and url:
|
73
|
+
self._label = label
|
74
|
+
self._url = url
|
75
|
+
else:
|
76
|
+
raise ValueError('labelとurlは両方必須かdifficultyが必要です')
|
77
|
+
|
78
|
+
@property
|
79
|
+
def contest(self) -> Contest:
|
80
|
+
return self._contest
|
81
|
+
|
82
|
+
@property
|
83
|
+
def label(self) -> str:
|
84
|
+
return self._label
|
85
|
+
|
86
|
+
@property
|
87
|
+
def url(self) -> str:
|
88
|
+
return self._url
|
89
|
+
|
90
|
+
def __repr__(self) -> str:
|
91
|
+
return f'Problem(contest={self.contest}, label={self.label}, url={self.url})'
|
92
|
+
|
93
|
+
def __str__(self) -> str:
|
94
|
+
return f'{self.contest} {self.label}'
|
atcdr/util/session.py
ADDED
@@ -0,0 +1,140 @@
|
|
1
|
+
import json
|
2
|
+
import os
|
3
|
+
from urllib.parse import unquote
|
4
|
+
|
5
|
+
import requests
|
6
|
+
from rich import print
|
7
|
+
from rich.align import Align
|
8
|
+
from rich.panel import Panel
|
9
|
+
from rich.syntax import Syntax
|
10
|
+
from rich.table import Table
|
11
|
+
|
12
|
+
from atcdr.util.parse import get_username_from_html
|
13
|
+
|
14
|
+
COOKIE_PATH = os.path.join(os.path.expanduser('~'), '.cache', 'atcdr', 'session.json')
|
15
|
+
|
16
|
+
|
17
|
+
# デバック用のレスポンス解析用関数
|
18
|
+
def print_rich_response(
|
19
|
+
response: requests.Response, body_range: tuple = (0, 24)
|
20
|
+
) -> None:
|
21
|
+
# レスポンス情報をテーブル形式で表示
|
22
|
+
info_table = Table(title='レスポンス情報')
|
23
|
+
info_table.add_column('項目', justify='left', style='cyan', no_wrap=True)
|
24
|
+
info_table.add_column('内容', justify='left', style='magenta')
|
25
|
+
info_table.add_row('URL', response.url)
|
26
|
+
info_table.add_row('ステータスコード', str(response.status_code))
|
27
|
+
info_table.add_row('理由', response.reason)
|
28
|
+
info_table = Align.center(info_table)
|
29
|
+
|
30
|
+
# ヘッダー情報をテーブル形式で表示
|
31
|
+
header_table = Table(title='レスポンスヘッダー')
|
32
|
+
header_table.add_column('キー', style='cyan', no_wrap=True)
|
33
|
+
header_table.add_column('値', style='magenta', overflow='fold')
|
34
|
+
for key, value in response.headers.items():
|
35
|
+
value = unquote(value)
|
36
|
+
header_table.add_row(key, value)
|
37
|
+
header_table = Align.center(header_table)
|
38
|
+
|
39
|
+
# リダイレクトの歴史
|
40
|
+
redirect_table = None
|
41
|
+
if response.history:
|
42
|
+
redirect_table = Table(title='リダイレクト履歴')
|
43
|
+
redirect_table.add_column('ステップ', style='cyan')
|
44
|
+
redirect_table.add_column('ステータスコード', style='magenta')
|
45
|
+
redirect_table.add_column('URL', style='green')
|
46
|
+
for i, redirect_response in enumerate(response.history):
|
47
|
+
redirect_table.add_row(
|
48
|
+
f'Redirect {i}',
|
49
|
+
str(redirect_response.status_code),
|
50
|
+
redirect_response.url,
|
51
|
+
)
|
52
|
+
redirect_table = Align.center(redirect_table)
|
53
|
+
|
54
|
+
# レスポンスボディの表示
|
55
|
+
content_type = response.headers.get('Content-Type', '').lower()
|
56
|
+
if 'application/json' in content_type:
|
57
|
+
# JSONの整形表示
|
58
|
+
try:
|
59
|
+
body = Syntax(
|
60
|
+
json.dumps(response.json(), indent=4),
|
61
|
+
'json',
|
62
|
+
theme='monokai',
|
63
|
+
line_numbers=True,
|
64
|
+
line_range=body_range,
|
65
|
+
word_wrap=True,
|
66
|
+
)
|
67
|
+
except Exception:
|
68
|
+
pass
|
69
|
+
else:
|
70
|
+
# HTMLやその他のコンテンツの整形表示
|
71
|
+
body = (
|
72
|
+
Syntax(
|
73
|
+
response.text,
|
74
|
+
'html' if 'html' in content_type else 'text',
|
75
|
+
theme='monokai',
|
76
|
+
line_numbers=True,
|
77
|
+
line_range=body_range,
|
78
|
+
word_wrap=True,
|
79
|
+
)
|
80
|
+
if response.text
|
81
|
+
else None
|
82
|
+
)
|
83
|
+
body_panel = Panel(body, title='レスポンスボディ') if body else None
|
84
|
+
|
85
|
+
print(info_table)
|
86
|
+
print(header_table)
|
87
|
+
if redirect_table:
|
88
|
+
print(redirect_table)
|
89
|
+
if body:
|
90
|
+
print(body_panel)
|
91
|
+
|
92
|
+
|
93
|
+
def load_session() -> requests.Session:
|
94
|
+
ATCODER_URL = 'https://atcoder.jp'
|
95
|
+
if not os.path.exists(COOKIE_PATH):
|
96
|
+
return requests.Session()
|
97
|
+
else:
|
98
|
+
with open(COOKIE_PATH) as file:
|
99
|
+
session = requests.Session()
|
100
|
+
session.cookies.update(json.load(file))
|
101
|
+
if validate_session(session):
|
102
|
+
response = session.get(ATCODER_URL)
|
103
|
+
username = get_username_from_html(response.text)
|
104
|
+
if username:
|
105
|
+
print(f'こんにちは![cyan]{username}[/] さん')
|
106
|
+
return session
|
107
|
+
else:
|
108
|
+
return requests.Session()
|
109
|
+
|
110
|
+
|
111
|
+
def save_session(session: requests.Session) -> None:
|
112
|
+
if validate_session(session):
|
113
|
+
os.makedirs(os.path.dirname(COOKIE_PATH), exist_ok=True)
|
114
|
+
with open(COOKIE_PATH, 'w') as file:
|
115
|
+
json.dump(session.cookies.get_dict(), file)
|
116
|
+
else:
|
117
|
+
pass
|
118
|
+
|
119
|
+
|
120
|
+
def validate_session(session: requests.Session) -> bool:
|
121
|
+
ATCODER_SETTINGS_URL = 'https://atcoder.jp/settings'
|
122
|
+
try:
|
123
|
+
response = session.get(
|
124
|
+
ATCODER_SETTINGS_URL, allow_redirects=False
|
125
|
+
) # リダイレクトを追跡しない
|
126
|
+
if response.status_code == 200:
|
127
|
+
return True
|
128
|
+
elif response.status_code in (301, 302) and 'Location' in response.headers:
|
129
|
+
redirect_url = response.headers['Location']
|
130
|
+
if 'login' in redirect_url:
|
131
|
+
return False
|
132
|
+
return False
|
133
|
+
except requests.RequestException as e:
|
134
|
+
print(f'[red][-][/] セッションチェック中にエラーが発生しました: {e}')
|
135
|
+
return False
|
136
|
+
|
137
|
+
|
138
|
+
def delete_session() -> None:
|
139
|
+
if os.path.exists(COOKIE_PATH):
|
140
|
+
os.remove(COOKIE_PATH)
|