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/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 re
2
- from enum import Enum
3
- from typing import Match, Optional
4
-
5
- from bs4 import BeautifulSoup as bs
6
- from bs4 import Tag
7
- from markdownify import MarkdownConverter # type: ignore
8
-
9
-
10
- def repair_html(html: str) -> str:
11
- html = html.replace('//img.atcoder.jp', 'https://img.atcoder.jp')
12
- html = html.replace(
13
- '<meta http-equiv="Content-Language" content="en">',
14
- '<meta http-equiv="Content-Language" content="ja">',
15
- )
16
- html = html.replace('LANG = "en"', 'LANG="ja"')
17
- html = remove_unnecessary_emptylines(html)
18
- return html
19
-
20
-
21
- def get_title_from_html(html: str) -> str:
22
- title: Optional[Match[str]] = re.search(r'<title>([^<]*)</title>', html)
23
- return title.group(1).strip() if title else ''
24
-
25
-
26
- def title_to_filename(title: str) -> str:
27
- title = re.sub(r'[\\/*?:"<>| !@#$%^&()+=\[\]{};,\']', '', title)
28
- title = re.sub(r'^[A-Z]-', '', title)
29
- return title
30
-
31
-
32
- def find_link_from_html(html: str) -> str:
33
- soup = bs(html, 'html.parser')
34
- meta_tag = soup.find('meta', property='og:url')
35
- if isinstance(meta_tag, Tag) and 'content' in meta_tag.attrs:
36
- content = meta_tag['content']
37
- if isinstance(content, list):
38
- return content[0] # 必要に応じて、最初の要素を返す
39
- return content
40
- return ''
41
-
42
-
43
- class Lang(Enum):
44
- JA = 'ja'
45
- EN = 'en'
46
-
47
-
48
- class CustomMarkdownConverter(MarkdownConverter):
49
- def convert_var(self, el, text, convert_as_inline):
50
- var_text = el.text.strip()
51
- return f'\\({var_text}\\)'
52
-
53
- def convert_pre(self, el, text, convert_as_inline):
54
- pre_text = el.text.strip()
55
- return f'```\n{pre_text}\n```'
56
-
57
-
58
- def custom_markdownify(html, **options):
59
- return CustomMarkdownConverter(**options).convert(html)
60
-
61
-
62
- def remove_unnecessary_emptylines(text):
63
- text = re.sub(r'\n\s*\n\s*\n+', '\n\n', text)
64
- text = text.strip()
65
- return text
66
-
67
-
68
- def abstract_problem_part(html_content: str, lang: str) -> str:
69
- soup = bs(html_content, 'html.parser')
70
- task_statement = soup.find('div', {'id': 'task-statement'})
71
-
72
- if not isinstance(task_statement, Tag):
73
- return ''
74
-
75
- if lang == 'ja':
76
- lang_class = 'lang-ja'
77
- elif lang == 'en':
78
- lang_class = 'lang-en'
79
- else:
80
- pass
81
- span = task_statement.find('span', {'class': lang_class})
82
- return str(span)
83
-
84
-
85
- def make_problem_markdown(html_content: str, lang: str) -> str:
86
- title = get_title_from_html(html_content)
87
- problem_part = abstract_problem_part(html_content, lang)
88
- problem_md = custom_markdownify(problem_part)
89
- problem_md = f'# {title}\n{problem_md}'
90
- problem_md = remove_unnecessary_emptylines(problem_md)
91
- return problem_md
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)