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 CHANGED
@@ -1,289 +1,300 @@
1
1
  import os
2
2
  import re
3
3
  import time
4
- from dataclasses import dataclass
5
- from enum import Enum
6
- from typing import Callable, List, Optional, Union, cast
4
+ from typing import Callable, List, Union, cast
7
5
 
8
6
  import questionary as q
9
- import requests
10
- from rich.console import Console
11
- from rich.prompt import IntPrompt, Prompt
7
+ from rich import print
8
+ from rich.prompt import Prompt
12
9
 
13
10
  from atcdr.util.filetype import FILE_EXTENSIONS, Lang
14
- from atcdr.util.problem import (
15
- get_title_from_html,
16
- make_problem_markdown,
17
- repair_html,
18
- title_to_filename,
19
- )
20
-
21
- console = Console()
22
-
23
-
24
- class Diff(Enum):
25
- A = 'A'
26
- B = 'B'
27
- C = 'C'
28
- D = 'D'
29
- E = 'E'
30
- F = 'F'
31
- G = 'G'
32
-
33
-
34
- @dataclass
35
- class Problem:
36
- number: int
37
- difficulty: Diff
38
-
39
-
40
- def get_problem_html(problem: Problem) -> Optional[str]:
41
- url = f'https://atcoder.jp/contests/abc{problem.number}/tasks/abc{problem.number}_{problem.difficulty.value.lower()}'
42
- response = requests.get(url)
43
- retry_attempts = 3
44
- retry_wait = 1 # 1 second
45
-
46
- for _ in range(retry_attempts):
47
- response = requests.get(url)
48
- if response.status_code == 200:
49
- return response.text
50
- elif response.status_code == 429:
51
- console.print(
52
- f'[bold yellow][Error {response.status_code}][/bold yellow] 再試行します。abc{problem.number} {problem.difficulty.value}'
53
- )
54
- time.sleep(retry_wait)
55
- elif 300 <= response.status_code < 400:
56
- console.print(
57
- f'[bold yellow][Error {response.status_code}][/bold yellow] リダイレクトが発生しました。abc{problem.number} {problem.difficulty.value}'
58
- )
59
- elif 400 <= response.status_code < 500:
60
- console.print(
61
- f'[bold red][Error {response.status_code}][/bold red] 問題が見つかりません。abc{problem.number} {problem.difficulty.value}'
62
- )
63
- break
64
- elif 500 <= response.status_code < 600:
65
- console.print(
66
- f'[bold red][Error {response.status_code}][/bold red] サーバーエラーが発生しました。abc{problem.number} {problem.difficulty.value}'
67
- )
68
- break
69
- else:
70
- console.print(
71
- f'[bold red][Error {response.status_code}][/bold red] abc{problem.number} {problem.difficulty.value}に対応するHTMLファイルを取得できませんでした。'
72
- )
73
- break
74
- return None
75
-
76
-
77
- def save_file(file_path: str, html: str) -> None:
78
- with open(file_path, 'w', encoding='utf-8') as file:
79
- file.write(html)
80
- console.print(f'[bold green][+][/bold green] ファイルを保存しました :{file_path}')
11
+ from atcdr.util.parse import ProblemHTML
12
+ from atcdr.util.problem import Contest, Diff, Problem
13
+ from atcdr.util.session import load_session
14
+
15
+
16
+ class Downloader:
17
+ def __init__(self) -> None:
18
+ self.session = load_session()
19
+
20
+ def get(self, problem: Problem) -> ProblemHTML:
21
+ session = self.session
22
+ retry_attempts = 3
23
+ retry_wait = 1 # 1 second
24
+
25
+ for _ in range(retry_attempts):
26
+ response = session.get(problem.url)
27
+ if response.status_code == 200:
28
+ return ProblemHTML(response.text)
29
+ elif response.status_code == 429:
30
+ print(
31
+ f'[bold yellow][Error {response.status_code}][/bold yellow] 再試行します。{problem}'
32
+ )
33
+ time.sleep(retry_wait)
34
+ elif 300 <= response.status_code < 400:
35
+ print(
36
+ f'[bold yellow][Error {response.status_code}][/bold yellow] リダイレクトが発生しました。{problem}'
37
+ )
38
+ elif 400 <= response.status_code < 500:
39
+ print(
40
+ f'[bold red][Error {response.status_code}][/bold red] 問題が見つかりません。{problem}'
41
+ )
42
+ break
43
+ elif 500 <= response.status_code < 600:
44
+ print(
45
+ f'[bold red][Error {response.status_code}][/bold red] サーバーエラーが発生しました。{problem}'
46
+ )
47
+ break
48
+ else:
49
+ print(
50
+ f'[bold red][Error {response.status_code}][/bold red] {problem}に対応するHTMLファイルを取得できませんでした。'
51
+ )
52
+ break
53
+ return ProblemHTML('')
81
54
 
