refactoring stage 1

- add StorageManager StateManager
- use pathlib

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-03 01:09:31 +09:00
parent b9a8ea6bad
commit 21d51e4af9

828
run.py
View File

@@ -1,37 +1,89 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os
import sys
import pathlib
import base64 import base64
from enum import Enum, unique, auto import os
from dataclasses import dataclass import pathlib
import re import re
import subprocess import subprocess
import sys
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from dataclasses import dataclass
from enum import Enum, auto, unique
from html import escape, unescape from html import escape, unescape
import yaml
import click import click
import yaml
try: try:
from latex2mathml.converter import convert as latex_to_mathml from latex2mathml.converter import convert as latex_to_mathml
except ImportError: except ImportError:
latex_to_mathml = None latex_to_mathml = None
CFG_PATH = "./config.yml" __version__ = "2.0.0" # new version
STATE_PATH = "./state.yml"
TC_DIR = "./_testcases" CFG_PATH = pathlib.Path("./config.yml")
STATE_PATH = pathlib.Path("./state.yml")
TEMPLATES_DIR = "./templates" TC_DIR = pathlib.Path("./_testcases")
TEMPLATES_DIR = pathlib.Path("./templates")
SRC_SPACE_DIR = pathlib.Path("./space")
STORAGE_DIR = pathlib.Path("./storage")
BUILD_DIR = pathlib.Path("./build")
SRC_SPACE_DIR = "./space"
STORAGE_DIR = "./storage" # ======
# Helper Functions
# ======
BUILD_DIR = "./build"
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 _dispatch_target(target: str) -> tuple[str, str]:
splited = target.split("/")
if len(splited) != 2:
raise ValueError("Invalid target format. Expected format: <loc>/<problem>")
return splited[0], splited[1]
@unique @unique
@@ -170,551 +222,155 @@ class Language(Enum):
return Language.UNDEFINED return Language.UNDEFINED
def natural_sort_key(s: str): class StateManager:
"""Natural sort key: splits string into text/number chunks for proper ordering.""" _instance: "StateManager | None" = None
return [int(c) if c.isdigit() else c.lower() for c in re.split(r"(\d+)", s)] _state: dict | None = None
def __new__(cls) -> "StateManager":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def parse_range_string(range_str: str) -> list[int]: def load(self) -> dict:
""" if self._state is not None:
범위 문자열을 정수 리스트로 변환. return self._state
* ..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: try:
mathml = latex_to_mathml(expr) with open(STATE_PATH, "r", encoding="utf-8") as f:
return f'<div class="math-block">{mathml}</div>' self._state = yaml.safe_load(f) or {}
except Exception: except FileNotFoundError as exc:
return m.group(0) raise click.ClickException(f"state.yml not found at {STATE_PATH}") from exc
except yaml.YAMLError as e:
raise click.ClickException(f"state.yml parse error: {e}") from e
return self._state # type: ignore[return-value]
def repl_inline(m: re.Match) -> str: def save(self) -> None:
expr = unescape(m.group(1).strip()) if self._state is None:
if not expr: return
return m.group(0) with open(STATE_PATH, "w", encoding="utf-8") as f:
try: yaml.safe_dump(self._state, f)
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) def get_space(self, lang: str) -> dict | None:
temp = re.sub( state = self.load()
r"(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)", repl_inline, temp, flags=re.DOTALL return state.get("space", {}).get(lang)
)
for i, block in enumerate(protected_blocks): def set_space(self, lang: str, data: dict) -> None:
temp = temp.replace(f"@@PROTECTED_{i}@@", block) state = self.load()
if "space" not in state:
state["space"] = {}
state["space"][lang] = data
self._state = state
return temp def clear_space(self, lang: str) -> None:
state = self.load()
if "space" in state and lang in state["space"]:
del state["space"][lang]
self._state = state
def reload(self) -> None:
self._state = None
def make_offline_problem_html(problem_id: str, raw_html: str, force: bool) -> str: class StorageManager:
""" _instance: "StorageManager | None" = None
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] = [] def __new__(cls) -> "StorageManager":
core_specs = [ if cls._instance is None:
("problem_description", "문제"), cls._instance = super().__new__(cls)
("problem_input", "입력"), return cls._instance
("problem_output", "출력"),
("problem_limit", "제한"),
("problem_hint", "힌트"),
]
for content_id, fallback_label in core_specs: def register_location(self, location: str) -> None:
content = _extract_html_by_id(raw_html, "div", content_id) path = STORAGE_DIR / location
if not content or not content.strip(): os.makedirs(path, exist_ok=True)
continue
localized_content = _localize_images_in_html( def check_location(self, location: str) -> bool:
problem_id, path = STORAGE_DIR / location
content, return os.path.isdir(path)
force=force,
def register_location_lang(self, location: str, lang: Language) -> None:
path = STORAGE_DIR / location / lang.value
os.makedirs(path, exist_ok=True)
def check_location_lang(self, location: str, lang: Language) -> bool:
path = STORAGE_DIR / location / lang.value
return os.path.isdir(path)
def check_get_file(
self, location: str, lang: Language, filename: str
) -> pathlib.Path | None:
completed_path = (
STORAGE_DIR
/ location
/ lang.value
/ "completed"
/ f"{filename}.{lang.value}"
) )
localized_content = _render_math_expressions(localized_content) uncompleted_path = (
STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}"
blocks.append(
"\n".join(
[
'<article class="section">',
f"<h2>{fallback_label}</h2>",
f"{localized_content}",
"</article>",
]
)
) )
for sample_type, sample_label in ( if os.path.isfile(completed_path):
("sampleinput", "예제 입력"), return completed_path
("sampleoutput", "예제 출력"), elif os.path.isfile(uncompleted_path):
): return uncompleted_path
sample_pattern = rf"<section[^>]*id=\"{sample_type}(\d+)\"[^>]*>(.*?)</section>" else:
sample_matches = list( return None
re.finditer(sample_pattern, raw_html, flags=re.DOTALL | re.IGNORECASE)
def save_file(
self, location: str, lang: str, filename: str, content: bytes, completed: bool
) -> None:
path = STORAGE_DIR / location / lang / ("completed" if completed else "")
os.makedirs(path, exist_ok=True)
with open(f"{path}/{filename}", "wb") as f:
f.write(content)
def read_file(self, location: str, lang: Language, filename: str) -> bytes:
path = self.check_get_file(location, lang, filename)
if path is None:
raise FileNotFoundError(
f"File not found in storage: {location}/{filename}.{lang.value}"
)
file_path = f"{path}/{filename}"
if not os.path.isfile(file_path):
raise FileNotFoundError(f"File not found in storage: {file_path}")
with open(file_path, "rb") as f:
return f.read()
def mark_completed(self, location: str, lang: Language, filename: str) -> None:
uncompleted_file = (
STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}"
) )
sample_matches.sort(key=lambda m: int(m.group(1))) completed_file = (
STORAGE_DIR
for m in sample_matches: / location
idx = m.group(1) / lang.value
section_html = m.group(2) / "completed"
pre_match = re.search( / f"{filename}.{lang.value}"
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}" os.makedirs(completed_file.parent, exist_ok=True)
content_html = "\n".join(blocks) if os.path.isfile(uncompleted_file):
os.rename(uncompleted_file, completed_file)
return f"""<!DOCTYPE html> def unmark_completed(self, location: str, lang: Language, filename: str) -> None:
<html lang=\"ko\"> uncompleted_file = (
<head> STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}"
<meta charset=\"UTF-8\" /> )
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" /> completed_file = (
<title>BOJ {problem_id} - Offline</title> STORAGE_DIR
<style> / location
:root {{ / lang.value
--bg: #fafaf8; / "completed"
--paper: #ffffff; / f"{filename}.{lang.value}"
--ink: #1e1f24; )
--muted: #6a6d75;
--line: #d8dce3; os.makedirs(uncompleted_file.parent, exist_ok=True)
--accent: #0d6e6e; if os.path.isfile(completed_file):
--code-bg: #f4f6fb; os.rename(completed_file, uncompleted_file)
}}
* {{ 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: # =======================
""" # MAIN CLI LOGIC
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() @click.group()
@@ -722,14 +378,29 @@ def cli():
pass pass
@click.command(name="register")
@click.argument("location", type=str, nargs=1, required=True)
def register(location: str):
"""
새로운 location을 storage에 등록하는 명령어.
"""
storage_manager = StorageManager()
if storage_manager.check_location(location):
click.echo(f"Location '{location}' is already registered.")
else:
storage_manager.register_location(location)
click.echo(f"Location '{location}' registered successfully.")
@click.command(name="run") @click.command(name="run")
@click.option("--from", "from_", type=str, required=True) @click.argument("target", type=str, nargs=1, required=True)
@click.option("--target", "-t", default=["1"], multiple=True) @click.option("--testcase", "-t", default=["1"], multiple=True)
@click.option("--verbose/--no-verbose", "-v/-nv", default=True) @click.option("--verbose/--no-verbose", "-v/-nv", default=True)
def run(from_: str, target: str, verbose: bool): def run(target: str, testcase: str, verbose: bool):
""" """
지정된 언어로 빌드 및 실행하는 명령어 지정된 언어로 빌드 및 실행하는 명령어
""" """
loc, prob = _dispatch_target(target)
# Language 확인 # Language 확인
from_language: Language = Language.convert_name(from_) from_language: Language = Language.convert_name(from_)
@@ -760,7 +431,7 @@ def run(from_: str, target: str, verbose: bool):
# 테케 분석 # 테케 분석
try: try:
tcs: list[int] = parse_range_string_list(target) tcs: list[int] = _parse_range_string_list(target)
except ValueError as e: except ValueError as e:
raise click.ClickException(e) raise click.ClickException(e)
@@ -1148,7 +819,9 @@ def show(filter: str | None, completed: bool, show_all: bool):
continue continue
# uncompleted files # uncompleted files
for f in sorted(lang_dir.iterdir(), key=lambda p: natural_sort_key(p.stem)): for f in sorted(
lang_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem)
):
if f.is_file(): if f.is_file():
entries.append((loc_name, lang_name, f.stem, False)) entries.append((loc_name, lang_name, f.stem, False))
@@ -1156,7 +829,7 @@ def show(filter: str | None, completed: bool, show_all: bool):
completed_dir = lang_dir / "completed" completed_dir = lang_dir / "completed"
if completed_dir.is_dir(): if completed_dir.is_dir():
for f in sorted( for f in sorted(
completed_dir.iterdir(), key=lambda p: natural_sort_key(p.stem) completed_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem)
): ):
if f.is_file(): if f.is_file():
entries.append((loc_name, lang_name, f.stem, True)) entries.append((loc_name, lang_name, f.stem, True))
@@ -1275,7 +948,7 @@ def find(keyword: str, completed: bool | None):
if completed is not None: if completed is not None:
entries = [e for e in entries if e[3] == completed] entries = [e for e in entries if e[3] == completed]
entries.sort(key=lambda e: (e[0], e[1], natural_sort_key(e[2]))) entries.sort(key=lambda e: (e[0], e[1], _natural_sort_key(e[2])))
if not entries: if not entries:
click.echo("No problems found.") click.echo("No problems found.")
@@ -1316,70 +989,9 @@ def find(keyword: str, completed: bool | None):
@click.argument("target", type=str, nargs=1, required=True) @click.argument("target", type=str, nargs=1, required=True)
@click.option("--force", "-f", is_flag=True, help="Overwrite existing HTML files") @click.option("--force", "-f", is_flag=True, help="Overwrite existing HTML files")
def fetchprob(target: str, force: bool): def fetchprob(target: str, force: bool):
""" """(disabled) Fetch BOJ problem HTML - BOJ is closing, keeping for future multi-location support."""
Fetch BOJ problem HTML into storage/zeta/_static. del target, force
raise click.ClickException("fetchprob is currently disabled (BOJ closing)")
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(run)