import os import random import time from typing import Optional, Tuple, List import requests from fastapi import HTTPException, Header # ====== HTTP Session ====== SESSION = requests.Session() SESSION.headers.update({"User-Agent": "baekjoon-n8n-bot/1.0"}) # 필요한 언어만 관리(엄격 모드에서 제외할 대상) KNOWN_LANGS = ["ko", "en", "ja", "ru", "zh", "de", "fr", "es", "pt", "it"] def require_admin(x_admin_password: str | None = Header(default=None, alias="X-Admin-Password")): expected = env("ADMIN_PASSWORD", "") if not expected: raise HTTPException(status_code=500, detail="ADMIN_PASSWORD is not configured") if not x_admin_password or x_admin_password != expected: raise HTTPException(status_code=403, detail="invalid admin password") def fetch_json_with_retry(url: str, params: dict, retries: int = 3, timeout=(3.05, 10)) -> dict: last_err = None for i in range(retries): try: res = SESSION.get(url, params=params, timeout=timeout) res.raise_for_status() return res.json() except Exception as e: last_err = e time.sleep(0.7 * (2 ** i)) raise last_err def parse_csv(s: str) -> List[str]: return [x.strip() for x in (s or "").split(",") if x.strip()] def env(name: str, default: str = "") -> str: return os.getenv(name, default).strip() def build_lang_filter(lang: str) -> str: """ 사용자가 읽을 수 있는 언어가 '하나라도' 포함된 문제를 찾는 것이 목적입니다. -%en 처럼 제외 필터를 쓰면, 한국어와 영어가 모두 있는 양질의 문제가 제외되므로 긍정 필터(%ko) 위주로 구성합니다. """ raw = (lang or "all").strip().lower() if raw in ("all", ""): return "" allow = set(parse_csv(raw)) & set(KNOWN_LANGS) if not allow: return "" # 여러 언어를 선택했을 경우(예: ko,en) -> (%ko | %en) # 즉, 한국어 '또는' 영어 중 하나라도 지문이 있는 문제 if len(allow) == 1: return f"%{next(iter(allow))}" else: expr = " | ".join(f"%{c}" for c in sorted(allow)) return f"({expr})" def resolve_difficulty(difficulty: Optional[str], difficulty_mode: str) -> str: if difficulty and difficulty.strip(): return difficulty.strip() mode = (difficulty_mode or env("DIFFICULTY_MODE_DEFAULT", "easy")).lower() if mode == "easy": return env("DIFFICULTY_EASY", "6..10") if mode == "hard": return env("DIFFICULTY_HARD", "11..15") if mode == "all": return env("DIFFICULTY_ALL", "1..30") return env("DIFFICULTY_EASY", "6..10") def resolve_tags(tags_csv: Optional[str], tag_mode: str) -> List[str]: """ tags_csv(쿼리)가 있으면 그것이 최우선. 없으면 tag_mode 프리셋 기반으로 선택. TAG_PICK_* 정책에 따라 랜덤 1개 / 전체 / 필터 없음으로 결정. """ if tags_csv is not None: return parse_csv(tags_csv) mode = (tag_mode or env("TAG_MODE_DEFAULT", "easy")).lower() if mode == "easy": preset = parse_csv(env("TAGS_EASY", "")) pick = env("TAG_PICK_EASY", env("TAG_PICK", "random")).lower() elif mode == "hard": preset = parse_csv(env("TAGS_HARD", "")) pick = env("TAG_PICK_HARD", env("TAG_PICK", "random")).lower() elif mode == "all": preset = parse_csv(env("TAGS_ALL", "")) pick = env("TAG_PICK_ALL", env("TAG_PICK", "none")).lower() else: preset = parse_csv(env("TAGS_EASY", "")) pick = env("TAG_PICK_EASY", "random").lower() if pick == "none": return [] if pick == "random": return [random.choice(preset)] if preset else [] return preset def build_query(difficulty: str, tags: List[str], lang: str) -> str: # 1. 난이도 기본 조건 query_parts = [f"*{difficulty}"] # 2. 태그 조건 (괄호로 감싸서 우선순위 확보) if tags: join_op = env("TAGS_JOIN", "or").lower() if join_op == "and": # 모든 태그가 다 있어야 함: tag:a tag:b tag_expr = " ".join(f"tag:{t}" for t in tags) else: # 태그 중 하나만 있어도 됨: (tag:a | tag:b) tag_expr = "(" + " | ".join(f"tag:{t}" for t in tags) + ")" query_parts.append(tag_expr) # 3. 언어 조건 (괄호로 감싸기) lang_filter = build_lang_filter(lang) if lang_filter: # 언어 필터가 복합적일 수 있으므로 괄호 처리 query_parts.append(f"({lang_filter})") # 결과 예시: *6..10 (tag:dp | tag:bfs) (%ko) return " ".join(query_parts) def get_problem(query: str, size: int = 50) -> Tuple[Optional[int], Optional[str], Optional[int]]: url = "https://solved.ac/api/v3/search/problem" params = { "query": query, "sort": "random", "direction": "desc", "page": 1, "size": size, } try: data = fetch_json_with_retry(url, params=params) items = data.get("items", []) if not items: return None, None, None p = random.choice(items) return p.get("problemId"), (p.get("titleKo") or p.get("titleEn") or "제목 없음"), p.get("level") except Exception: return None, None, None