82
55
 
83
56
  def mkdir(path: str) -> None:
84
- if not os.path.exists(path):
85
- os.makedirs(path)
86
- console.print(f'[bold green][+][/bold green] フォルダー: {path} を作成しました')
57
+ if not os.path.exists(path):
58
+ os.makedirs(path)
59
+ print(f'[bold green][+][/bold green] フォルダー: {path} を作成しました')
87
60
 
88
61
 
89
62
  class GenerateMode:
90
- @staticmethod
91
- def gene_path_on_diff(base: str, number: int, diff: Diff) -> str:
92
- return os.path.join(base, diff.name, str(number))
63
+ @staticmethod
64
+ def gene_path_on_diff(base: str, problem: Problem) -> str:
65
+ return (
66
+ os.path.join(base, problem.label, f'{problem.contest.number:03}')
67
+ if problem.contest.number
68
+ else os.path.join(base, problem.label, problem.contest.contest)
69
+ )
93
70
 
94
- @staticmethod
95
- def gene_path_on_num(base: str, number: int, diff: Diff) -> str:
96
- return os.path.join(base, str(number), diff.name)
71
+ @staticmethod
72
+ def gene_path_on_num(base: str, problem: Problem) -> str:
73
+ return (
74
+ os.path.join(base, f'{problem.contest.number:03}', problem.label)
75
+ if problem.contest.number
76
+ else os.path.join(base, problem.contest.contest, problem.label)
77
+ )
78
+
79
+
80
+ def title_to_filename(title: str) -> str:
81
+ title = re.sub(r'[\\/*?:"<>| !@#$%^&()+=\[\]{};,\']', '', title)
82
+ title = re.sub(r'.*?-', '', title)
83
+ return title
97
84
 
98
85
 
99
86
  def generate_problem_directory(
100
- base_path: str, problems: List[Problem], gene_path: Callable[[str, int, Diff], str]
87
+ base_path: str, problems: List[Problem], gene_path: Callable[[str, Problem], str]
101
88
  ) -> None:
102
- for problem in problems:
103
- dir_path = gene_path(base_path, problem.number, problem.difficulty)
89
+ downloader = Downloader()
90
+ for problem in problems:
91
+ problem_content = downloader.get(problem)
92
+ if not problem_content:
93
+ print(f'[bold red][Error][/] {problem}の保存に失敗しました')
94
+ continue
104
95
 
105
- html = get_problem_html(problem)
106
- if html is None:
107
- continue
96
+ dir_path = gene_path(base_path, problem)
97
+ mkdir(dir_path)
108
98
 
109
- title = get_title_from_html(html)
110
- if not title:
111
- console.print('[bold red][Error][/bold red] タイトルが取得できませんでした')
112
- title = f'problem{problem.number}{problem.difficulty.value}'
99
+ problem_content.repair_me()
113
100
 
114
- title = title_to_filename(title)
101
+ title = problem_content.title or problem.label
102
+ title = title_to_filename(title)
115
103
 
116
- mkdir(dir_path)
117
- repaired_html = repair_html(html)
104
+ html_path = os.path.join(dir_path, title + FILE_EXTENSIONS[Lang.HTML])
105
+ with open(html_path, 'w', encoding='utf-8') as file:
106
+ file.write(problem_content.html)
107
+ print(f'[bold green][+][/bold green] ファイルを保存しました :{html_path}')
118
108
 
119
- html_path = os.path.join(dir_path, title + FILE_EXTENSIONS[Lang.HTML])
120
- save_file(html_path, repaired_html)
121
- md = make_problem_markdown(html, 'ja')
122
- md_path = os.path.join(dir_path, title + FILE_EXTENSIONS[Lang.MARKDOWN])
123
- save_file(md_path, md)
109
+ md = problem_content.make_problem_markdown('ja')
110
+ md_path = os.path.join(dir_path, title + FILE_EXTENSIONS[Lang.MARKDOWN])
111
+ with open(md_path, 'w', encoding='utf-8') as file:
112
+ file.write(md)
113
+ print(f'[bold green][+][/bold green] ファイルを保存しました :{md_path}')
124
114
 
125
115
 
