1382 lines
42 KiB
Python
Executable File
1382 lines
42 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import os
|
|
import sys
|
|
import pathlib
|
|
import base64
|
|
from enum import Enum, unique, auto
|
|
from dataclasses import dataclass
|
|
import re
|
|
import subprocess
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from html import escape, unescape
|
|
import yaml
|
|
|
|
import click
|
|
|
|
try:
|
|
from latex2mathml.converter import convert as latex_to_mathml
|
|
except ImportError:
|
|
latex_to_mathml = None
|
|
|
|
CFG_PATH = "./config.yml"
|
|
STATE_PATH = "./state.yml"
|
|
|
|
TC_DIR = "./_testcases"
|
|
|
|
TEMPLATES_DIR = "./templates"
|
|
|
|
SRC_SPACE_DIR = "./space"
|
|
|
|
STORAGE_DIR = "./storage"
|
|
|
|
BUILD_DIR = "./build"
|
|
|
|
|
|
@unique
|
|
class Language(Enum):
|
|
"""
|
|
실행 가능한 언어(Language)를 정의하는 클래스입니다. 각 언어는 고유 문자열 값을 가짐.
|
|
- Python: py
|
|
- C: c
|
|
- C++: cpp
|
|
- Rust: rs
|
|
- Kotlin: kt
|
|
- Lua: lua
|
|
"""
|
|
PYTHON = "py"
|
|
C = "c"
|
|
CPP = "cpp"
|
|
RUST = "rs"
|
|
KOTLIN = "kt"
|
|
LUA = "lua"
|
|
|
|
UNDEFINED = "undefined"
|
|
|
|
@property
|
|
def build_command(self):
|
|
"""
|
|
각 언어에 대한 빌드 명령어.
|
|
"""
|
|
return {
|
|
Language.PYTHON: [
|
|
"python",
|
|
"./make.py",
|
|
"build",
|
|
],
|
|
Language.C: [
|
|
"make",
|
|
"build",
|
|
],
|
|
Language.CPP: [
|
|
"make",
|
|
"build",
|
|
],
|
|
Language.RUST: [
|
|
"make",
|
|
"build",
|
|
],
|
|
Language.KOTLIN: [
|
|
"./gradlew",
|
|
"jar",
|
|
],
|
|
Language.LUA: [
|
|
"make",
|
|
"build",
|
|
],
|
|
}[self]
|
|
|
|
@property
|
|
def working_dir(self):
|
|
"""
|
|
각 언어의 작업 디렉토리
|
|
"""
|
|
return f"{SRC_SPACE_DIR}/src-{self.value}"
|
|
|
|
@property
|
|
def build_executable(self):
|
|
"""
|
|
각 언어의 빌드 후 생성되는 실행 파일의 이름.
|
|
"""
|
|
return {
|
|
Language.PYTHON: "py.pyc",
|
|
Language.C: "c.out",
|
|
Language.CPP: "cpp.out",
|
|
Language.RUST: "rs.out",
|
|
Language.KOTLIN: "kt.jar",
|
|
Language.LUA: "lua.luac",
|
|
}[self]
|
|
|
|
@property
|
|
def executable_command(self):
|
|
"""
|
|
각 언어에 대한 실행 명령어.
|
|
"""
|
|
return {
|
|
Language.PYTHON: ["python", f"{BUILD_DIR}/{self.build_executable}"],
|
|
Language.C: [f"{BUILD_DIR}/{self.build_executable}"],
|
|
Language.CPP: [f"{BUILD_DIR}/{self.build_executable}"],
|
|
Language.RUST: [f"{BUILD_DIR}/{self.build_executable}"],
|
|
Language.KOTLIN: ["java", "-jar", f"{BUILD_DIR}/{self.build_executable}"],
|
|
Language.LUA: ["lua", f"{BUILD_DIR}/{self.build_executable}"],
|
|
}[self]
|
|
|
|
@staticmethod
|
|
def convert_ext(ext: str):
|
|
"""
|
|
확장자를 언어 Enum으로 변환하는 메소드.
|
|
만약 확장자가 정의된 언어에 해당하지 않으면 UNDEFINED를 반환.
|
|
"""
|
|
match ext:
|
|
case "py":
|
|
return Language.PYTHON
|
|
case "c":
|
|
return Language.C
|
|
case "cpp":
|
|
return Language.CPP
|
|
case "rs":
|
|
return Language.RUST
|
|
case "kt":
|
|
return Language.KOTLIN
|
|
case "lua":
|
|
return Language.LUA
|
|
case _:
|
|
return Language.UNDEFINED
|
|
|
|
@staticmethod
|
|
def convert_name(format_name: str):
|
|
"""
|
|
다양한 언어 형식 이름을 언어 Enum으로 변환하는 메소드.
|
|
convert_ext의 Super Set임.
|
|
"""
|
|
if Language.convert_ext(format_name) is not Language.UNDEFINED:
|
|
return Language.convert_ext(format_name)
|
|
match format_name.lower():
|
|
case "py" | "py3" | "python" | "python3":
|
|
return Language.PYTHON
|
|
case "c":
|
|
return Language.C
|
|
case "cpp" | "c++":
|
|
return Language.CPP
|
|
case "rust" | "rs":
|
|
return Language.RUST
|
|
case "kt" | "kotlin":
|
|
return Language.KOTLIN
|
|
case "lua":
|
|
return Language.LUA
|
|
case _:
|
|
return Language.UNDEFINED
|
|
|
|
|
|
def natural_sort_key(s: str):
|
|
"""Natural sort key: splits string into text/number chunks for proper ordering."""
|
|
return [int(c) if c.isdigit() else c.lower() for c in re.split(r"(\d+)", s)]
|
|
|
|
def parse_range_string(range_str: str) -> list[int]:
|
|
"""
|
|
범위 문자열을 정수 리스트로 변환.
|
|
* ..N: 1부터 N까지
|
|
* M..N: M부터 N까지
|
|
* N: 단일 숫자
|
|
"""
|
|
if range_str.startswith(".."):
|
|
end_str = range_str[2:]
|
|
if not end_str.isdigit():
|
|
raise ValueError("Invalid range format")
|
|
return list(range(1, int(end_str) + 1))
|
|
|
|
parts = range_str.split("..")
|
|
if len(parts) == 1:
|
|
if not parts[0].isdigit():
|
|
raise ValueError("Invalid range format")
|
|
return [int(parts[0])]
|
|
elif len(parts) == 2:
|
|
if not parts[0].isdigit() or not parts[1].isdigit():
|
|
raise ValueError("Invalid range format")
|
|
return list(range(int(parts[0]), int(parts[1]) + 1))
|
|
else:
|
|
raise ValueError("Invalid range format")
|
|
|
|
|
|
def parse_range_string_list(str_list) -> list[int]:
|
|
"""
|
|
여러 범위 문자열을 정수 리스트로 변환하는 함수; 이때 중복은 제거함.
|
|
* e.g. ["1..3", "5", "7..9"] -> [1, 2, 3, 5, 7, 8, 9]
|
|
"""
|
|
result = set()
|
|
for s in str_list:
|
|
result.update(parse_range_string(s))
|
|
return list(result)
|
|
|
|
|
|
def parse_fetchprob_target(target: str) -> tuple[str, str | None]:
|
|
"""
|
|
fetchprob target parser.
|
|
- zeta/<id>: single mode
|
|
- zeta: batch mode
|
|
"""
|
|
parts = target.split("/", 1)
|
|
location = parts[0].strip()
|
|
|
|
if location != "zeta":
|
|
raise click.UsageError("fetchprob target must start with 'zeta'")
|
|
|
|
if len(parts) == 1:
|
|
return location, None
|
|
|
|
problem_id = parts[1].strip()
|
|
if not problem_id.isdigit():
|
|
raise click.UsageError("problem id must be numeric (e.g. zeta/2447)")
|
|
|
|
return location, problem_id
|
|
|
|
|
|
def extract_problem_id_from_stem(stem: str) -> str | None:
|
|
"""
|
|
Extract BOJ numeric id from file stem.
|
|
Accepted forms: <id>, <id>_<suffix>, <id>-<suffix>
|
|
"""
|
|
m = re.match(r"^(\d+)(?:[_-].*)?$", stem)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def collect_zeta_problem_ids() -> list[str]:
|
|
"""
|
|
Collect problem ids from storage/zeta/* and storage/zeta/*/completed.
|
|
"""
|
|
zeta_dir = pathlib.Path(STORAGE_DIR) / "zeta"
|
|
if not zeta_dir.is_dir():
|
|
raise click.ClickException(f"Storage location '{zeta_dir}' not found")
|
|
|
|
ids: set[str] = set()
|
|
for lang_dir in sorted(zeta_dir.iterdir()):
|
|
if not lang_dir.is_dir() or lang_dir.name.startswith("_"):
|
|
continue
|
|
|
|
for f in lang_dir.iterdir():
|
|
if f.is_file():
|
|
problem_id = extract_problem_id_from_stem(f.stem)
|
|
if problem_id:
|
|
ids.add(problem_id)
|
|
|
|
completed_dir = lang_dir / "completed"
|
|
if completed_dir.is_dir():
|
|
for f in completed_dir.iterdir():
|
|
if f.is_file():
|
|
problem_id = extract_problem_id_from_stem(f.stem)
|
|
if problem_id:
|
|
ids.add(problem_id)
|
|
|
|
return sorted(ids, key=int)
|
|
|
|
|
|
def fetch_boj_problem_html(problem_id: str, timeout: int = 10) -> str:
|
|
"""
|
|
Download BOJ problem page raw HTML.
|
|
"""
|
|
url = f"https://www.acmicpc.net/problem/{problem_id}"
|
|
req = urllib.request.Request(
|
|
url,
|
|
headers={
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (X11; Linux x86_64) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/123.0.0.0 Safari/537.36"
|
|
)
|
|
},
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
status = getattr(resp, "status", 200)
|
|
if status != 200:
|
|
raise click.ClickException(
|
|
f"failed to fetch problem {problem_id}: HTTP {status}"
|
|
)
|
|
return resp.read().decode("utf-8", errors="replace")
|
|
except urllib.error.HTTPError as e:
|
|
raise click.ClickException(
|
|
f"failed to fetch problem {problem_id}: HTTP {e.code}"
|
|
) from e
|
|
except urllib.error.URLError as e:
|
|
raise click.ClickException(
|
|
f"network error while fetching problem {problem_id}: {e.reason}"
|
|
) from e
|
|
|
|
|
|
def _problem_static_html_path(problem_id: str) -> pathlib.Path:
|
|
return pathlib.Path(STORAGE_DIR) / "zeta" / "_static" / f"{problem_id}.html"
|
|
|
|
|
|
def _problem_static_assets_dir(problem_id: str) -> pathlib.Path:
|
|
return pathlib.Path(STORAGE_DIR) / "zeta" / "_static" / "assets" / problem_id
|
|
|
|
|
|
def _guess_image_mime(src_url: str, content_type: str | None) -> str:
|
|
if content_type:
|
|
mime = content_type.split(";", 1)[0].strip().lower()
|
|
if mime.startswith("image/"):
|
|
return mime
|
|
|
|
parsed = urllib.parse.urlparse(src_url)
|
|
ext = pathlib.Path(parsed.path).suffix.lower()
|
|
ext_to_mime = {
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
".svg": "image/svg+xml",
|
|
".bmp": "image/bmp",
|
|
".ico": "image/x-icon",
|
|
}
|
|
return ext_to_mime.get(ext, "image/png")
|
|
|
|
|
|
def _download_image_for_offline(problem_id: str, src_url: str, seq: int, force: bool) -> str | None:
|
|
req = urllib.request.Request(
|
|
src_url,
|
|
headers={
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (X11; Linux x86_64) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/123.0.0.0 Safari/537.36"
|
|
),
|
|
"Referer": f"https://www.acmicpc.net/problem/{problem_id}",
|
|
},
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
status = getattr(resp, "status", 200)
|
|
if status != 200:
|
|
return None
|
|
content_type = resp.headers.get("Content-Type")
|
|
image_bytes = resp.read()
|
|
except (urllib.error.HTTPError, urllib.error.URLError):
|
|
return None
|
|
|
|
mime = _guess_image_mime(src_url, content_type)
|
|
encoded = base64.b64encode(image_bytes).decode("ascii")
|
|
return f"data:{mime};base64,{encoded}"
|
|
|
|
|
|
def _localize_images_in_html(problem_id: str, html_fragment: str, force: bool) -> str:
|
|
base_url = f"https://www.acmicpc.net/problem/{problem_id}"
|
|
counter = {"i": 0}
|
|
cache: dict[str, str] = {}
|
|
|
|
pattern = re.compile(
|
|
r'(<img\b[^>]*?\bsrc\s*=\s*)(["\']?)([^"\'>\s]+)(["\']?)',
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
def repl(m: re.Match) -> str:
|
|
prefix = m.group(1)
|
|
q1 = m.group(2)
|
|
src = m.group(3)
|
|
|
|
if src.startswith("data:"):
|
|
return m.group(0)
|
|
|
|
abs_url = urllib.parse.urljoin(base_url, src)
|
|
if abs_url in cache:
|
|
local_src = cache[abs_url]
|
|
quote = q1 if q1 else '"'
|
|
return f"{prefix}{quote}{local_src}{quote}"
|
|
|
|
counter["i"] += 1
|
|
local_src = _download_image_for_offline(problem_id, abs_url, counter["i"], force=force)
|
|
if not local_src:
|
|
return m.group(0)
|
|
|
|
cache[abs_url] = local_src
|
|
quote = q1 if q1 else '"'
|
|
return f"{prefix}{quote}{local_src}{quote}"
|
|
|
|
return pattern.sub(repl, html_fragment)
|
|
|
|
|
|
def _extract_html_by_id(raw_html: str, tag: str, element_id: str) -> str | None:
|
|
pattern = rf"<{tag}[^>]*id=\"{re.escape(element_id)}\"[^>]*>(.*?)</{tag}>"
|
|
m = re.search(pattern, raw_html, flags=re.DOTALL | re.IGNORECASE)
|
|
if not m:
|
|
return None
|
|
return m.group(1).strip()
|
|
|
|
|
|
def _strip_tags(html_text: str) -> str:
|
|
text = re.sub(r"<[^>]+>", "", html_text, flags=re.DOTALL)
|
|
return " ".join(text.split())
|
|
|
|
|
|
def _render_math_expressions(html_fragment: str) -> str:
|
|
"""
|
|
Convert TeX math delimiters to MathML for offline rendering.
|
|
- Inline: $...$
|
|
- Block: $$...$$
|
|
"""
|
|
if latex_to_mathml is None:
|
|
return html_fragment
|
|
|
|
protected_blocks: list[str] = []
|
|
|
|
def protect(m: re.Match) -> str:
|
|
protected_blocks.append(m.group(0))
|
|
return f"@@PROTECTED_{len(protected_blocks) - 1}@@"
|
|
|
|
# Do not touch code/pre blocks.
|
|
temp = re.sub(
|
|
r"<(pre|code)\b[^>]*>.*?</\1>",
|
|
protect,
|
|
html_fragment,
|
|
flags=re.DOTALL | re.IGNORECASE,
|
|
)
|
|
|
|
def repl_block(m: re.Match) -> str:
|
|
expr = unescape(m.group(1).strip())
|
|
if not expr:
|
|
return m.group(0)
|
|
try:
|
|
mathml = latex_to_mathml(expr)
|
|
return f'<div class="math-block">{mathml}</div>'
|
|
except Exception:
|
|
return m.group(0)
|
|
|
|
def repl_inline(m: re.Match) -> str:
|
|
expr = unescape(m.group(1).strip())
|
|
if not expr:
|
|
return m.group(0)
|
|
try:
|
|
mathml = latex_to_mathml(expr)
|
|
return f'<span class="math-inline">{mathml}</span>'
|
|
except Exception:
|
|
return m.group(0)
|
|
|
|
temp = re.sub(r"\$\$(.+?)\$\$", repl_block, temp, flags=re.DOTALL)
|
|
temp = re.sub(r"(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)", repl_inline, temp, flags=re.DOTALL)
|
|
|
|
for i, block in enumerate(protected_blocks):
|
|
temp = temp.replace(f"@@PROTECTED_{i}@@", block)
|
|
|
|
return temp
|
|
|
|
|
|
def make_offline_problem_html(problem_id: str, raw_html: str, force: bool) -> str:
|
|
"""
|
|
Build a self-contained offline-friendly HTML page from BOJ raw HTML.
|
|
"""
|
|
title = _extract_html_by_id(raw_html, "span", "problem_title")
|
|
if not title:
|
|
title = f"BOJ {problem_id}"
|
|
|
|
blocks: list[str] = []
|
|
core_specs = [
|
|
("problem_description", "문제"),
|
|
("problem_input", "입력"),
|
|
("problem_output", "출력"),
|
|
("problem_limit", "제한"),
|
|
("problem_hint", "힌트"),
|
|
]
|
|
|
|
for content_id, fallback_label in core_specs:
|
|
content = _extract_html_by_id(raw_html, "div", content_id)
|
|
if not content or not content.strip():
|
|
continue
|
|
|
|
localized_content = _localize_images_in_html(
|
|
problem_id,
|
|
content,
|
|
force=force,
|
|
)
|
|
localized_content = _render_math_expressions(localized_content)
|
|
|
|
blocks.append(
|
|
"\n".join(
|
|
[
|
|
"<article class=\"section\">",
|
|
f"<h2>{fallback_label}</h2>",
|
|
f"{localized_content}",
|
|
"</article>",
|
|
]
|
|
)
|
|
)
|
|
|
|
for sample_type, sample_label in (("sampleinput", "예제 입력"), ("sampleoutput", "예제 출력")):
|
|
sample_pattern = rf"<section[^>]*id=\"{sample_type}(\d+)\"[^>]*>(.*?)</section>"
|
|
sample_matches = list(
|
|
re.finditer(sample_pattern, raw_html, flags=re.DOTALL | re.IGNORECASE)
|
|
)
|
|
sample_matches.sort(key=lambda m: int(m.group(1)))
|
|
|
|
for m in sample_matches:
|
|
idx = m.group(1)
|
|
section_html = m.group(2)
|
|
pre_match = re.search(
|
|
r"(<pre[^>]*>.*?</pre>)",
|
|
section_html,
|
|
flags=re.DOTALL | re.IGNORECASE,
|
|
)
|
|
if not pre_match:
|
|
continue
|
|
|
|
pre_html = _localize_images_in_html(
|
|
problem_id,
|
|
pre_match.group(1),
|
|
force=force,
|
|
)
|
|
|
|
h2_match = re.search(
|
|
r"<h2[^>]*>(.*?)</h2>",
|
|
section_html,
|
|
flags=re.DOTALL | re.IGNORECASE,
|
|
)
|
|
if h2_match:
|
|
h2 = _strip_tags(h2_match.group(1))
|
|
else:
|
|
h2 = f"{sample_label} {idx}"
|
|
|
|
blocks.append(
|
|
"\n".join(
|
|
[
|
|
"<article class=\"section\">",
|
|
f"<h2>{h2}</h2>",
|
|
pre_html,
|
|
"</article>",
|
|
]
|
|
)
|
|
)
|
|
|
|
if not blocks:
|
|
body_fallback = (
|
|
"<article class=\"section\">"
|
|
"<h2>원본 페이지</h2>"
|
|
"<p>문제 본문 파싱에 실패하여 원본 HTML을 포함합니다.</p>"
|
|
f"<pre>{escape(raw_html[:100000])}</pre>"
|
|
"</article>"
|
|
)
|
|
blocks.append(body_fallback)
|
|
|
|
source_url = f"https://www.acmicpc.net/problem/{problem_id}"
|
|
content_html = "\n".join(blocks)
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang=\"ko\">
|
|
<head>
|
|
<meta charset=\"UTF-8\" />
|
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />
|
|
<title>BOJ {problem_id} - Offline</title>
|
|
<style>
|
|
:root {{
|
|
--bg: #fafaf8;
|
|
--paper: #ffffff;
|
|
--ink: #1e1f24;
|
|
--muted: #6a6d75;
|
|
--line: #d8dce3;
|
|
--accent: #0d6e6e;
|
|
--code-bg: #f4f6fb;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
background:
|
|
radial-gradient(circle at 15% 0%, #f0efe9 0%, transparent 42%),
|
|
radial-gradient(circle at 85% 20%, #e7f1f2 0%, transparent 38%),
|
|
var(--bg);
|
|
color: var(--ink);
|
|
font-family: "Noto Sans KR", "Pretendard", "Apple SD Gothic Neo", sans-serif;
|
|
line-height: 1.65;
|
|
}}
|
|
main {{
|
|
max-width: 980px;
|
|
margin: 0 auto;
|
|
padding: 24px 16px 56px;
|
|
}}
|
|
.header {{
|
|
background: var(--paper);
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
padding: 18px 20px;
|
|
margin-bottom: 18px;
|
|
}}
|
|
.header h1 {{ margin: 0 0 6px; font-size: 1.5rem; }}
|
|
.header p {{ margin: 0; color: var(--muted); font-size: 0.95rem; }}
|
|
.header a {{ color: var(--accent); text-decoration: none; }}
|
|
.section {{
|
|
background: var(--paper);
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
padding: 16px 18px;
|
|
margin-bottom: 14px;
|
|
overflow-x: auto;
|
|
}}
|
|
h2 {{
|
|
margin: 0 0 10px;
|
|
font-size: 1.05rem;
|
|
color: var(--accent);
|
|
border-bottom: 1px solid var(--line);
|
|
padding-bottom: 8px;
|
|
}}
|
|
pre, code {{
|
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
|
background: var(--code-bg);
|
|
}}
|
|
pre {{
|
|
padding: 12px;
|
|
border-radius: 10px;
|
|
border: 1px solid #e7ebf2;
|
|
overflow: auto;
|
|
}}
|
|
blockquote {{
|
|
margin: 14px 0;
|
|
padding: 16px 16px 14px 22px;
|
|
border-left: 4px solid var(--accent);
|
|
border-radius: 10px;
|
|
background: linear-gradient(90deg, #eef8f8 0%, #f9fdfd 100%);
|
|
color: #24313a;
|
|
font-weight: 600;
|
|
position: relative;
|
|
}}
|
|
blockquote::before {{
|
|
content: "“";
|
|
position: absolute;
|
|
left: 8px;
|
|
top: 2px;
|
|
font-size: 1.35rem;
|
|
line-height: 1;
|
|
color: #0b5f5f;
|
|
opacity: 0.7;
|
|
}}
|
|
blockquote > :first-child {{ margin-top: 0; }}
|
|
blockquote > :last-child {{ margin-bottom: 0; }}
|
|
q {{
|
|
color: #114f50;
|
|
font-weight: 700;
|
|
background: #edf8f8;
|
|
border-radius: 6px;
|
|
padding: 0 4px;
|
|
}}
|
|
.math-inline math {{
|
|
font-size: 1em;
|
|
vertical-align: middle;
|
|
}}
|
|
.math-block {{
|
|
margin: 10px 0;
|
|
padding: 8px 10px;
|
|
overflow-x: auto;
|
|
background: #f8fbff;
|
|
border: 1px solid #e2ecf8;
|
|
border-radius: 8px;
|
|
}}
|
|
.math-block math {{
|
|
font-size: 1.04em;
|
|
display: block;
|
|
}}
|
|
table {{ border-collapse: collapse; width: 100%; }}
|
|
th, td {{ border: 1px solid var(--line); padding: 6px 8px; }}
|
|
img {{ max-width: 100%; height: auto; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<header class=\"header\">
|
|
<h1>{title}</h1>
|
|
</header>
|
|
{content_html}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def save_problem_html(problem_id: str, html: str, force: bool) -> str:
|
|
"""
|
|
Save html to storage/zeta/_static/<id>.html
|
|
Return: fetched | skipped
|
|
"""
|
|
static_dir = pathlib.Path(STORAGE_DIR) / "zeta" / "_static"
|
|
static_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
dest = _problem_static_html_path(problem_id)
|
|
if dest.exists() and not force:
|
|
return "skipped"
|
|
|
|
dest.write_text(html, encoding="utf-8")
|
|
return "fetched"
|
|
|
|
|
|
@click.group()
|
|
def cli():
|
|
pass
|
|
|
|
|
|
@click.command(name="run")
|
|
@click.option("--from", "from_", type=str, required=True)
|
|
@click.option("--target", "-t", default=["1"], multiple=True)
|
|
@click.option("--verbose/--no-verbose", "-v/-nv", default=True)
|
|
def run(from_: str, target: str, verbose: bool):
|
|
"""
|
|
지정된 언어로 빌드 및 실행하는 명령어
|
|
"""
|
|
# Language 확인
|
|
from_language: Language = Language.convert_name(from_)
|
|
|
|
if from_language is Language.UNDEFINED:
|
|
raise click.ClickException("Undefined Language Exception")
|
|
try:
|
|
with open("state.yml", "r", encoding="utf-8") as f:
|
|
state = yaml.safe_load(f)
|
|
except FileNotFoundError as e:
|
|
raise click.ClickException(e)
|
|
except yaml.YAMLError as e:
|
|
raise click.ClickException(e)
|
|
|
|
if "space" not in state:
|
|
raise click.ClickException("State(state.yml) Exception")
|
|
elif not state["space"]:
|
|
click.secho("state.space has no member", fg="yellow", bold=True)
|
|
state["space"] = {}
|
|
|
|
lang_space_state: dict | None
|
|
if from_language.value not in state["space"]:
|
|
lang_space_state = None
|
|
else:
|
|
lang_space_state = state["space"][from_language.value]
|
|
|
|
if lang_space_state is None or not lang_space_state:
|
|
raise click.ClickException("Run Exception: There are no pre-defined state")
|
|
|
|
# 테케 분석
|
|
try:
|
|
tcs: list[int] = parse_range_string_list(target)
|
|
except ValueError as e:
|
|
raise click.ClickException(e)
|
|
|
|
# build
|
|
|
|
try:
|
|
s = subprocess.run(
|
|
from_language.build_command,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=from_language.working_dir,
|
|
)
|
|
print(s.stderr)
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
click.echo(">>>>>> [Error while BUILD process] >>>>>>")
|
|
raise click.ClickException(e.stderr)
|
|
|
|
for tc in tcs:
|
|
if not (
|
|
os.path.isfile(f"{TC_DIR}/{tc}.in") and os.path.isfile(f"{TC_DIR}/{tc}.out")
|
|
):
|
|
click.echo(f"{tc:02d}: {None}")
|
|
else:
|
|
f_in_path = f"{TC_DIR}/{tc}.in"
|
|
f_out_path = f"{TC_DIR}/{tc}.out"
|
|
with open(f_in_path, "r", encoding="utf-8") as f_in:
|
|
try:
|
|
stdout = subprocess.run(
|
|
from_language.executable_command,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
stdin=f_in,
|
|
).stdout
|
|
except subprocess.CalledProcessError as e:
|
|
click.echo(">>>>>> [Error while RUNNING process] >>>>>>")
|
|
click.echo(f"{e.stderr}")
|
|
click.echo(f"returncode: {e.returncode}")
|
|
stdout = ""
|
|
continue
|
|
flag: bool = False
|
|
with open(f_out_path, "r", encoding="utf-8") as f_out:
|
|
flag = stdout.strip() == f_out.read().strip()
|
|
click.echo(f"{tc:02d}: {flag}")
|
|
|
|
if verbose:
|
|
click.echo("===" * 3)
|
|
click.echo(stdout)
|
|
click.echo("===" * 3)
|
|
|
|
|
|
@click.command(name="load")
|
|
@click.argument("file", type=str, nargs=1, required=True)
|
|
@click.option("--to", type=str, default="") # language value
|
|
@click.option("--from", "from_", type=str, required=True)
|
|
@click.option("--force", "-f", is_flag=True)
|
|
def load(file: str, to: str, from_: str, force: bool):
|
|
"""
|
|
파일을 각 언어의 src-space로 이동시키는 명령어.
|
|
이 명령어는 다음 과정을 수행함.
|
|
|
|
"""
|
|
|
|
# 파일의 src-space가 어딘지 확인
|
|
to_language: Language
|
|
file_name: str
|
|
file_sep_ext = file.split(".")
|
|
if len(file_sep_ext) == 1:
|
|
file_name = file_sep_ext[0]
|
|
to_language = Language.convert_name(to) # if blank, undefined
|
|
elif len(file_sep_ext) == 2:
|
|
file_name = file_sep_ext[0]
|
|
to_language = Language.convert_name(file_sep_ext[1])
|
|
else:
|
|
file_name = ""
|
|
to_language = Language.UNDEFINED
|
|
|
|
if to_language is Language.UNDEFINED:
|
|
raise click.UsageError("LoadError: `to` or `file` argument is wrong")
|
|
|
|
try:
|
|
with open(STATE_PATH, "r", encoding="utf-8") as f_state:
|
|
state = yaml.safe_load(f_state)
|
|
except FileNotFoundError as e:
|
|
raise click.ClickException(e)
|
|
except yaml.YAMLError as e:
|
|
raise click.ClickException(e)
|
|
|
|
if "space" not in state:
|
|
raise click.ClickException("state.yml is wrong")
|
|
elif not state["space"]:
|
|
click.secho("state.space has no member", fg="yellow", bold=True)
|
|
state["space"] = {}
|
|
|
|
lang_space_state: dict | None
|
|
if to_language.value not in state["space"]:
|
|
lang_space_state = None
|
|
else:
|
|
lang_space_state = state["space"][to_language.value]
|
|
|
|
if not force:
|
|
if lang_space_state is None or not lang_space_state:
|
|
pass
|
|
elif lang_space_state["file"] is None:
|
|
pass
|
|
else:
|
|
raise click.ClickException(
|
|
f"File {lang_space_state['file']}.{to_language.value} already in source space {to_language.value}"
|
|
)
|
|
|
|
# 파일이 존재하는지 확인
|
|
# uncompleted
|
|
file_is_exist: bool = False
|
|
file_is_completed: bool = False
|
|
file_loc: str = None
|
|
if os.path.isfile(
|
|
f"{STORAGE_DIR}/{from_}/{to_language.value}/{file_name}.{to_language.value}"
|
|
):
|
|
file_is_exist = True
|
|
file_is_completed = False
|
|
file_loc = (
|
|
f"{STORAGE_DIR}/{from_}/{to_language.value}/{file_name}.{to_language.value}"
|
|
)
|
|
elif os.path.isfile(
|
|
f"{STORAGE_DIR}/{from_}/{to_language.value}/completed/{file_name}.{to_language.value}"
|
|
):
|
|
file_is_exist = True
|
|
file_is_completed = True
|
|
file_loc = f"{STORAGE_DIR}/{from_}/{to_language.value}/completed/{file_name}.{to_language.value}"
|
|
# 존재하면 move
|
|
# 존재하지 않으면 빈 파일(또는 템플릿 파일)을 생성하고
|
|
# state에 기록함
|
|
if file_is_exist:
|
|
with (
|
|
open(file_loc, "rb") as f_src,
|
|
open(
|
|
f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}",
|
|
"wb",
|
|
) as f_dst,
|
|
):
|
|
f_dst.write(f_src.read())
|
|
os.remove(file_loc)
|
|
else:
|
|
if TEMPLATES_DIR and os.path.isfile(
|
|
f"{TEMPLATES_DIR}/template.{to_language.value}"
|
|
):
|
|
with (
|
|
open(f"{TEMPLATES_DIR}/template.{to_language.value}", "rb") as f_src,
|
|
open(
|
|
f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}",
|
|
"wb",
|
|
) as f_dst,
|
|
):
|
|
f_dst.write(f_src.read())
|
|
else:
|
|
with open(
|
|
f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}",
|
|
"wb",
|
|
) as f_dst:
|
|
pass
|
|
|
|
with open("state.yml", "w", encoding="utf-8") as f:
|
|
if lang_space_state is None:
|
|
state["space"][to_language.value] = {}
|
|
state["space"][to_language.value]["file"] = file_name
|
|
state["space"][to_language.value]["location"] = from_
|
|
state["space"][to_language.value]["is_completed"] = file_is_completed
|
|
|
|
yaml.safe_dump(state, f)
|
|
if file_is_exist:
|
|
click.echo(
|
|
f"File {file_name}.{to_language.value} from {from_}{'/completed' if file_is_completed else ''} is successfully loaded"
|
|
)
|
|
else:
|
|
click.echo(f"File {file_name}.{to_language.value} is successfully created")
|
|
|
|
|
|
@click.command(name="export")
|
|
@click.option("--from", "from_", type=str, required=True)
|
|
@click.option(
|
|
"--completed/--no-completed", "-c/-nc", is_flag=True, flag_value=False, default=None
|
|
)
|
|
@click.option("--copy", is_flag=True, help="is copy from state or move")
|
|
def export(from_: str, completed: bool, copy: bool):
|
|
# Language 알기
|
|
from_language = Language.convert_name(from_)
|
|
|
|
if from_language is Language.UNDEFINED:
|
|
raise click.ClickException("Undefined Language Exception")
|
|
try:
|
|
with open("state.yml", "r", encoding="utf-8") as f:
|
|
state = yaml.safe_load(f)
|
|
except FileNotFoundError as e:
|
|
raise click.ClickException(e)
|
|
except yaml.YAMLError as e:
|
|
raise click.ClickException(e)
|
|
|
|
if "space" not in state:
|
|
raise click.ClickException("State(state.yml) Exception")
|
|
elif not state["space"]:
|
|
click.secho("state.space has no member", fg="yellow", bold=True)
|
|
state["space"] = {}
|
|
|
|
lang_space_state: dict | None
|
|
if from_language.value not in state["space"]:
|
|
lang_space_state = None
|
|
else:
|
|
lang_space_state = state["space"][from_language.value]
|
|
|
|
if lang_space_state is None or not lang_space_state:
|
|
raise click.ClickException("Export Exception: There are no pre-defined state")
|
|
|
|
s_file = lang_space_state["file"]
|
|
s_loc = lang_space_state["location"]
|
|
s_is_completed = lang_space_state["is_completed"]
|
|
|
|
if not s_file:
|
|
raise click.ClickException(
|
|
f"Export Exception: There is no file in {from_language.value} space"
|
|
)
|
|
|
|
if not s_loc:
|
|
raise click.ClickException(f"Export Exception: Location {s_loc} is not defined")
|
|
|
|
if completed:
|
|
s_is_completed = True
|
|
|
|
dest_path: str = f"{STORAGE_DIR}/{s_loc}/{from_language.value}{'/completed' if s_is_completed else ''}/{s_file}.{from_language.value}"
|
|
source_path: str = (
|
|
f"{SRC_SPACE_DIR}/src-{from_language.value}/src/main.{from_language.value}"
|
|
)
|
|
|
|
with open(source_path, "rb") as f_src, open(dest_path, "wb") as f_dst:
|
|
f_dst.write(f_src.read())
|
|
|
|
click.echo(f"File {s_file}.{from_language.value} successfully ")
|
|
|
|
if not copy:
|
|
with open("state.yml", "w", encoding="utf-8") as f:
|
|
state["space"][from_language.value] = {}
|
|
yaml.safe_dump(state, f)
|
|
|
|
os.remove(source_path)
|
|
|
|
|
|
@click.command(name="state")
|
|
def state():
|
|
try:
|
|
with open(STATE_PATH, "r", encoding="utf-8") as f_state:
|
|
state = yaml.safe_load(f_state)
|
|
except FileNotFoundError as e:
|
|
raise click.ClickException(e)
|
|
except yaml.YAMLError as e:
|
|
raise click.ClickException(e)
|
|
|
|
if "space" not in state:
|
|
raise click.ClickException("state.yml is wrong")
|
|
elif not state["space"]:
|
|
click.secho("state.space has no member", fg="yellow", bold=True)
|
|
state["space"] = {}
|
|
|
|
active = sum(1 for v in state["space"].values() if v)
|
|
total = len(state["space"])
|
|
click.secho(f"Space: {active}/{total} active", fg="cyan", bold=True)
|
|
click.echo()
|
|
|
|
for k, v in state["space"].items():
|
|
click.secho(f" {k}/", fg="yellow")
|
|
if not v:
|
|
click.echo(f" {click.style('—', fg='bright_black')} empty")
|
|
else:
|
|
status = (
|
|
click.style("✓", fg="green")
|
|
if v["is_completed"]
|
|
else click.style("○", fg="white")
|
|
)
|
|
click.echo(f" {status} {v['file']}.{k} @ {v['location']}")
|
|
|
|
|
|
@click.command(name="init")
|
|
@click.argument("count", type=int)
|
|
def init(count: int):
|
|
"""
|
|
init testcases with initial count and state.yml and config.yml
|
|
"""
|
|
if count < 1:
|
|
raise click.UsageError("var count should be greater than 1.")
|
|
|
|
if not os.path.exists("./" + TC_DIR):
|
|
os.mkdir(TC_DIR)
|
|
for i in range(1, count + 1):
|
|
default_in_path = "./" + TC_DIR + f"/{i}.in"
|
|
default_out_path = "./" + TC_DIR + f"/{i}.out"
|
|
if not os.path.exists(default_in_path):
|
|
with open(default_in_path, "w", encoding="utf-8") as _:
|
|
pass
|
|
if not os.path.exists(default_out_path):
|
|
with open(default_out_path, "w", encoding="utf-8") as _:
|
|
pass
|
|
|
|
click.echo(f"1. Testcases was made with count {count} successfully!")
|
|
|
|
if not os.path.isfile("./state.yml"):
|
|
click.echo("2-1. state.yml does not exist, creating...")
|
|
with open("state.yml", "w", encoding="utf-8") as f:
|
|
f.write("space:\n")
|
|
click.echo("2-1. state.yml is created successfully!")
|
|
else:
|
|
click.echo("2-1. state.yml exists")
|
|
if not os.path.isfile("./config.yml"):
|
|
click.echo("2-2. config.yml does not exist, creating...")
|
|
with open("config.yml", "w", encoding="utf-8") as f:
|
|
f.write("space:\n")
|
|
click.echo("2-2. config.yml is created successfully!")
|
|
else:
|
|
click.echo("2-2. config.yml exists")
|
|
|
|
|
|
@click.command(name="show")
|
|
@click.argument("filter", type=str, default=None, required=False)
|
|
@click.option(
|
|
"--completed/--no-completed",
|
|
"-c/-nc",
|
|
default=False,
|
|
help="Filter by completed status (default: uncompleted only)",
|
|
)
|
|
@click.option(
|
|
"--all",
|
|
"show_all",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Show all problems regardless of completed status",
|
|
)
|
|
def show(filter: str | None, completed: bool, show_all: bool):
|
|
"""
|
|
Show all stored problems in storage directory.
|
|
|
|
\b
|
|
FILTER format (optional):
|
|
location e.g. zeta
|
|
location/lang e.g. zeta/rs
|
|
/lang e.g. /py
|
|
"""
|
|
location = None
|
|
lang = None
|
|
if filter:
|
|
parts = filter.split("/", 1)
|
|
if len(parts) == 2:
|
|
location = parts[0] or None
|
|
lang = parts[1] or None
|
|
else:
|
|
location = parts[0]
|
|
|
|
storage = pathlib.Path(STORAGE_DIR)
|
|
if not storage.is_dir():
|
|
raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found")
|
|
|
|
# Collect all files
|
|
entries = [] # (location, language, filename, is_completed)
|
|
|
|
for loc_dir in sorted(storage.iterdir()):
|
|
if not loc_dir.is_dir():
|
|
continue
|
|
loc_name = loc_dir.name
|
|
|
|
if location and loc_name != location:
|
|
continue
|
|
|
|
for lang_dir in sorted(loc_dir.iterdir()):
|
|
if not lang_dir.is_dir():
|
|
continue
|
|
lang_name = lang_dir.name
|
|
|
|
if (
|
|
lang
|
|
and lang_name != Language.convert_name(lang).value
|
|
and lang_name != lang
|
|
):
|
|
continue
|
|
|
|
# uncompleted files
|
|
for f in sorted(lang_dir.iterdir(), key=lambda p: natural_sort_key(p.stem)):
|
|
if f.is_file():
|
|
entries.append((loc_name, lang_name, f.stem, False))
|
|
|
|
# completed files
|
|
completed_dir = lang_dir / "completed"
|
|
if completed_dir.is_dir():
|
|
for f in sorted(
|
|
completed_dir.iterdir(), key=lambda p: natural_sort_key(p.stem)
|
|
):
|
|
if f.is_file():
|
|
entries.append((loc_name, lang_name, f.stem, True))
|
|
|
|
# Filter by completed status
|
|
if not show_all:
|
|
entries = [e for e in entries if e[3] == completed]
|
|
|
|
if not entries:
|
|
click.echo("No problems found.")
|
|
return
|
|
|
|
# Display
|
|
total = len(entries)
|
|
completed_count = sum(1 for e in entries if e[3])
|
|
uncompleted_count = total - completed_count
|
|
|
|
click.secho(
|
|
f"Total: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})",
|
|
fg="cyan",
|
|
bold=True,
|
|
)
|
|
click.echo()
|
|
|
|
# Group by location
|
|
current_loc = None
|
|
current_lang = None
|
|
for loc_name, lang_name, file_name, is_completed in entries:
|
|
if loc_name != current_loc:
|
|
current_loc = loc_name
|
|
current_lang = None
|
|
click.secho(f"[{loc_name}]", fg="green", bold=True)
|
|
|
|
if lang_name != current_lang:
|
|
current_lang = lang_name
|
|
click.secho(f" {lang_name}/", fg="yellow")
|
|
|
|
status = (
|
|
click.style("✓", fg="green")
|
|
if is_completed
|
|
else click.style("○", fg="white")
|
|
)
|
|
click.echo(f" {status} {file_name}.{lang_name}")
|
|
|
|
|
|
@click.command(name="find")
|
|
@click.argument("keyword", type=str)
|
|
@click.option(
|
|
"--completed/--no-completed",
|
|
"-c/-nc",
|
|
default=None,
|
|
help="Filter by completed status (default: all)",
|
|
)
|
|
def find(keyword: str, completed: bool | None):
|
|
"""
|
|
Find problems by keyword in storage.
|
|
|
|
KEYWORD format:
|
|
keyword e.g. 2447
|
|
keyword.ext e.g. 2447.py (filters by language)
|
|
.ext e.g. .py (all files of a language)
|
|
location/keyword e.g. zeta/2447
|
|
location/keyword.ext e.g. zeta/2447.py
|
|
location/.ext e.g. zeta/.py
|
|
"""
|
|
location = None
|
|
lang = None
|
|
if "/" in keyword:
|
|
parts = keyword.split("/", 1)
|
|
location = parts[0] or None
|
|
keyword = parts[1]
|
|
|
|
# Extract language from extension
|
|
if "." in keyword:
|
|
keyword_base, ext = keyword.rsplit(".", 1)
|
|
lang_candidate = Language.convert_ext(ext)
|
|
if lang_candidate is not Language.UNDEFINED:
|
|
lang = lang_candidate.value
|
|
else:
|
|
keyword_base = keyword
|
|
else:
|
|
keyword_base = keyword
|
|
|
|
storage = pathlib.Path(STORAGE_DIR)
|
|
if not storage.is_dir():
|
|
raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found")
|
|
|
|
entries = [] # (location, language, filename, is_completed)
|
|
|
|
for loc_dir in sorted(storage.iterdir()):
|
|
if not loc_dir.is_dir():
|
|
continue
|
|
loc_name = loc_dir.name
|
|
|
|
if location and loc_name != location:
|
|
continue
|
|
|
|
for lang_dir in sorted(loc_dir.iterdir()):
|
|
if not lang_dir.is_dir():
|
|
continue
|
|
lang_name = lang_dir.name
|
|
|
|
if lang and lang_name != lang:
|
|
continue
|
|
|
|
for f in lang_dir.iterdir():
|
|
if f.is_file() and keyword_base.lower() in f.stem.lower():
|
|
entries.append((loc_name, lang_name, f.stem, False))
|
|
|
|
completed_dir = lang_dir / "completed"
|
|
if completed_dir.is_dir():
|
|
for f in completed_dir.iterdir():
|
|
if f.is_file() and keyword_base.lower() in f.stem.lower():
|
|
entries.append((loc_name, lang_name, f.stem, True))
|
|
|
|
if completed is not None:
|
|
entries = [e for e in entries if e[3] == completed]
|
|
|
|
entries.sort(key=lambda e: (e[0], e[1], natural_sort_key(e[2])))
|
|
|
|
if not entries:
|
|
click.echo("No problems found.")
|
|
return
|
|
|
|
total = len(entries)
|
|
completed_count = sum(1 for e in entries if e[3])
|
|
uncompleted_count = total - completed_count
|
|
|
|
click.secho(
|
|
f"Found: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})",
|
|
fg="cyan",
|
|
bold=True,
|
|
)
|
|
click.echo()
|
|
|
|
current_loc = None
|
|
current_lang = None
|
|
for loc_name, lang_name, file_name, is_completed in entries:
|
|
if loc_name != current_loc:
|
|
current_loc = loc_name
|
|
current_lang = None
|
|
click.secho(f"[{loc_name}]", fg="green", bold=True)
|
|
|
|
if lang_name != current_lang:
|
|
current_lang = lang_name
|
|
click.secho(f" {lang_name}/", fg="yellow")
|
|
|
|
status = (
|
|
click.style("✓", fg="green")
|
|
if is_completed
|
|
else click.style("○", fg="white")
|
|
)
|
|
click.echo(f" {status} {file_name}.{lang_name}")
|
|
|
|
|
|
@click.command(name="fetchprob")
|
|
@click.argument("target", type=str, nargs=1, required=True)
|
|
@click.option("--force", "-f", is_flag=True, help="Overwrite existing HTML files")
|
|
def fetchprob(target: str, force: bool):
|
|
"""
|
|
Fetch BOJ problem HTML into storage/zeta/_static.
|
|
|
|
TARGET:
|
|
zeta/<id> Fetch one problem
|
|
zeta Fetch all detected problem ids under storage/zeta
|
|
"""
|
|
location, problem_id = parse_fetchprob_target(target)
|
|
if location != "zeta":
|
|
raise click.UsageError("only 'zeta' location is supported")
|
|
|
|
if problem_id is not None:
|
|
if _problem_static_html_path(problem_id).exists() and not force:
|
|
click.echo(f"{problem_id}: skipped (already exists)")
|
|
return
|
|
|
|
raw_html = fetch_boj_problem_html(problem_id)
|
|
offline_html = make_offline_problem_html(problem_id, raw_html, force=force)
|
|
result = save_problem_html(problem_id, offline_html, force=force)
|
|
if result == "skipped":
|
|
click.echo(f"{problem_id}: skipped (already exists)")
|
|
else:
|
|
click.echo(f"{problem_id}: fetched (offline processed + images)")
|
|
return
|
|
|
|
ids = collect_zeta_problem_ids()
|
|
if not ids:
|
|
click.echo("No problem ids found in storage/zeta")
|
|
return
|
|
|
|
attempted = len(ids)
|
|
fetched = 0
|
|
skipped = 0
|
|
failed = 0
|
|
|
|
for pid in ids:
|
|
try:
|
|
if _problem_static_html_path(pid).exists() and not force:
|
|
skipped += 1
|
|
click.echo(f"{pid}: skipped")
|
|
continue
|
|
|
|
raw_html = fetch_boj_problem_html(pid)
|
|
offline_html = make_offline_problem_html(pid, raw_html, force=force)
|
|
result = save_problem_html(pid, offline_html, force=force)
|
|
if result == "skipped":
|
|
skipped += 1
|
|
click.echo(f"{pid}: skipped")
|
|
else:
|
|
fetched += 1
|
|
click.echo(f"{pid}: fetched (offline processed + images)")
|
|
except click.ClickException as e:
|
|
failed += 1
|
|
click.echo(f"{pid}: failed ({e.message})")
|
|
|
|
click.echo()
|
|
click.secho(
|
|
(
|
|
f"Summary - attempted: {attempted}, fetched: {fetched}, "
|
|
f"skipped: {skipped}, failed: {failed}"
|
|
),
|
|
fg="cyan",
|
|
bold=True,
|
|
)
|
|
|
|
|
|
|
|
cli.add_command(run)
|
|
cli.add_command(load)
|
|
cli.add_command(init)
|
|
cli.add_command(export)
|
|
cli.add_command(state)
|
|
cli.add_command(show)
|
|
cli.add_command(find)
|
|
cli.add_command(fetchprob)
|
|
|
|
if __name__ == "__main__":
|
|
cli()
|