#!/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/: 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: , _, - """ 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'(]*?\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)}\"[^>]*>(.*?)" 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[^>]*>.*?", 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'
{mathml}
' 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'{mathml}' except Exception: return m.group(0) temp = re.sub(r"\$\$(.+?)\$\$", repl_block, temp, flags=re.DOTALL) temp = re.sub(r"(? 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( [ "
", f"

{fallback_label}

", f"{localized_content}", "
", ] ) ) for sample_type, sample_label in (("sampleinput", "예제 입력"), ("sampleoutput", "예제 출력")): sample_pattern = rf"]*id=\"{sample_type}(\d+)\"[^>]*>(.*?)" 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"(]*>.*?)", 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"]*>(.*?)", 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( [ "
", f"

{h2}

", pre_html, "
", ] ) ) if not blocks: body_fallback = ( "
" "

원본 페이지

" "

문제 본문 파싱에 실패하여 원본 HTML을 포함합니다.

" f"
{escape(raw_html[:100000])}
" "
" ) blocks.append(body_fallback) source_url = f"https://www.acmicpc.net/problem/{problem_id}" content_html = "\n".join(blocks) return f""" BOJ {problem_id} - Offline

{title}

{content_html}
""" def save_problem_html(problem_id: str, html: str, force: bool) -> str: """ Save html to storage/zeta/_static/.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/ 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()