126
- def parse_range(range_str: str) -> List[int]:
127
- match = re.match(r'^(\d+)\.\.(\d+)$', range_str)
128
- if match:
129
- start, end = map(int, match.groups())
130
- return list(range(start, end + 1))
131
- else:
132
- raise ValueError('数字の範囲の形式が間違っています')
116
+ def parse_range(match: re.Match) -> List[int]:
117
+ start, end = map(int, match.groups())
118
+ start, end = min(start, end), max(start, end)
119
+ return list(range(start, end + 1))
133
120
 
134
121
 
135
- def parse_diff_range(range_str: str) -> List[Diff]:
136
- match = re.match(r'^([A-Z])\.\.([A-Z])$', range_str)
137
- if match:
138
- start, end = match.groups()
139
- start_index = ord(start) - ord('A')
140
- end_index = ord(end) - ord('A')
141
- if start_index <= end_index:
142
- return [Diff(chr(i + ord('A'))) for i in range(start_index, end_index + 1)]
143
- raise ValueError('A..C の形式になっていません')
122
+ def parse_diff_range(match: re.Match) -> List[Diff]:
123
+ start, end = match.groups()
124
+ start_index = min(ord(start.upper()), ord(end.upper()))
125
+ end_index = max(ord(start.upper()), ord(end.upper()))
126
+ return [Diff(chr(i)) for i in range(start_index, end_index + 1)]
144
127
 
145
128
 
146
129
  def convert_arg(arg: str) -> Union[List[int], List[Diff]]:
147
- if isinstance(arg, int):
148
- return [arg]
149
- elif isinstance(arg, str):
150
- if arg.isdigit():
151
- return [int(arg)]
152
- elif arg in Diff.__members__:
153
- return [Diff[arg]]
154
- elif re.match(r'^\d+\.\.\d+$', arg):
155
- return parse_range(arg)
156
- elif re.match(r'^[A-Z]\.\.[A-Z]$', arg):
157
- return parse_diff_range(arg)
158
- raise ValueError(f'{arg}は認識できません')
130
+ if arg.isdigit():
131
+ return [int(arg)]
132
+ elif arg.isalpha() and len(arg) == 1:
133
+ return [Diff(arg)]
134
+ elif match := re.match(r'^(\d+)\.\.(\d+)$', arg):
135
+ return parse_range(match)
136
+ elif match := re.match(r'^([A-Z])\.\.([A-Z])$', arg, re.IGNORECASE):
137
+ return parse_diff_range(match)
138
+ else:
139
+ raise ValueError(f'{arg}は認識できません')
159
140
 
160
141
 
161
142
  def are_all_integers(args: Union[List[int], List[Diff]]) -> bool:
162
- return all(isinstance(arg, int) for arg in args)
143
+ return all(isinstance(arg, int) for arg in args)
163
144
 
164
145
 
165
146
  def are_all_diffs(args: Union[List[int], List[Diff]]) -> bool:
166
- return all(isinstance(arg, Diff) for arg in args)
147
+ return all(isinstance(arg, Diff) for arg in args)
167
148
 
168
149
 
169
150
  def interactive_download() -> None:
170
- CONTEST = '1. 特定のコンテストの問題を解きたい'
171
- PRACTICE = '2. 特定の難易度の問題を集中的に練習したい'
172
- ONE_FILE = '3. 1ファイルだけダウンロードする'
173
- END = '4. 終了する'
174
-
175
- choice = q.select(
176
- message='AtCoderの問題のHTMLファイルをダウンロードします',
177
- qmark='',
178
- pointer='❯❯❯',
179
- choices=[CONTEST, PRACTICE, ONE_FILE, END],
180
- instruction='\n 十字キーで移動,[enter]で実行',
181
- style=q.Style(
182
- [
183
- ('question', 'fg:#2196F3 bold'),
184
- ('answer', 'fg:#FFB300 bold'),
185
- ('pointer', 'fg:#FFB300 bold'),
186
- ('highlighted', 'fg:#FFB300 bold'),
187
- ('selected', 'fg:#FFB300 bold'),
188
- ]
189
- ),
190
- ).ask()
191
-
192
- if choice == CONTEST:
193
- number = IntPrompt.ask(
194
- 'コンテスト番号を入力してください (例: 120)',
195
- )
196
- contest_diffs = list(Diff)
197
-
198
- problems = [Problem(number, diff) for diff in contest_diffs]
199
-
200
- generate_problem_directory('.', problems, GenerateMode.gene_path_on_num)
201
-
202
- elif choice == PRACTICE:
203
- diff = Prompt.ask(
204
- '難易度を入力してください (例: A)',
205
- )
206
- try:
207
- diff = Diff[diff.upper()]
208
- except KeyError:
209
- raise ValueError('入力された難易度が認識できません')
210
- number_str = Prompt.ask(
211
- 'コンテスト番号または範囲を入力してください (例: 120..130)'
212
- )
213
- if number_str.isdigit():
214
- contest_numbers = [int(number_str)]
215
- elif re.match(r'^\d+\.\.\d+$', number_str):
216
- contest_numbers = parse_range(number_str)
217
- else:
218
- raise ValueError('数字の範囲の形式が間違っています')
219
-
220
- problems = [Problem(number, diff) for number in contest_numbers]
221
-
222
- generate_problem_directory('.', problems, GenerateMode.gene_path_on_diff)
223
-
224
- elif choice == ONE_FILE:
225
- contest_number = IntPrompt.ask(
226
- 'コンテスト番号を入力してください (例: 120)',
227
- )
228
- difficulty = Prompt.ask(
229
- '難易度を入力してください (例: A)', choices=[d.name for d in Diff]
230
- )
231
-
232
- difficulty = difficulty.upper().strip()
233
-
234
- problem = Problem(contest_number, Diff[difficulty])
235
- generate_problem_directory('.', [problem], GenerateMode.gene_path_on_num)
236
-
237
- elif choice == END:
238
- console.print('終了します', style='bold red')
239
- else:
240
- console.print('無効な選択です', style='bold red')
151
+ CONTEST = '1. コンテストの問題を解きたい'
152
+ PRACTICE = '2. 特定の難易度の問題を集中的に練習したい'
153
+ ONE_FILE = '3. 1問だけダウンロードする'
154
+ END = '4. 終了する'
155
+
156
+ choice = q.select(
157
+ message='AtCoderの問題のHTMLファイルをダウンロードします',
158
+ qmark='',
159
+ pointer='❯❯❯',
160
+ choices=[CONTEST, PRACTICE, ONE_FILE, END],
161
+ instruction='\n 十字キーで移動,[enter]で実行',
162
+ style=q.Style(
163
+ [
164
+ ('question', 'fg:#2196F3 bold'),
165
+ ('answer', 'fg:#FFB300 bold'),
166
+ ('pointer', 'fg:#FFB300 bold'),
167
+ ('highlighted', 'fg:#FFB300 bold'),
168
+ ('selected', 'fg:#FFB300 bold'),
169
+ ]
170
+ ),
171
+ ).ask()
172
+
173
+ if choice == CONTEST:
174
+ name = Prompt.ask(
175
+ 'コンテスト名を入力してください (例: abc012, abs, typical90)',
176
+ )
177
+
178
+ problems = Contest(name=name).problems(session=load_session())
179
+ if not problems:
180
+ print(f'[red][Error][/red] コンテスト名が間違っています: {name}')
181
+ return
182
+
183
+ generate_problem_directory('.', problems, GenerateMode.gene_path_on_num)
184
+
185
+ elif choice == PRACTICE:
186
+ difficulty = Prompt.ask(
187
+ '難易度を入力してください (例: A)',
188
+ )
189
+ try:
190
+ difficulty = Diff(difficulty)
191
+ except KeyError:
192
+ raise ValueError('入力された難易度が認識できません')
193
+ number_str = Prompt.ask(
194
+ 'コンテスト番号または範囲を入力してください (例: 120..130)'
195
+ )
196
+ if number_str.isdigit():
197
+ contest_numbers = [int(number_str)]
198
+ elif match := re.match(r'^\d+\.\.\d+$', number_str):
199
+ contest_numbers = parse_range(match)
200
+ else:
201
+ raise ValueError('数字の範囲の形式が間違っています')
202
+
203
+ problems = [
204
+ Problem(contest=Contest('abc', number), difficulty=difficulty)
205
+ for number in contest_numbers
206
+ ]
207
+
208
+ generate_problem_directory('.', problems, GenerateMode.gene_path_on_diff)
209
+
210
+ elif choice == ONE_FILE:
211
+ name = Prompt.ask(
212
+ 'コンテスト名を入力してください (例: abc012, abs, typical90)',
213
+ )
214
+
215
+ problems = Contest(name=name).problems(session=load_session())
216
+
217
+ problem = q.select(
218
+ message='どの問題をダウンロードしますか?',
219
+ qmark='',
220
+ pointer='❯❯❯',
221
+ choices=[
222
+ q.Choice(title=f'{problem.label:10} | {problem.url}', value=problem)
223
+ for problem in problems
224
+ ],
225
+ instruction='\n 十字キーで移動,[enter]で実行',
226
+ style=q.Style(
227
+ [
228
+ ('question', 'fg:#2196F3 bold'),
229
+ ('answer', 'fg:#FFB300 bold'),
230
+ ('pointer', 'fg:#FFB300 bold'),
231
+ ('highlighted', 'fg:#FFB300 bold'),
232
+ ('selected', 'fg:#FFB300 bold'),
233
+ ]
234
+ ),
235
+ ).ask()
236
+
237
+ generate_problem_directory('.', [problem], GenerateMode.gene_path_on_num)
238
+
239
+ elif choice == END:
240
+ print('[bold red]終了します[/]')
241
+ else:
242
+ print('[bold red]無効な選択です[/]')
241
243
 
242
244
 
243
245
  def download(
244
- first: Union[str, int, None] = None,
245
- second: Union[str, int, None] = None,
246
- base_path: str = '.',
246
+ first: Union[str, int, None] = None,
247
+ second: Union[str, int, None] = None,
248
+ base_path: str = '.',
247
249
  ) -> None:
248
- if first is None:
249
- interactive_download()
250
- return
251
-
252
- first_args = convert_arg(str(first))
253
- if second is None:
254
- if isinstance(first, Diff):
255
- raise ValueError(
256
- """難易度だけでなく, 問題番号も指定してコマンドを実行してください.
250
+ if first is None:
251
+ interactive_download()
252
+ return
253
+
254
+ if second is None:
255
+ try:
256
+ first_args = convert_arg(str(first))
257
+ except ValueError:
258
+ first = str(first)
259
+ problems = Contest(name=first).problems(session=load_session())
260
+ if not problems:
261
+ print(f'[red][Error][red/] コンテスト名が間違っています: {first}')
262
+ return
263
+ generate_problem_directory('.', problems, GenerateMode.gene_path_on_num)
264
+ return
265
+
266
+ if are_all_diffs(first_args):
267
+ raise ValueError(
268
+ """難易度だけでなく, 問題番号も指定してコマンドを実行してください.
257
269
  例 atcdr -d A 120 : A問題の120をダウンロードます
258
270
  例 atcdr -d A 120..130 : A問題の120から130をダウンロードます
259
271
  """
260
- )
261
- second_args: Union[List[int], List[Diff]] = list(Diff)
262
- else:
263
- second_args = convert_arg(str(second))
264
-
265
- if are_all_integers(first_args) and are_all_diffs(second_args):
266
- first_args_int = cast(List[int], first_args)
267
- second_args_diff = cast(List[Diff], second_args)
268
- problems = [
269
- Problem(number, diff)
270
- for number in first_args_int
271
- for diff in second_args_diff
272
- ]
273
- generate_problem_directory(base_path, problems, GenerateMode.gene_path_on_num)
274
- elif are_all_diffs(first_args) and are_all_integers(second_args):
275
- first_args_diff = cast(List[Diff], first_args)
276
- second_args_int = cast(List[int], second_args)
277
- problems = [
278
- Problem(number, diff)
279
- for diff in first_args_diff
280
- for number in second_args_int
281
- ]
282
- generate_problem_directory(base_path, problems, GenerateMode.gene_path_on_diff)
283
- else:
284
- raise ValueError(
285
- """次のような形式で問題を指定してください
272
+ )
273
+ else:
274
+ second_args = convert_arg(str(second))
275
+
276
+ if are_all_integers(first_args) and are_all_diffs(second_args):
277
+ first_args_int = cast(List[int], first_args)
278
+ second_args_diff = cast(List[Diff], second_args)
279
+ problems = [
280
+ Problem(Contest('abc', number), difficulty=diff)
281
+ for number in first_args_int
282
+ for diff in second_args_diff
283
+ ]
284
+ generate_problem_directory(base_path, problems, GenerateMode.gene_path_on_num)
285
+ elif are_all_diffs(first_args) and are_all_integers(second_args):
286
+ first_args_diff = cast(List[Diff], first_args)
287
+ second_args_int = cast(List[int], second_args)
288
+ problems = [
289
+ Problem(Contest('abc', number), difficulty=diff)
290
+ for diff in first_args_diff
291
+ for number in second_args_int
292
+ ]
293
+ generate_problem_directory(base_path, problems, GenerateMode.gene_path_on_diff)
294
+ else:
295
+ raise ValueError(
296
+ """次のような形式で問題を指定してください
286
297
  例 atcdr -d A 120..130 : A問題の120から130をダウンロードします
287
298
  例 atcdr -d 120 : ABCのコンテストの問題をダウンロードします
288
299
  """
289
- )
300
+ )